Back to blog
· 8 min read · #Rust#data-analysis#GPUI

Building data analysis tools with Rust and GPUI

How to build performant data analysis desktop tools with Rust: large datasets, real-time visualization, and GPU rendering.

hmziqrs
hmziqrs @hmziqrs

Data analysis tools have a performance problem. Jupyter notebooks choke on datasets over a few million rows. Tableau spins on complex joins. Even well-written Python tools hit GIL bottlenecks when the UI thread and the computation thread want the same cores.

I have been building data analysis desktop tools with Rust and GPUI, and the difference is stark. A CSV viewer that loads 10 million rows in under a second. Real-time charts that redraw at 60fps while ingesting streaming data. Memory usage that stays flat because you control every allocation.

Here are the architecture patterns that make this work.

The data analysis workload

Data analysis tools do three things at scale: load, transform, and visualize. Each stage has different performance characteristics and different failure modes.

Loading means parsing CSV, Parquet, JSON, or custom binary formats into structured memory. At 100MB, any language handles this fine. At 10GB, your choices start to matter. At 100GB, you need to think about memory-mapped files and lazy evaluation.

Transformation means filtering, aggregating, joining, and computing derived columns. This is where CPU-bound work hits hardest. A group-by over 50 million rows is a real benchmark, and Python’s pandas takes several seconds for it. Polars, written in Rust, does it in under one.

Visualization means rendering the results as charts, tables, or heatmaps that update in real time as the user interacts. This is where most frameworks fall apart. Rendering 100,000 points in a scatter plot requires GPU acceleration. Updating it at 30Hz while the user drags a filter slider requires a tight render loop.

Rust addresses the first two stages through its zero-cost abstractions and fearless concurrency. GPUI addresses the third through direct GPU rendering. The combination works because you never leave the same memory space. There is no serialization boundary between the computation layer and the UI layer, and no IPC overhead. The chart widget reads from the same Vec that the transform pipeline writes to.

Handling large datasets in Rust

The first decision is how to represent data in memory. For tabular data, columnar storage is significantly faster than row-oriented storage for analytical workloads. A filter on the “price” column touches one contiguous array instead of striding through heterogeneous structs.

Here is a minimal columnar table representation:

pub enum Column {
Float64(Vec<f64>),
Int64(Vec<i64>),
String(Vec<String>),
Boolean(Vec<bool>),
}
pub struct DataFrame {
columns: HashMap<String, Column>,
row_count: usize,
}
impl DataFrame {
pub fn filter(&self, column: &str, predicate: impl Fn(f64) -> bool) -> Self {
let col = match self.columns.get(column) {
Some(Column::Float64(values)) => values,
_ => panic!("column not found or wrong type"),
};
let mut indices = Vec::with_capacity(self.row_count / 4);
for (i, &val) in col.iter().enumerate() {
if predicate(val) {
indices.push(i);
}
}
// Rebuild only the columns we need
let mut new_columns = HashMap::new();
for (name, column) in &self.columns {
new_columns.insert(
name.clone(),
column.gather(&indices),
);
}
DataFrame {
columns: new_columns,
row_count: indices.len(),
}
}
}

For datasets that do not fit in memory, memory-mapped files are the way to go. The memmap2 crate gives you a byte slice backed by the OS page cache. You parse on demand, loading only the columns and rows the user is looking at.

use memmap2::Mmap;
use std::fs::File;
pub struct MappedTable {
mmap: Mmap,
schema: Vec<ColumnType>,
row_offsets: Vec<usize>, // byte offset for each row
}
impl MappedTable {
pub fn open(path: &Path) -> io::Result<Self> {
let file = File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
// Parse header, build index of row offsets
// ...
}
pub fn read_cell(&self, row: usize, col: usize) -> CellValue {
let offset = self.row_offsets[row];
// Parse from the mmap at offset + column_position
// Zero-copy: no allocation unless it is a string
}
}

You do not need to load everything. A data grid displaying 50 visible rows out of 10 million should only touch those 50 rows. Virtualize at the data layer, not just the UI layer.

Real-time visualization with GPUI

GPUI renders directly through Metal on macOS and Vulkan on Linux. There is no DOM and no garbage collection pauses. The render pipeline is explicit: you tell it what to draw, and it draws it.

For charts, this means you can render thousands of data points per frame without trouble. Here is how a basic line chart view works:

pub struct LineChartView {
data: Vec<(f64, f64)>,
x_range: (f64, f64),
y_range: (f64, f64),
}
impl Render for LineChartView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.size_full()
.canvas(
move |bounds, _, cx| {
let mut path = gpui::Path::new(bounds.origin);
let (x_min, x_max) = self.x_range;
let (y_min, y_max) = self.y_range;
for (i, (x, y)) in self.data.iter().enumerate() {
let px = bounds.origin.x
+ ((*x - x_min) / (x_max - x_min)) as f32 * bounds.size.width;
let py = bounds.origin.y
+ bounds.size.height
- ((*y - y_min) / (y_max - y_min)) as f32 * bounds.size.height;
if i == 0 {
path.move_to(point(px, py));
} else {
path.line_to(point(px, py));
}
}
cx.paint_path(path, gpui::fill(gpui::black()));
},
)
}
}

The canvas primitive gives you direct drawing access. For a scatter plot with 100,000 points, you would batch them into a single draw call using instanced rendering rather than issuing one call per point. GPUI’s paint API handles this batching internally when you use its path primitives.

For real-time data, you combine this with GPUI’s reactive model. The chart holds a reference to shared state. When new data arrives, you mutate the state and call cx.notify(). GPUI re-renders only the views that depend on the changed state.

pub struct StreamingChart {
buffer: Vec<f64>,
max_points: usize,
}
impl StreamingChart {
pub fn push_value(&mut self, value: f64, cx: &mut ViewContext<Self>) {
self.buffer.push(value);
if self.buffer.len() > self.max_points {
self.buffer.remove(0);
}
cx.notify(); // triggers re-render
}
}

This model is efficient because GPUI tracks dependencies at the view level. A streaming chart updating at 60Hz does not cause the filter panel or the data grid to re-render. Each view owns its state and only repaints when its own state changes.

Keeping the UI responsive during heavy computation

The main thread in a GPUI app handles rendering and input. Any blocking work on this thread causes dropped frames. The fix is to move computation to background tasks and communicate results back through channels.

use std::sync::mpsc;
pub fn run_analysis(
dataframe: DataFrame,
query: AnalysisQuery,
cx: &mut AppContext,
callback: impl FnOnce(AnalysisResult) + 'static,
) {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = dataframe
.filter(&query.filter_column, |v| v > query.threshold)
.aggregate(&query.group_by, query.aggregation);
tx.send(result).ok();
});
cx.spawn(async move {
let result = rx.recv().unwrap();
callback(result);
}).detach();
}

GPUI’s spawn runs the future on the main thread, so the callback can safely update view state. The heavy computation happens on a separate thread. The channel is bounded: if the UI is busy rendering, the background thread blocks instead of queuing up unbounded results.

For very large datasets, a single background thread is not enough. Rayon’s work-stealing thread pool parallelizes the column scans:

use rayon::prelude::*;
impl DataFrame {
pub fn parallel_filter(&self, column: &str, predicate: impl Fn(f64) -> bool + Sync) -> Self {
let col = match self.columns.get(column) {
Some(Column::Float64(values)) => values,
_ => panic!("column not found"),
};
let indices: Vec<usize> = col
.par_iter()
.enumerate()
.filter_map(|(i, &v)| {
if predicate(v) { Some(i) } else { None }
})
.collect();
// Rebuild filtered frame...
}
}

On an 8-core machine, this cuts filter time roughly 6x for large columns. The remaining 25% overhead comes from the work-stealing scheduler and cache effects. For a 50 million row float column, I have measured filter times under 200ms with Rayon versus 1.2s single-threaded.

Virtualized data grids

A data grid showing 10 million rows needs to render about 50 at a time. The rest exist in memory but never become UI elements. GPUI handles this through a pattern where you track scroll position and render only the visible window.

pub struct VirtualGrid {
data: Arc<DataFrame>,
scroll_offset: f32,
row_height: f32,
visible_rows: usize,
}
impl Render for VirtualGrid {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let first_visible = (self.scroll_offset / self.row_height) as usize;
let visible_count = self.visible_rows.min(
self.data.row_count - first_visible
);
div()
.size_full()
.overflow_y_scroll()
.on_scroll(cx.listener(|this, event: &ScrollEvent, cx| {
this.scroll_offset = event.offset.y;
cx.notify();
}))
.children(
(first_visible..first_visible + visible_count)
.map(|row_idx| {
self.render_row(row_idx)
})
)
}
}

The scroll container fires events that update scroll_offset, which triggers a re-render showing the new visible window. There is no virtual DOM diffing or reconciliation. Just a new set of 50 row elements.

Tradeoffs worth knowing

GPUI is not without friction. The framework is primarily developed for Zed, so the API surface reflects Zed’s needs first. Documentation is improving but still thin compared to Electron or Qt. If you need Windows support today, you are out of luck; GPUI currently targets macOS and Linux.

The Rust compilation cycle adds friction during rapid prototyping. Iterating on chart aesthetics takes longer than it would in a JavaScript notebook. Incremental compilation helps, but a clean build still takes time.

On the other hand, once the tool works, it stays fast. There are no GC pauses, no memory leaks from dangling event listeners, and no surprise O(n^2) layout recalculation when you add one more column. The performance characteristics are predictable and stable, which matters for a tool someone uses for hours at a time.

Putting it together

The architecture I described here is columnar data storage, GPU-accelerated rendering, background computation with foreground UI updates, and virtualized grids. These patterns work together because Rust gives you control over memory and concurrency, and GPUI gives you control over rendering.

If you want to explore this stack, check out gpui-starter, a boilerplate project that sets up GPUI with SQLite persistence, i18n, theming, and auto-updates out of the box. It handles the infrastructure so you can focus on building the actual analysis features. The source is on GitHub.

For more on GPUI fundamentals, see the getting started guide and the desktop app architecture post.