对 GPUI 的批评:它显得快,是因为 Zed 的界面太简单

GPUI 显得快,是因为 Zed 的界面本来就简单。改一个标签 8.4 毫秒,改成相同的值也一样——成本跟着树走,不跟着变化走。

分享
对 GPUI 的批评:它显得快,是因为 Zed 的界面太简单

Zed 的界面框架 GPUI 最近名声很响:2026 年 11 月 4 日,Discord CTO 宣布桌面客户端要用它重写。与此同时,一批 LLM 客户端克隆出的大多选了 egui。Rust 原生 GUI 看起来要成气候了。

zortax 在 11 月 8 日泼了盆水,标题就叫《A Critique of GPUI》。他给出的数字很具体:3,000 行实时列表上改一个标签,因为有 per-row 的 cached view,要 8.4 毫秒;把行写成普通元素,同样的改动要 92 毫秒。

更扎人的是这句话:悬停、敲一个键、写入一个和旧值相同的值,耗时都一样——成本跟着树走,不跟着变化走。

而按他的说法,Zed 之所以显得快,是因为Zed 在 UI 上本来就简单:窗口外壳只有几个按钮和一个侧栏,真正贵的编辑器是自绘元素,绕开了 GPUI 的布局与 view 语义。

他把原因归到一行 API:notify 只告诉框架"这个实体的某个字段可能变了",和 Angular 2023 年亲手拆掉的 zone.js 是同一类陈述,只是精度高一层。

披露(原文有,我们照译):作者把 GPUI 当前的 Linux 渲染器移植到了 wgpu,并搭建了下面这套 benchmark;他自己在写 zgui——一个基于 signal 的 Rust 框架,会出现在图表里。

一、所有 UI 框架最终都要回答同一个问题

Raph Levien 引用 Ron Minsky 那句话:每个 UI 框架里都藏着某种增量计算框架。UI 是状态的函数,而状态一次只变一点。一旦这个函数变贵,你就得找一条"从增量变化更快算出像素"的路。

所以每个框架都必须回答:一个值变了,什么会跑?

模型 一次写入会跑什么 谁负责找出变化
即时模式(egui) 下一帧的全部 UI 代码 没人
Elm(Iced) 全部 view,然后做一次 diff diff
Views(GPUI) 所有没有 cache 的 view 你,靠 notify
Signal + 重跑(Freya、Compose) 读了这个值的组件 框架
Signal + 只跑一次(Floem、Solid、zgui) 读了这个值的闭包 框架

前三种为什么讨人喜欢,不难理解:没有要同步的状态、状态只有一处、render 就是普通 Rust、没有宏也没有额外编译器。作者要论证的是:它们不随规模走,而想修好底下的问题,就得把让它们"简单好懂"的那点东西拆掉。

二、GPUI:notify 就是 zone.js

GPUI 自称"即时模式与保留模式的混合体":状态存在 entity 里,UI 写在 render 函数里,改状态必须触发重跑。

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()))
    }
}

这就是普通 Rust,能用 if、for 和局部变量,没有奇怪的包装。改状态要显式通知:

self.threads[i].unread = false;
cx.notify();          // 只带 entity id

Rust 对 &mut 没有写屏障,所以 GPUI 看不见哪个字段变了,甚至看不见有没有变。Angular 团队 2023 年写 zone.js 的话可以原样搬过来:它"只能通知我们某件事可能发生了",而"这种做法没有随规模走"。

notify 比 zone 精确——它点名了一个 entity。但在这个 id 之下,GPUI 一无所知,于是它重渲染所有不落在有效 .cached() 边界后面的 view。实践中往往就是重渲染每个 view,因为应用作者很少用 .cached(),而且它对布局语义有限制。

于是有了开头那组数字:3,000 行、每行一个 cached view,改一个标签 8.4 ms,并且以每 1,000 行约 3 ms 的速度线性上涨;写成普通元素 92 ms;手写一个相等守卫,把"写入相同的值"从 7.9 ms 降到 0.9 µs——这正好说明每个调用点还剩多少活要人干。

这不等于每个 GPUI 应用都慢。但按作者的说法,Zed 本身在 UI 上是个"相对简单"的应用:窗口 chrome 就是几个按钮、一个侧栏、一个编辑器,而编辑器是自绘元素,完全绕开了 GPUI 的布局与 view 语义。按 Zed 自己的说法,一个窗口"有几十个节点、几千个元素"。

三、把 GPUI 改精确,GPUI 就没了

最直接的修法是"多保留一点"。Zed 有实验分支,Longbridge 的 gpui-fast 是保留每帧 view 子树的 fork。它们有效,但有天花板:能复用的最小单位是 view。

为了让一个标签更新而不重建侧栏,这个标签就得升格成一个 view——需要 entity、Render 实现、订阅、值的一份副本、手写守卫和一个持有者。而在 signal 框架里,它是两行:

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

麻烦不止于此。Render 不带 props,所以父级要在自己的 render 里把值推进子 entity;RenderOnce 组件(按钮、行就是它)画在父 view 里,根本不是边界;列表行是闭包,想要"行级 view"就得自己维护一张按 key 索引的 entity map。

而且它只在"状态刚好和 view 对齐"时有效。作者重跑了 gpui-fast 自己的 60 面板 benchmark:每帧画一次,再移动状态——状态归各面板自己持有时省 83%;换成共享模型,保留反而慢 11%。窗口和编辑内容完全一样。

Zed 团队自己的测量更值得看:保留在"普通负载上慢 1–3%","真实 Zed 上的大部分收益"来自别的工作,而且"微基准的胜利没有预测真实 Zed 的结果"。gpui-fast 把 3,000 行的标签从 8.4 ms 降到 3.4 ms——但那一帧只渲染三个 view、布局一个元素,仍然要 3.4 ms,因为这一帧还是在遍历整棵树。

Web 上早走过这条路。Redux 风格指南要求"让更多组件订阅 store、在更细的粒度读数据",MobX 要求"造很多小的 observer 组件"。两家都在让你手工给每个投影划边界,因为框架推不出来。

这是作者最难接受的地方:人选 GPUI,是因为 render 是状态的普通函数。而一个"每个投影都是独立 entity + 订阅 + 守卫"的 UI,已经不再是那个东西了——它是用更重的零件手工搭出来的 signal 图,漏一条边是静默的。Zed 自己的设计文档写着:"漏掉的 notify 是应用 bug。"

四、Elm:小规模时好得过分

在最常见的展示规模上,Elm 架构简单得漂亮:一个状态、一个 update、一个 view,每次变化都有名字。大型应用确实有用它成功的,但作者要指出:这种清晰属于小规模。

还拿 unread 计数举例。它必须在每条消息后都正确,那就只有两条路:view 每次重算,或者 update 自己维护。维护的写法是这样:

match message {
    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);
    }
}

unread 的定义现在不在任何具体位置:它散落在每一个碰过 thread 的分支里,每加一种消息都得记得改。编译器会检查 match 穷尽,但不会检查这几个算式。而 memo 只有一处定义,新的写入方忘不掉它。

位置状态同理:悬停态、打了一半的草稿放哪?单一数据源说放 model 里,向下发一个 message 类型,向上回一个结果。Iced 维护者自己也说这套架构里的组件"最终总是以痛苦收场",同时给了最好的辩护:不这么做"会逼你认真想清楚数据模型"。

但 cosmic-files 有 136 个顶层 message 变体,一个 tab 还有 73 个,外加一个把命令返回给父级解释的 Tab::update。全都是显式的,没有一样是简单的。

性能上同理:Iced 每批消息之后重建整个 view,3,000 行改一个标签会构建 39,023 个 widget、耗时 7.3 ms;每行加 Lazy 只降到 5.7 ms,因为 view 还是要逐行去 hash key。官方 lazy 示例的 key 是一个 version: u8,由四个 update 分支手工递增——那就是一个改了名字的脏标记。

公平地说:cosmic-files 和 Halloy 根本没用 lazy,而在虚拟化列表上 Iced 是作者测过最快的框架之一(0.19 ms)。问题不在常数,在渐近复杂度。

五、即时模式:一句话

egui 的卖点全行业最好:"你不必担心应用状态和 GUI 状态不同步。" 对工具类界面,这几乎无敌。

代价是:一帧的成本等于 UI 的大小,与"变了什么"无关。3,000 行下每次小编辑约 32 ms,关掉无障碍约 19 ms。egui 作者对这个取舍很坦率:即时模式"更好用,但更弱"。

应用一变大,最初的吸引力就没有了。布局悖论被作者称为"根本缺陷":长列表要用 show_rows,而它要求固定行高。已知最大的 egui 应用 rerun,靠"检查上一帧的 egui 状态"来夹逼查询。任何昂贵的东西都要塞进一个按你的数据做 key 的缓存——而一个必须和数据保持一致的缓存,正是即时模式想消灭的同步问题,只是挪到了应用代码里,还晚一帧。

六、同一个引擎,不同接法

这套 harness 能直接证明这一点,因为有些框架可以用粗、细两种方式接同一个引擎:

  • Iced 的粗细比很小,因为 view 函数仍然要跑。
  • Floem 的粗粒度案例值得一看:每个 cell 都去读一个全局 revision signal——那就是穿了 signal 外套的 notify。它是图里最慢的结果。

所以:signal 救不了粗糙的状态。React 团队从另一侧得到同样结论:在"一个巨大输入"的 benchmark 上,signal 风格运行时令人失望,Joe Savona 的结论是数据模型"同样重要,也许更重要"。状态得长成读取的形状——这也是作者管它叫"状态模型问题"的原因。

七、为什么"只跑一次"

假设你接受上面全部论证:要框架跟踪读取,要状态匹配读取。那最简 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),
    ))
}

函数体只跑一次;交给框架的闭包在它读的值变了时重跑。局部变量就是局部变量:没有 hook 规则、没有依赖数组、没有过期闭包、props 上没有 PartialEq,也没有东西去重建"行的身份"——因为行还在那。

对照 Freya 的"重跑组件"模型:它跑得很好,3,000 行下一次按键 1.2 ms。但看一个计数器就知道代价:

#[derive(PartialEq)]
struct Counter;

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        let mut count = use_state(|| 0);   // 因为这一行会再跑一次,所以需要它
        // ...
    }
}

use_state 的存在,是因为那一行会再跑。你现在同时拥有两个模型:读取跟踪决定哪个作用域重跑,然后作用域内部又是重渲染语义(hook、prop 相等、diff)。想更细就得拆更小组件——这正是 GPUI 的问题,只是价格低得多。

Dan Abramov 的反驳很有说服力:只跑一次把渲染逻辑塞进闭包,顶层控制流就用不了了,代码会围着值而不是围着逻辑组织,"React 的美在于把东西变成 computed 是优化,不是必须"。

作者认这一点,但也认另一半:那种重组和 useMemo 优化时的重组是同一件事,差别只是什么时候付——一开始,还是以后在 profiler 下面。Ryan Carniato 的回应是他站的那边:"在 Solid 里,你永远按'数据是派生的'来写,所以不用为了优化去重构。"

而且失败模式偏向只跑一次:忘了挖的洞,会在第一次更新时就在你写它的地方变旧;而忘了 notify,取决于别的地方碰巧重绘了什么。

八、signal 不会自动让你的应用变快

细粒度响应式本身不保证快。作者逐个点名(同一台机器、同一套 harness):

  • Floem 的响应式完全兑现承诺:改一个标签跑 5 个 effect、0.01 ms,然后花约 14 ms 在布局上,因为 Taffy 不知道有一个叶子动了(Floem 还有个已知 bug:这次编辑从不重绘,所以只能当地板看)。
  • Vizia 跑 4 个绑定,然后遍历 120,091 个 entity,46 ms。
  • Slint 写入相同值 4 µs,改一个标签 172 ms,那一趟走了约 30,000 个 item。
  • Freya 产生 5 次变更,然后访问 57,050 个布局节点 重算 14 个。
  • 这次编辑上,GPUI 打败了其中大多数。

这正是论证的关键区分:这些线性成本在引擎里,应用代码并不要求它们,所以可以在同一套 API 后面修掉。zgui 展示了能修到哪:3,000 行标签 0.69 ms,每 1,000 行约 0.26 ms——比 GPUI 平约 12 倍(这是作者自己的框架、在这套 harness 上调过,数字打个折)。

而 Iced 和 egui 的线性成本写在契约里:API 本身就说"你的代码会跑"。引擎修不掉,应用必须改。GPUI 大概在两者之间——这也是 gpui-fast 能改善一部分的原因。

代价还有三处:

  • 创建:3,000 行下做过滤,Iced 56 ms、egui 67、GPUI 123、zgui 371、Floem 835、Freya 982(虚拟化列表:Iced 1.0 ms、zgui 7.2 ms)。"建依赖图不是免费的"。
  • 内存:保留得越多,大场景的常驻内存越高。
  • Effects:Facebook 放弃双向绑定,就是因为"级联更新"变得难以预测;现代依赖图无毛刺,但随手写的 effect 照样能造出反馈环。

Rust 这一侧也有摩擦:signal 依赖 arena 句柄和 move 闭包,Levien 认为这种模式"和 Rust 适配得不好"。作者认为够用(GPUI 的 entity 也是 arena 句柄),但也承认不是白吃的午餐。

九、原文结论

GPUI 让"一整帧"相对便宜,这让它对很多场景够用——对一个昂贵表面被手工接管的编辑器,当然够。但对大型应用它留了一道选择题:要么保留大家喜欢的 API、按 view 付费;要么用 entity 手工搭一张 signal 图换精确度。 Elm 与即时模式有同样的问题:各自在小规模最清晰,而一旦精确度变成必需品,各自丢掉当初被选中的理由——按作者的经验,这比人们预期来得更快。

跟踪读取是行业大多数最终落脚的地方。如果要去那儿,那就从那儿开始,并把组件做成只跑一次的函数。

十、我们的整理:什么时候够用,什么时候别赌

以上是译文,下面是我们加的判断。

你的场景 够用的选择 别赌的选择
工具面板、调试界面、内部小工具(一屏几十到几百个元素) egui:同步问题直接消失,改一行就是一行 别为它写长列表,show_rows 固定行高会先咬人
中等应用,列表 < 1,000 行,逻辑分支多、想要显式状态机 Iced / Elm:状态可见,测试好写 别指望它的性能随规模走:7.3 ms 是 3,000 行,不是常数
编辑器、终端这类"昂贵表面自己画"的产品 GPUI:只要你的重活不在 GPUI 的布局里 别把 GPUI 用在"整屏都是 GPUI 元素"的后台管理界面上
大表格、大列表、实时刷新的看板 signal + 只跑一次(zgui/Solid 系) 别用它图省事:创建依赖图有成本,粗粒度用 signal 反而最慢

三条可以拿去问框架(或者问自己)的判据:

  1. 你的列表会不会超过 1,000 行? 会,就先按每 1,000 行 3 ms 去估算 GPUI 这类"按树付费"的框架。
  2. 你改的那个值,和"贵"的那一层在不在同一个粒度上? 改一个标签触发一次全布局,问题就不在状态模型而在引擎——那种情况换框架也救不了。
  3. 你愿意手写多少个"边界"? 每个 cached() / 每个行级 view,都是一次手工增量。超过十几处,说明你在用重零件搭 signal 图。

边界:这里的数字全部来自作者的 harness,同一台机器(i9-13900K / RTX 3080 Ti / Vulkan / Linux),只算 apply 与 settle 的中位数,不含 Windows、合成器与输入;框架适配器未经各框架维护者审查;作者是 zgui 作者,且在这套 harness 上调过它。所以结论的方向可以信,绝对数字别跨平台套用。

对我们做工业界面的人还有一条额外的:PLC/上位机界面里"贵"的往往是通信与刷新频率,不是控件树。3000 行的表格在工业场景不常见,但每秒刷新 20 次的点位表很常见——这种场景下,先解决数据层的节流,比挑框架重要一个数量级。


来源

  • zortax,《A Critique of GPUI》,2026-11-08:https://blog.zortax.de/posts/a-critique-of-gpui(原文 20 分钟阅读,含完整 benchmark 矩阵与方法说明;本文按段翻译,删去部分代码细节,保留全部关键数字)
  • 原文引用的关键出处:Angular《Sub-RFC 1: Signals for Angular Reactivity》(2023-04-03)、Redux Style Guide、Raph Levien《Xilem: an architecture for UI in Rust》与《The case for signals》(Levien 对 Rust 适配性的质疑即出于此)、Zed gpui-retained-plan 分支文档《retained_extraction_plan.md》(2026-10-30)、Longbridge gpui-fast、Vizia《Migration Guide: Lens-Based State to Signals》(2026-03)、Dan Abramov 与 Ryan Carniato 关于"只跑一次"的往复。
  • 数字口径与版本:GPUI ddc9eda925、gpui-fast 86a8228229、zgui 082337db;2026 年 9–11 月三轮取最好的一次;标记 † 的格子未通过正确性检查(Floem 的标签与按键编辑、Slint 的字号)。

相关阅读:Blender 的 UI 抽成库后:跑同一份 gpui 代码,首帧快 5 倍——同一份 gpui 源码,换引擎后首帧 40 ms 对 231 ms,可以看清"引擎常数"与"状态模型渐近"是两件事。