单一来源
当前只有一个可用来源,尚未完成多源交叉核验GPUI批评:唯一重要的问题与精确性挑战
All postsOn this page
· The only question that matters
· GPUI: notify is zone.js
· Making GPUI precise defeats GPUI
· Elm: deceptively simple at small scales
· A quick note on immediate mode
· One engine, different wiring
· Why run once
· Signals do not automatically make your app fast
Recently, there has been a trend of more desktop GUI apps being built using native rust GUI frameworks. And there are now even well known electron apps getting official rewrites: Discord announced their app is being rewritten using GPUI.1 Meanwhile, a lot of the LLM-clones being published seem to choose egui. In this post, I want to explain why I am not sure those frameworks are a good foundation to build on.
To put it plainly: I believe both GPUI’s and egui’s state models are dead ends for complex desktop applications. Both are impressive projects, and they have their place. Zed genuinely feels fast 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 want to make two arguments:
· Every UI architecture ends up needing to know what changed and who depends on
it, or performance characteristics will not scale. A framework whose state model cannot say so makes the application say it by hand, at the cost of whatever made that framework’s API semantics attractive in the first place.
· Once you admit you want 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 wgpu2, I built the benchmark harness used below, and I work on zgui, a signal-based Rust framework that appears in the charts.
The only question that matters
Raph Levien quotes Ron Minsky: “hidden inside of every UI framework is some kind of incrementalization framework.”3 A UI is a function of state, and state changes a little at a time. But if that function becomes expensive, you might want to start looking for ways to derive a faster way to update pixels on the screen from an incremental state change. So every framework has to answer one question: when one value changes, what runs?
ModelWhat a write runsWho finds the change
Immediate (egui)All UI code, next frameNobody
Elm (Iced)All of view, then a diffThe diff
Views (GPUI)Every view without a cacheYou, with notify
Signals, re-run (Freya, Compose)Components that read itThe framework
Signals, run once (Floem, Solid, zgui)Closures that read itThe framework
I understand why people like the first three. No state to synchronize, one place where state lives, a render function that is plain Rust, no macros or separate compiler. I’m gonna make the argument that these approaches don’t scale, and fixing the underlying problem defeats what makes these models simple and easy to reason about.
GPUI: notify is zone.js
GPUI calls itself “a hybrid immediate and retained mode” framework.4 It retains state 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 ordinary Rust. You can use if, for and local variables, and there aren’t any weird wrappers. State changes require an explicit notification:
self.threads[i].unread = false; cx.notify();
notify carries the entity id.5 Since Rust doesn’t have a write barrier on &mut, GPUI cannot see which field changed (or whether anything changed at all). The Angular team wrote this about zone.js in 2023: it “is only capable of notifying us when something might have happened”, and “This approach has not scaled well.”6 notify is more precise than a zone, since it names an entity, but ultimately it makes the same kind of statement. Below that id, GPUI knows nothing, so it re-renders every view that is not behind a valid .cached() boundary. And in practice, GPUI often rerender every view, because .cached() is rarely used by application authors, and imposes certain restrictions on layout semantics.
GPUI has a reputation of being “fast”. And while constants might 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 state change in a large UI actually costs.
At 3,000 live rows with a cached view per row, changing one label takes 8.4 ms. A hover, a keystroke and writing a value equal to the old one all cost about the same, because the cost 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 same label takes 92 ms. A hand-written equality guard takes the equal write from 7.9 ms to 0.9 µs (demonstrating how much is left for each call site to find).
This doesn’t mean that every GPUI app is slow, but it shows the limit of the state model. Especially with more expensive layout, you do hit this wall in practice. Zed might feel fast, but the truth 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 text editor, and the text editor is implemented as a custom element, completely bypassing GPUI’s layout and view semantics. By their own account, a window “has tens of nodes and a few thousand elements”.7
Making GPUI precise defeats GPUI
The obvious fix is to retain more. Zed has experimental branches for this, and Longbridge’s gpui-fast is a fork that keeps every view’s subtree between frames.8 9 It works, within a limit: the smallest thing either one can reuse is a view. So to update one label without rebuilding the sidebar, the label has 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 then has to create it and keep the handle:
let badge = 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 guard and an owner, all to express that one label depends on one count. In a signal framework 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 rest of GPUI’s API works against this too. Render takes no props, so parents push values into child entities during their own render. RenderOnce components, which is what buttons and rows are, draw inside their parent’s view and are no boundary at all. List rows are closures, so a row view means managing a keyed map of entities by hand.
And it only helps where state happens to line up with views. I reran gpui-fast’s own 60-panel benchmark, drew each frame once, and then moved the state:
With state owned by each panel, one change saves 83%. With a shared model, retention makes the same frame 11% slower. The window and the edit are identical. In real Zed, the Zed team measured retention “1–3% slower than off on ordinary workloads”, found that “Most of the real-Zed win” came from other work, and warned that “microbenchmark wins didn’t predict real-Zed results”.8 gpui-fast is real progress and costs little to adopt: it takes the 3,000-row label from 8.4 to 3.4 ms. But that frame renders three views and lays out one element, and still spends 3.4 ms, because the frame is still a walk over the whole tree.
The web has already walked this
Evidence
来源与证据
当前仅有一个可用来源,尚未完成多源交叉核验。
- 发布时间
- 内容处理
- AI 翻译 / 摘要,需结合原文核对
相关资讯
AI 翻译、摘要和解读仅用于辅助判断;涉及重要决策时,请回到上方来源核实。