A Critique of GPUI

Hacker News by 22 min read 504x views
A Critique of GPUI

Share Post

Recently, there has been a trend of additional desktop GUI apps being built using native rust GUI frameworks. And there are now equal fine known speck apps getting authoritative rewrites: Discord announced their app is being rewritten using GPUI. Meanwhile, a lot of the LLM-clones being published appear to choose egui. In this post, I desire to explain why I am not certain those frameworks are a good basis to build on.

To put it plainly: I accept the two GPUI’s and egui’s province models are dead ends for complex desktop applications. Both are notable projects, and they have their place. Zed genuinely feels accelerated and nice to use, and I use egui myself for debug interfaces in bevy games. But I don’t think those frameworks should be the default choice for native desktop apps.

I desire to create two arguments:

  • Every UI architecture ends up needing to cognize what changed and who depends on it, or achievement characteristics volition not scale. A example whose state model cannot say so makes the use say it by hand, at the disbursal of whatever made that framework’s API semantics appealing in the archetypal place.
  • Once you acknowledge you desire that precision, components that run once, with signals and reactive holes, are the simplest API for it.

Disclosure: I ported GPUI’s current Linux renderer to wgpu, I built the benchmark harness used below, and I activity on zgui, a signal-based Rust example that appears in the charts.

The lone inquiry that matters

Raph Levien quotes Ron Minsky: “hidden inner of all UI example is some kind of incrementalization framework.” A UI is a function of state, and state changes a small at a time. But if that function becomes expensive, you might desire to commencement looking for ways to get a faster way to update pixels on the display from an incremental province change. So all example has to answer one question: whenever one value changes, what runs?

ModelWhat a compose runsWho finds the change
Immediate (egui)All UI code, next frameNobody
Elm (Iced)All of view, afterward a diffThe diff
Views (GPUI)Every perspective without a cacheYou, alongside notify
Signals, re-run (Freya, Compose)Components that peruse itThe framework
Signals, run formerly (Floem, Solid, zgui)Closures that peruse itThe framework

I comprehend why group akin the archetypal three. No province to synchronize, one place anywhere province lives, a render function that is plain Rust, no macros or separate compiler. I’m gonna create the disagreement that these approaches don’t scale, and fixing the underlying issue defeats what makes these models simple and uncomplicated to logic about.

GPUI: notify is zone.js

GPUI calls itself “a hybrid contiguous and retained mode” framework. It retains province in entities. The UI is defined in render functions, changing state must trigger a rerun.

impl Render for Sidebar { fn render( &mut self, _: &mut Window, cx: &mut Context<Self>, ) -> impl IntoElement { let inbox = self.inbox.read(cx); div() .child(format!("{} unread", inbox.unread())) .children(inbox.threads.iter().map(|t| t.title.clone())) } }

It is average Rust. You can use if, for and local variables, and there aren’t any weird wrappers. State changes necessitate an definitive notification:

self.threads[i].unread = false; cx.notify();

notify carries the being id. Since Rust doesn’t have a compose obstacle on &mut, GPUI cannot see which site changed (or whether item changed at all). The Angular squad wrote this concerning zone.js in 2023: it “is lone capable of notifying us whenever item might have happened”, and “This method has not scaled well.” notify is additional exact than a zone, since it names an entity, but ultimately it makes the identical benevolent of statement. Below that id, GPUI knows nothing, so it re-renders all perspective that is not rearward a valid .cached() boundary. And in practice, GPUI frequently rerender all view, because .cached() is rarely used by use authors, and imposes certain restrictions on layout semantics.

GPUI has a position of being “fast”. And during constants power be relatively low, asymptotically, it is leaving a lot on the table. So I ran a few benchmarks, that specifically test how much a sparse province alter in a ample UI really costs.

At 3,000 live rows alongside a cached perspective per row, changing one tag takes 8.4 ms. A hover, a keystroke and penning a value equal to the old one all disbursal about the same, since the disbursal follows the tree and not the change. It grows by about 3 ms per 1,000 live rows. Write the rows as plain elements, as in the code above, and the identical tag takes 92 ms. A hand-written equality defender takes the equal compose from 7.9 ms to 0.9 µs (demonstrating how much is remaining for each call location to find).

On a linear axis, the disbursal per row is the slopeOn a linear axis, the disbursal per row is the slope

This doesn’t average that all GPUI app is slow, but it shows the bounds of the state model. Especially alongside additional costly layout, you do hit this wall in practice. Zed power awareness fast, but the fact is that Zed is a relatively simple app, UI wise. The app’s chrome contains a few buttons, in a simple layout, a sidebar and a content editor, and the content publishing company is implemented as a custom element, entirely bypassing GPUI’s layout and perspective semantics. By their own account, a opening “has tens of nodes and a few thousand elements”.

Making GPUI exact defeats GPUI

The apparent fix is to keep more. Zed has experimental branches for this, and Longbridge’s gpui-fast is a fork that keeps all view’s subtree between frames. It works, inside a limit: the smallest item either one can reuse is a view. So to update one tag without rebuilding the sidebar, the label have to be a view:

pub struct UnreadBadge { unread: usize, _watch: Subscription } impl UnreadBadge { pub fn new(inbox: Entity<Inbox>, cx: &mut Context<Self>) -> Self { let unread = inbox.read(cx).unread(); let _watch = cx.observe(&inbox, |this, inbox, cx| { let unread = inbox.read(cx).unread(); if unread != this.unread { this.unread = unread; cx.notify(); } }); Self { unread, _watch } } } impl Render for UnreadBadge { fn render( &mut self, _: &mut Window, _: &mut Context<Self>, ) -> impl IntoElement { format!("{} unread", self.unread) } }

Its owner afterward has to create it and keep the handle:

let sign = cx.new(|cx| UnreadBadge::new(inbox.clone(), cx));

That is an entity, a Render impl, a subscription, a stored copy of the value, a hand-written defender and an owner, all to province that one tag depends on one count. In a indication example it is this:

let unread = Memo::new(move |_| { threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count()) }); Label::derived(move || format!("{} unread", unread.get()))

The remainder of GPUI’s API plant against this too. Render takes no props, so parents shove values into kid entities during their own render. RenderOnce components, which is what buttons and rows are, diagram inner their parent’s view and are no border at all. List rows are closures, so a row perspective means managing a keyed map of entities by hand.

And it lone helps anywhere province happens to row up alongside views. I reran gpui-fast’s own 60-panel benchmark, drew all example once, and afterward moved the state:

Retention pays lone anywhere province already matches the viewsRetention pays lone anywhere province already matches the views

With province owned by all panel, one alter saves 83%. With a shared model, retention makes the identical example 11% slower. The opening and the edit are identical. In genuine Zed, the Zed squad measured preservation “1–3% slower than off on average workloads”, established that “Most of the real-Zed win” came from other work, and warned that “microbenchmark wins didn’t foretell real-Zed results”. gpui-fast is genuine advancement and expenses small to adopt: it takes the 3,000-row tag from 8.4 to 3.4 ms. But that example renders three views and lays out one element, and motionless spends 3.4 ms, since the example is motionless a walk complete the entire tree.

The web has already walked this road. Redux’s manner guide says “Prefer having more UI components subscribed to the Redux shop and study data at a more granular level”. MobX says to “create many of small observer components”. Both inform you to create a border per projection, manually, because the example can’t infer it.

This is the part I discover hardest to get past. People choose GPUI since render is a plain function of state. A UI anywhere all projection is a separately owned entity alongside a subscription and a defender is no longer that. It is a indication graph built by hand from much heavier parts, anywhere a missed border is silent. Zed’s own design note says “Missing notifications are use bugs”. Keep the pleasant API and you get view-sized updates. Take the precision and you endure the pleasant API. A signals crate on top would not alter the trade, since its smallest descend is motionless a view. Reaching one node alongside no render operating needs writes the example can see and nodes that outlive a frame, and that method a new province example and a new component contract.

Elm: deceptively uncomplicated at small scales

At the size it is normally shown, the Elm architecture is a deceptively simple UI model: one province value, one update, one view, and all alter has a name. While ample applications do use it successully, I do desire to make the item that this clarity belongs to the small case, and in a complex application signals and stores are arguably easier to logic about.

Take the identical unread count. It have to be correct following all message, so either view recomputes it on all update or update maintains it:

match communication { InboxMessage::MarkRead(i) => { if self.threads[i].unread { self.threads[i].unread = false; self.unread -= 1; } } InboxMessage::Delete(i) => { if self.threads.remove(i).unread { self.unread -= 1; } } InboxMessage::Arrived(thread) => { self.unread += thread.unread as usize; self.threads.push(thread); } }

The definition of unread is now nowhere in particular. It is dispersed across every arm that touches a thread, and all new communication has to recall it. The compiler checks that the equivalent is exhaustive, but it does not inspect the arithmetic. The memo complete has one definition, and a new author cannot forget it.

The identical item happens alongside location. Where does a hover province or a half-typed draft live? Single origin of fact says in the model, alongside a communication type routed downward and an outcome routed rear up. Elm’s guide: “Actively trying to make components is a formula for catastrophe in Elm.”. Iced recommends returning an Action to the parent. Iced’s maintainer says components in this architecture continually end “with pain”, and additionally makes the finest case for the discipline: avoiding them “forces you to think difficult concerning your data model”. Okay, I conjecture that’s a fair argument. But cosmic-files has 136 top-level communication variants and 73 additional for a tab, affirmative a Tab::update that returns commands for the genitor to interpret. Everything in that is explicit, and none of it is simple.

The architecture additionally has the identical matter GPUI has. The update implementation tells you what a communication writes, it cannot inform you what peruse the field. Anything inner the perspective might. Elm’s architecture doesn’t answer “what changed?”, it answers “what happened?”. Session export power be helpful in some scenarios, but it is not helpful for scalable achievement characteristics.

This is what I peruse in the web’s history. Redux is the best-known related of this design, and its docs peruse akin a site report. Treat a part of your store “as if it were a database”. Selectors “will be re-run following every dispatched action”. The center bundle is “obsolete”, replaced by a toolkit made to eliminate boilerplate. Outside React, the mainstream has moved to tracked reads. Angular, Vue, Svelte and Solid build on signals or something equivalent, Preact offers them, and Svelte’s squad put it this way: “we’ve come to the realisation that Knockout was correct all along.” Inside React, Jotai and MobX bring tracking in as libraries, and Legend-State equal offers a “render once” style. Most applications are motionless on React, and React’s team disagrees, which I arrive rear to below. So the convergence is on the province model, outside one ample exception.

Performance follows the identical line. Since Iced rebuilds all of perspective after every lot of messages, one tag at 3,000 rows builds 39,023 widgets and takes 7.3 ms. Lazy on all row lone gets that to 5.7 ms, since view still has to sojourn all row to hash its key. The authoritative lazy example keys on a version: u8 that four update arms bump by hand. That is a dirty flag alongside a nicer name. In fairness, cosmic-files and Halloy use no lazy at all, and on a virtualized catalog Iced is among the fastest frameworks I measured, at 0.19 ms. A bounded display rebuilds cheaply. But asymptotic complexity is clear. And akin to GPUI, the API semantics merely can’t express the necessary information.

There is really precedence for this: Vizia shows what happens whenever you add precision to this model. It moved from lenses to signals in March 2026 and kept its Model and event dispatch. This is its counter, shortened:

pub enum AppEvent { Increment, } impl Model for AppData { fn event(&mut self, _: &mut EventContext, event: &mut Event) { event.map(|app_event, _| match app_event { AppEvent::Increment => self.count.update(|count| *count += 1), }); } }
let figure = Signal::new(0); AppData { figure }.build(cx); Button::new(cx, |cx| Label::new(cx, "Increment")) .on_press(|cx| cx.emit(AppEvent::Increment)); Label::new(cx, count).width(Pixels(50.0)).live(Live::Polite);

The tag says the indication directly, and the identical closure that builds the button created it. The enum, the dispatch and the handler be to transport one call to update. Vizia was never strict Elm, since its views are built once, and signals were a sensible move: it got per-field writes. But appearance at what remains of Elm’s reasons. State is a chart of mutable cells, Model is a bag of handles, and event is a &mut oneself flank effect. You now have two systems for saying that item changed. So my exception to adding signals to the Elm architecture really is the identical as alongside GPUI: they work, and they eliminate what you came for.

egui’s throw is the finest in the field: “You don’t have to concern concerning app state and GUI province being out-of-sync”. For a tool, this is difficult to beat:

for row in rows { ui.horizontal(|ui| { ui.label(&row.name); ui.label(row.price.to_string()); if ui.button("+").clicked() { row.price += 1; } }); }

The cost is that a example expenses the size of the UI, autonomous of what changed. At 3,000 rows all small edit takes concerning 32 ms, or concerning 19 ms with accessibility off. egui’s author is candid concerning the trade: contiguous manner is “easier to use, but small powerful”.

As the use grows, the first attention goes. The layout paradox is “a fundamental shortcoming”. Long lists need show_rows, which takes one fixed row height. Rerun, the largest egui use I cognize of, clamps queries by “inspecting egui province from the former frame”. Anything costly goes behind a cache keyed on your data. A cache that has to concur alongside your data is the synchronization issue contiguous manner set out to remove, now in application code, one example late.

One engine, distinct wiring

The harness can display this directly, since several frameworks can be wired coarsely and finely on the identical engine:

I benchmarked several of the engines in two ways, formerly in the most fine grained way they can be used, and formerly in a coarser way. The results display how much there is to acquire alongside fine-grained reactivity.

Tracking pays, unless the perspective function motionless has to runTracking pays, unless the perspective function motionless has to run

Two things remain out. Iced’s aspect is small since the perspective function still runs. And Floem’s coarse case is value a look: all division says one global revision signal, which is notify in indication clothing. It is the slowest result on the chart. So signals do not rescue coarse state. The React squad established the same item from the another side: on a benchmark alongside “one elephantine input”, a signals-style runtime disappointed, and Joe Savona concluded the data example “is just as important, perchance additional so” than the algorithm. The province has to be shaped akin the reads. That is why I call this a province example problem. A good API should create it uncomplicated to align the data example alongside its UI projection.

Why run once

Suppose you obtain all of that. You desire the example to track reads, and you want province shaped to match. What is the simplest API?

pub fn sidebar(threads: RwSignal<Vec<Thread>>) -> impl IntoView { let unread = Memo::new(move |_| { threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count()) }); Stack::vertical(( Label::derived(move || format!("{} unread", unread.get())), dyn_stack(move || threads.get(), |t| t.id, thread_row), )) }

That is the entire thing. The build runs once. The closures you hand to the framework run again whenever item they peruse changes. A local changeable is a local variable. There are no hook rules, no dependency arrays, no stale closures and no PartialEq on props. Nothing reconstructs a row’s identity, because the row is motionless there.

Compare signals alongside re-running components, which is Freya’s model. It works well: one keystroke at 3,000 rows expenses 1.2 ms. But appearance at a counter:

#[derive(PartialEq)] struct Counter; impl Component for Counter { fn render(&self) -> impl IntoElement { let mut figure = use_state(|| 0); } }

use_state exists since that row runs again. You now clasp the two models: read tracking decides which range re-runs, and afterward re-render semantics use inside it, alongside hooks, prop equality and a diff. Finer updates need smaller components, which is GPUI’s issue at a much lesser price. What the re-running build buys is ordinary authority flow, and that is the grave objection.

Dan Abramov makes a compelling argument. Run-once puts your rendering logic in closures, so top-level authority stream stops working, and you collection code around values and not about logic. “The attractiveness of React is that making things ‘computed’ is an optimization, not a requirement.” He is correct concerning it. This compiles, but the archetypal row never updates:

Label::new(count.get()), Label::derived(move || count.get()), 

A reactive if has to rotate into a dyn_container, and a for a dyn_stack. Solid 2.0 had to add dev warnings for top-level reads.

But the identical comment contains the logic I district the another way. He says Solid’s regrouping is “Similar to how you have to restructure your code about the values whenever you optimize it alongside useMemo.” Exactly. If your use never needs that, React’s example may be simpler. If it does, you pay for the regrouping either way, and the lone choice is when: from the start, or later under a profiler. Ryan Carniato’s answer is the one I concur with: “In Solid you always compose as if the data is derived so you don’t refactor to optimize.” Once you cognize you desire this precision, run-once is the API that stops pretending otherwise. The nonaccomplishment modes additionally favor it. A forgotten gap goes stale on the archetypal update, in the location you wrote it. A forgotten notify depends on what alternatively happened to redraw.

To be absolutely honest, the ecosystem’s wider convergence is on peruse tracking in general, and not on run-once specifically. Angular considered per-binding updates and chose per-view, in writing, and it lands inside concerning 1 ms of Solid on a partial update. Apple and Google, who authority their languages, kept re-running bodies alongside @Observable and Compose. A gap is itself a re-run scope, so what differs is how big the default range is. Two things I desire to note on this though:

  • First, those systems pay for their larger scopes alongside a compiler. Compose has a compiler plugin, Angular compiles templates, and React’s compiler is the serious competitor to everything here: it keeps React’s API and removes many needless re-renders, alongside loads up to 12% better. For Rust, I doubtful that most group that favor contiguous manner or elm (or any GPUI is) would not like the idea of an additional compiler. A proc macro lone sees tokens, not types, and render is an arbitrary function, so there is no continue that can split it into scopes. What Rust can province cheaply is a closure.
  • Second, their own direction keeps describing the identical problem: “Defer says as long as possible” for Compose, and “Unnecessary perspective build updates can really add up” for SwiftUI. A example that runs the build formerly removes that category at component level.

View-level rerun and fine-grained signals can have the identical performance ceiling. But signals advance fine-grained province projections and memoization from the beginning, for view-level rerun its an definitive choice that often leads to additional boilerplate.

Signals do not automatically create your app fast

Fine-grained reactivity does not create an app accelerated on its own.

Signals solitary do not flatten the curveSignals solitary do not flatten the curve

Floem’s reactivity does exactly what it promises. Changing a tag runs 5 effects and spends 0.01 ms in reactive work, afterward concerning 14 ms in layout, because Taffy does not cognize that one leaf moved. (Floem additionally has a known bug anywhere that edit never repaints, so treat this as a floor.) Vizia runs 4 bindings, then walks all 120,091 entities, for 46 ms. Slint writes an equal value in 4 µs, and takes 172 ms for a label, in a continue that walks concerning 30,000 items. Freya produces 5 mutations, afterward visits 57,050 layout nodes to recompute 14. GPUI beats most of them on this edit.

This is the difference the disagreement depends on. All of those linear expenses are in the motor respectively. Nothing in the application’s code requires them, so they can be fixed rearward the identical API without changing an application. zgui shows how far that goes: 0.69 ms for the tag at 3,000 rows and concerning 0.26 ms per 1,000 rows, about 12 times flatter than GPUI. It is motionless not flat, and it is my example and was tuned on this harness, so obtain these numbers alongside a grain of salt. In Iced and egui the linear disbursal is in the contract: the API says your code runs. No motor activity can eliminate that, and app code has to change. GPUI likely sits location in between, which is why gpui-fast can improve the circumstance to several extent.

At this point, I wan’t to portion a additional detailed benchmark matrix. Read the re-running columns downward the archetypal five rows and they barely move, since they do not depend on the edit. The indication columns move a lot, since the underlying implementations differ.

Re-running makes most edits disbursal the same. Signals do notRe-running makes most edits disbursal the same. Signals do not

The base of that grid is additionally anywhere the disbursal signals have becomes visible.

  • Creation. Filtering at 3,000 live rows takes 56 ms in Iced, 67 in egui and 123 in GPUI, against 371 in zgui, 835 in Floem and 982 in Freya. On a virtualized catalog it is 1.0 ms in Iced and 7.2 in zgui. “Setting up the dependency chart is not free.”
  • Memory. Resident recollection use for ample scenes is mostly higher the more a example retains.
  • Effects. Facebook moved distant from two-way binding since “cascading updates” became difficult to predict. That former is true, and Angular’s docs still say “Effects have to be the final API you attain for.” Modern graphs are glitch-free. Careless effects are motionless difficult to logic concerning and quickly outcome in feedback loops or akin stuff.
  • Rust. Signals depend on arena handles and move closures. Levien thinks such patterns “adapt poorly to Rust”. I think they modify fine enough, and GPUI’s entities are arena handles too, but, admittedly, it is not a liberated lunch.

Conclusion

GPUI makes a complete example realitively cheap, and that makes it likely good enough for many use-cases. It is certainly adequate for an publishing company whose expensive exterior is handled by hand. For a ample use it leaves a choice: keep the API group akin and pay per view, or get precision by writing a indication chart out of entities. Elm and contiguous manner have the identical problem. Each is clearest in the small, and all loses the item it was chosen for as precision becomes necessary, and, from my individual experience, that happens faster than group expect.

Tracked says are anywhere most of the site has ended up, any it started from. If that is anywhere we are going, I think we should commencement there, and make the component a function that runs once.

Method

  • Numbers are medians of use and resolve from my unpublished harness, on one machine: i9-13900K, RTX 3080 Ti, Vulkan, Linux. Windows, compositor and input are not included. Frameworks’ maintainers have not reviewed the adapters.
  • GPUI is at ddc9eda925 and gpui-fast at 86a8228229. zgui is at 082337db. These were through September and November 2026, as the finest of three passes.
  • Cells marked † in the grid unsuccessful their correctness oracle: Floem’s tag and keystroke edits, as noted above, and Slint’s font size. Every another charted cell passed.
  • The 60-panel results are gpui-fast’s retained_bench alongside my additions, in test mode, so no GPU activity is included. Retention off there is motionless gpui-fast.
  • Figures from Zed’s scheme are its authors’, from experimental branches.
  • Rust examples were type-checked against the vendored frameworks. Vizia’s and Freya’s arrive from their own sources, shortened.

References

Other Article Hacker News
↑
Close Right Ads
Close Left Ads