跳到正文
Hacker News· ByteAtATime·· 5 小时前AI 评分34

对 GPUI 的批判:状态模型为何难以支撑复杂桌面应用

A Critique of GPUI

AI 导读

一篇对 GPUI 的批判文章指出,GPUI 与 egui 的状态模型是复杂桌面应用的死胡同。基准测试显示,在 3000 行活跃列表且每行带缓存视图时,修改一个标签耗时 8.4 ms,成本随视图树规模增长而非变更范围;若改为普通元素则升至 92 ms。作者认为 GPUI 的 notify 机制类似 zone.js,无法定位具体字段变更,而保留更多状态的做法又会让最小复用单位退化为整个 view。

正文 · AI 翻译

最近出现了一种趋势:越来越多桌面 GUI 应用开始使用原生 Rust GUI 框架构建。如今甚至一些知名的 Electron 应用也推出了官方重写版本:Discord 宣布其应用正使用 GPUI 重写。1 与此同时,大量发布的 LLM 克隆应用似乎都选择了 egui。在这篇文章中,我想解释为什么我认为这些框架并不是一个良好的构建基础。

直白地说:我认为 GPUI 和 egui 的状态模型对于复杂的桌面应用来说都是死路。两者都是令人印象深刻的项目,也各有其适用场景。Zed 确实感觉很快、很好用,我自己也在 bevy 游戏里用 egui 做调试界面。但我不认为这些框架应该成为原生桌面应用的默认选择。

我想提出两个论点:

  • 每一种 UI 架构最终都需要知道什么发生了变化、谁依赖于它,否则性能特征就无法扩展。如果一个框架的状态模型无法表达这一点,那就只能让应用手动表达,代价是牺牲掉当初让该框架 API 语义具有吸引力的那些特性。
  • 一旦你承认你想要这种精确性,那么只运行一次的组件,配合信号和响应式空洞,就是实现它的最简单 API。

披露:我将 GPUI 当前的 Linux 渲染器移植到了 wgpu2,我构建了下文使用的基准测试工具,并且我在开发 zgui,这是一个基于信号的 Rust 框架,出现在图表中。

唯一重要的问题

Raph Levien 引用了 Ron Minsky 的话:“每个 UI 框架内部都隐藏着某种增量计算框架。”3 UI 是状态的函数,而状态每次只变化一点点。但如果这个函数变得昂贵,你可能就需要开始寻找方法,从增量的状态变化中推导出更快的屏幕像素更新方式。所以每个框架都必须回答一个问题:当一个值变化时,什么会运行?

模型一次写入会运行什么谁负责发现变化
立即模式(egui)所有 UI 代码,下一帧没有人
Elm(Iced)整个 view,然后做 diffdiff
视图(GPUI)每个没有缓存的视图你,用 notify
信号,重新运行(Freya、Compose)读取它的组件框架
信号,运行一次(Floem、Solid、zgui)读取它的闭包框架

我理解人们为什么喜欢前三种。无需同步状态,状态只存在于一个地方,render 函数就是普通的 Rust,没有宏或单独的编译器。我要论证的是这些方法无法扩展,而修复底层问题又会破坏这些模型简单易推理的特性。

GPUI:notify 就是 zone.js

GPUI 自称是“一种混合的立即模式和保留模式”框架。4 它在实体中保留状态。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();

notify 携带实体 id。5 由于 Rust 在 &mut 上没有写屏障,GPUI 无法看到哪个字段发生了变化(或者到底有没有发生变化)。Angular 团队在 2023 年这样评价 zone.js:它“只能在我们知道某些事情可能已经发生时通知我们”,并且“这种方法扩展性不佳。”6 notify 比 zone 更精确,因为它指明了实体,但归根结底它做出的是同一类断言。在那个 id 之下,GPUI 一无所知,因此它会重新渲染每一个不在有效 .cached() 边界之后的视图。而在实践中,GPUI 常常会重新渲染每一个视图,因为应用作者很少使用 .cached(),而且它对布局语义施加了某些限制。

GPUI 有着“快”的名声。虽然常数可能相对较低,但从渐近角度看,它浪费了很多性能。因此我跑了几个基准测试,专门测试大型 UI 中稀疏状态变更的实际开销。

在 3,000 个活跃行、每行一个缓存视图的情况下,更改一个标签需要 8.4 ms。悬停、按键以及写入与旧值相等的值,开销都差不多,因为开销跟随的是树,而不是变更本身。它大约每 1,000 个活跃行增长 3 ms。把行写成普通元素,就像上面的代码那样,同一个标签需要 92 ms。手写的相等性守卫把相等写入从 7.9 ms 降到 0.9 µs(这说明每个调用点还有多少工作要自己去找)。

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

这并不意味着每个 GPUI 应用都很慢,但它显示了状态模型的极限。尤其是在布局更昂贵的情况下,你确实会在实践中撞上这堵墙。Zed 可能感觉很快,但事实是,Zed 在 UI 方面是一个相对简单的应用。应用的界面外壳包含几个按钮、一个简单布局、一个侧边栏和一个文本编辑器,而文本编辑器被实现为自定义元素,完全绕过了 GPUI 的布局和视图语义。按照他们自己的说法,一个窗口“有几十个节点和几千个元素”。7

让 GPUI 精确,反而击败了 GPUI

显而易见的修复方法是保留更多内容。Zed 有为此设立的实验分支,而 Longbridge 的 gpui-fast 是一个在帧之间保留每个视图子树的 fork。8 9 它在一定限度内有效:两者能复用的最小单位都是视图。因此,要在不重建侧边栏的情况下更新一个标签,这个标签必须是一个视图:

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

然后它的所有者必须创建它并保留句柄:

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

那是一个实体、一个 Render 实现、一个订阅、一份存储的值副本、一个手写守卫和一个所有者,全都只是为了表达一个标签依赖于一个计数。在信号框架中,它是这样的:

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

GPUI 的其余 API 也与此相悖。Render 不接受 props,因此父级在自身渲染期间把值推入子实体。RenderOnce 组件——按钮和行就是这种组件——在父视图内部绘制,完全不是边界。列表行是闭包,因此一个行视图意味着要手动管理一个按键索引的实体映射。

而且它只在状态恰好与视图对齐的地方才有帮助。我重新运行了 gpui-fast 自己的 60 面板基准测试,每帧绘制一次,然后移动状态:

Retention pays only where state already matches the viewsRetention pays only where state already matches the views

当状态由每个面板各自持有时,一处改动可节省 83%。而使用共享模型时,保留机制会让同一帧慢 11%。窗口和编辑操作完全相同。在真实的 Zed 中,Zed 团队测得保留机制“在普通工作负载下比关闭时慢 1–3%”,发现“真实 Zed 中的大部分收益”来自其他工作,并警告“微基准测试的收益无法预测真实 Zed 的结果”。8 gpui-fast 是真正的进步,采用成本也很低:它把 3,000 行的标签从 8.4 ms 降到 3.4 ms。但那一帧要渲染三个视图并布局一个元素,却仍要花 3.4 ms,因为这一帧仍然是对整棵树的遍历。

Web 已经走过这条路。Redux 的风格指南说“倾向于让更多 UI 组件订阅 Redux store,并在更细的粒度上读取数据”。10 MobX 说要“创建大量小的 observer 组件”。11 两者都告诉你要为每个投影手动建立边界,因为框架无法推断出它。

这是我最难接受的一点。人们选择 GPUI,是因为 render 是状态的普通函数。而一个每个投影都是单独拥有的实体、带有订阅和守卫的 UI,就不再是那样了。它是由重得多的部件手工搭建的信号图,其中漏掉一条边是无声的。Zed 自己的设计说明写道“缺失的通知是应用 bug”。12 保留令人愉快的 API,你得到的就是视图级别的更新。追求精确性,你就会失去令人愉快的 API。在其上再加一个 signals crate 也不会改变这种取舍,因为它最小的 sink 仍然是一个视图。要在没有 render 运行的情况下到达单个节点,需要框架能看到的写入以及生命周期超过一帧的节点,而这意味着一套新的状态模型和一份新的元素契约。

Elm:在小规模下看似简单

在通常展示的规模下,Elm 架构是一个看似简单的 UI 模型:一个状态值、一个 update、一个 view,而且每次变化都有名字。虽然大型应用确实成功地使用了它,13 14 但我确实想指出,这种清晰性属于小规模场景,而在复杂应用中,signals 和 stores 可以说更容易推理。

以同样的 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 的定义现在不在任何特定位置。它分散在每一个涉及线程的分支中,每条新消息都必须记得它。编译器会检查 match 是否穷尽,但不会检查算术。上面的 memo 只有一个定义,新的写入者不可能忘记它。

位置信息也是如此。悬停状态或输入到一半的草稿该放在哪里?单一数据源原则认为应放在模型中,通过消息类型向下路由,再将结果向上路由回来。Elm 的指南写道:“主动尝试制作组件在 Elm 中就是灾难的配方。”15。Iced 建议向父级返回一个 Action。16 Iced 的维护者表示,这种架构中的组件最终总是“以痛苦收场”,同时也为这一纪律给出了最好的理由:避免使用组件“迫使你认真思考你的数据模型”。17 好吧,我想这是个合理的论点。但 cosmic-files 有 136 个顶层消息变体,一个标签页还有 73 个,外加一个 Tab::update 返回供父级解释的命令。18 其中的一切都是显式的,没有一样是简单的。

这种架构也存在与 GPUI 相同的问题。update 的实现告诉你一条消息写入了什么,却无法告诉你谁读取了该字段。view 内部的任何东西都可能读取它。Elm 的架构不回答“什么变了?”,它回答“发生了什么?”。会话导出在某些场景下可能有用,但对于可扩展的性能特征并无帮助。19

这就是我在 Web 历史中读到的。Redux 是这一设计最知名的近亲,其文档读起来像一份实地报告。将你 store 的一部分“当作数据库来对待”。20 选择器“会在每次派发 action 后重新运行”。21 核心包已“过时”,被一个旨在消除样板代码的工具包取代。22 在 React 之外,主流已转向追踪式读取。Angular、Vue、Svelte 和 Solid 构建在 signals 或类似机制之上,Preact 也提供它们,Svelte 团队如此表述:“我们意识到 Knockout 一直是对的。”23 在 React 内部,Jotai 和 MobX 作为库引入追踪,Legend-State 甚至提供“渲染一次”的风格。24 大多数应用仍在使用 React,而 React 团队不同意,我将在下文回到这一点。因此,除了一个巨大的例外,各方正在状态模型上趋于一致。

性能也遵循同样的路线。由于 Iced 在每批消息后重建所有 view,一个 3,000 行的标签会构建 39,023 个 widget,耗时 7.3 ms。在每一行上使用 Lazy 也只能将其降到 5.7 ms,因为 view 仍必须访问每一行来哈希其键。官方的 lazy 示例以 version: u8 为键,由四个 update 分支手动递增。25 那是一个名字更好听的脏标记。公平地说,cosmic-files 和 Halloy 完全不用 lazy,而在虚拟化列表上,Iced 是我测得的框架中最快的之一,为 0.19 ms。有界屏幕的重建成本很低。但渐近复杂度是明确的。而且与 GPUI 类似,API 语义根本无法表达必要的信息。

这其实有先例:Vizia 展示了在这个模型上增加精确性会发生什么。它在 2026 年 3 月从 lenses 转向 signals,并保留了其 Model 和事件派发。26 这是它的计数器,经过缩短:27

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 count = Signal::new(0);
AppData { count }.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);

这个标签直接读取信号,而构建按钮的同一个闭包创建了它。枚举、分发和处理程序的存在,只是为了把一次调用传递到 update。Vizia 从来都不是严格的 Elm,因为它的视图只构建一次,而信号是一个明智的举措:它获得了逐字段写入的能力。但看看 Elm 的理由还剩下什么。状态是一个可变单元的图,Model 是一袋句柄,而 event 是一个 &mut self 副作用。你现在有两套系统来表达某些东西发生了变化。所以我对在 Elm 架构中添加信号的反对意见,确实和 GPUI 一样:它们能工作,但它们移除了你来这里想要的东西。

egui 的卖点是该领域最好的:“你不必担心应用状态和 GUI 状态不同步”。28 对于一个工具来说,这很难被超越:

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

代价是,一帧的开销取决于 UI 的大小,与变化的内容无关。在 3,000 行时,每次小编辑大约需要 32 毫秒,关闭无障碍功能后大约需要 19 毫秒。egui 的作者坦率地谈到了这种权衡:立即模式“更易于使用,但功能较弱”。28

随着应用的增长,最初的吸引力就消失了。布局悖论是“一个根本性的缺陷”。28 长列表需要 show_rows,这要求固定的行高。Rerun,我所知道的最大的 egui 应用,通过“检查上一帧的 egui 状态”来限制查询。29 任何昂贵的操作都放在以你的数据为键的缓存后面。一个必须与你的数据保持一致的缓存,正是立即模式想要消除的同步问题,现在出现在应用代码中,晚了一帧。

一个引擎,不同的接线方式

测试框架可以直接展示这一点,因为有些框架可以在同一个引擎上进行粗粒度和细粒度的接线:

我以两种方式对其中一些引擎进行了基准测试,一次以它们能使用的最细粒度方式,一次以较粗的方式。结果显示了细粒度响应式能带来多大的收益。

Tracking pays, unless the view function still has to runTracking pays, unless the view function still has to run

有两点很突出。Iced 的系数很小,因为视图函数仍然会运行。而 Floem 的粗粒度情况值得一看:每个单元都读取一个全局修订信号,这实际上是披着信号外衣的 notify。这是图表上最慢的结果。所以信号并不能拯救粗粒度状态。React 团队从另一边发现了同样的事情:在一个“一个巨大输入”的基准测试中,信号风格的运行时令人失望,Joe Savona 得出结论,数据模型“同样重要,也许更重要”,而不是算法。30 状态必须按照读取的方式来塑造。这就是为什么我称这是一个状态模型问题。一个好的 API 应该让数据模型与其 UI 投影的对齐变得容易。

为什么只运行一次

假设你接受了所有这些。你希望框架跟踪读取,并且你希望状态被塑造成匹配的形状。最简单的 API 是什么?

pub fn sidebar(threads: RwSignal<Vec<Thread>>) -> impl IntoView {
     Runs once, when the sidebar mounts.
    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),
    ))
}

这就是全部。主体只运行一次。你交给框架的闭包在它们读取的内容发生变化时会再次运行。局部变量就是局部变量。没有钩子规则,没有依赖数组,没有过时的闭包,也没有 props 上的 PartialEq。没有任何东西会重建行的身份,因为行仍然在那里。

将信号与重新运行组件进行比较,这是 Freya 的模型。它运行良好:在 3,000 行时,一次击键只需 1.2 毫秒。但看看一个计数器:31

#[derive(PartialEq)]
struct Counter;

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        let mut count = use_state(|| 0);
            }
}

use_state 之所以存在,是因为那一行会再次运行。你现在同时拥有两种模型:读取追踪决定哪个作用域重新运行,然后重新渲染语义在其中生效,并带有 hooks、prop 相等性和 diff。更精细的更新需要更小的组件,这是 GPUI 以低得多的代价解决的问题。重新运行函数体换来的是普通的控制流,而这正是严肃的反对意见。

Dan Abramov 提出了一个有力的论点。32 只运行一次会把你的渲染逻辑放进闭包,于是顶层控制流不再工作,你会围绕值而不是围绕逻辑来组织代码。“React 的美妙之处在于,让东西‘被计算’是一种优化,而不是一项要求。”他说的没错。这段代码能编译,但第一行永远不会更新:

Label::new(count.get()),               // read once; never updates
Label::derived(move || count.get()),   // a hole; updates

一个响应式 if 必须变成一个 dyn_container,而一个 for 要变成一个 dyn_stack。Solid 2.0 不得不为顶层读取添加开发警告。33

但同一条评论里也包含了我得出相反结论的理由。他说 Solid 的重新分组“类似于你用 useMemo 优化代码时,必须围绕值来重构代码”。正是如此。如果你的应用永远不需要这个,React 的模型可能更简单。如果需要,那么无论哪种方式你都要为重新分组付出代价,唯一的选择是什么时候付:从一开始,还是以后在性能分析器下。Ryan Carniato 的回复是我赞同的:“在 Solid 中,你总是像数据是派生的一样来写,这样你就不必为了优化而重构。”32 一旦你知道你想要这种精确性,只运行一次就是那个不再假装相反的 API。失败模式也更有利于它。一个被遗忘的漏洞会在第一次更新时变旧,就在你写它的地方。一个被遗忘的 notify 则取决于还有什么其他东西碰巧重绘了。

说实话,生态更广泛的趋同是朝向一般的读取追踪,而不是专门朝向只运行一次。Angular 考虑过按绑定更新,并以书面形式选择了按视图更新,在部分更新上它与 Solid 的差距约为 1 毫秒。6 34 掌控自己语言的 Apple 和 Google 则保留了用 @Observable 和 Compose 重新运行函数体的做法。35 36 一个洞本身就是一个重新运行的作用域,所以不同之处在于默认作用域有多大。不过关于这一点我想指出两件事:

  • 第一,那些系统用编译器来为更大的作用域付出代价。Compose 有一个编译器插件,Angular 编译模板,而 React 的编译器是这里所有东西的严肃对手:它保留 React 的 API 并消除许多不必要的重新渲染,负载最多改善 12%。37 对于 Rust,我怀疑大多数喜欢立即模式或 elm(或不管 GPUI 是什么)的人不会喜欢额外编译器的想法。proc macro 只能看到 token,看不到类型,而 render 是一个任意函数,所以没有任何 pass 能把它拆分成作用域。Rust 能廉价表达的是闭包。
  • 第二,他们自己的指南一直在描述同一个问题:Compose 的“尽可能推迟读取”,以及 SwiftUI 的“不必要的视图体更新真的会累积起来”。38 39 一个只运行一次函数体的框架在组件层面消除了这一类别。

视图级重新运行和细粒度信号可以有相同的性能上限。但信号鼓励从一开始就进行细粒度的状态投影和记忆化,而对于视图级重新运行,这是一个明确的选择,往往导致更多样板代码。

信号并不会自动让你的应用变快

细粒度响应式本身并不能让应用变快。

Signals alone do not flatten the curveSignals alone do not flatten the curve

Floem 的响应式完全做到了它承诺的事。修改一个标签会运行 5 个 effect,在响应式工作上花费 0.01 ms,然后在布局上花费约 14 ms,因为 Taffy 不知道有一个叶子节点移动了。(Floem 还有一个已知 bug,即该编辑永远不会重绘,所以请把这当作下限。)Vizia 运行 4 个绑定,然后遍历全部 120,091 个实体,耗时 46 ms。Slint 在 4 µs 内写入一个相等的值,而处理一个标签需要 172 ms,这一过程遍历约 30,000 个条目。Freya 产生 5 个变更,然后访问 57,050 个布局节点以重新计算 14 个。GPUI 在这次编辑上击败了它们中的大多数。

这就是论证所依赖的区别。所有这些线性成本分别位于引擎中。应用代码中没有任何东西要求它们,因此它们可以在同一 API 背后被修复,而无需更改应用。zgui 展示了这能走多远:在 3,000 行时标签耗时 0.69 ms,每 1,000 行约 0.26 ms,比 GPUI 平坦约 12 倍。它仍然不是平坦的,而且它是我的框架,并在这个测试框架上进行了调优,所以请对这些数字持保留态度。在 Iced 和 egui 中,线性成本是契约的一部分:API 规定你的代码会运行。没有任何引擎工作可以消除这一点,应用代码必须更改。GPUI 可能介于两者之间,这就是为什么 gpui-fast 可以在某种程度上改善这种情况。

此时,我想分享一个更详细的基准矩阵。向下阅读前五行的重新运行列,它们几乎不动,因为它们不依赖于编辑。信号列变化很大,因为底层实现不同。

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

该网格的底部也是成本信号变得可见的地方。

  • 创建。在 3,000 个活动行上进行过滤,Iced 耗时 56 ms,egui 67 ms,GPUI 123 ms,而 zgui 为 371 ms,Floem 为 835 ms,Freya 为 982 ms。在虚拟化列表上,Iced 为 1.0 ms,zgui 为 7.2 ms。“设置依赖图不是免费的。”6
  • 内存。大型场景的常驻内存使用通常随着框架保留的内容越多而越高。
  • Effect。Facebook 放弃了双向绑定,因为“级联更新”变得难以预测。40那段历史是真实的,Angular 的文档仍然说“Effect 应该是你最后才使用的 API。”41现代图是无故障的。粗心的 effect 仍然难以推理,并很快导致反馈循环或类似的东西。
  • Rust。信号依赖于 arena 句柄和 move 闭包。Levien 认为这种模式“对 Rust 的适应很差”。3我认为它们适应得足够好,而且 GPUI 的实体也是 arena 句柄,但不可否认,这不是免费的午餐。

结论

GPUI 使完整帧相对便宜,这使得它对许多用例来说可能足够好。对于一个昂贵表面由手工处理的编辑器来说,它当然足够。对于大型应用,它留下了一个选择:保留人们喜欢的 API 并按视图付费,或者通过从实体编写信号图来获得精确性。Elm 和立即模式有同样的问题。每种在小规模时最清晰,而每种在精确性变得必要时都会失去它被选择的原因,而且,根据我的个人经验,这发生得比人们预期的要快。

无论起点如何,追踪读取(tracked reads)是大多数领域最终采用的方式。如果这就是我们的方向,我认为我们应该从那里开始,把组件做成一个只运行一次的函数。

方法

  • 数字是我未公开的测试框架中 apply 和 settle 的中位数,在单台机器上测得:i9-13900K、RTX 3080 Ti、Vulkan、Linux。不包含 Windows、合成器和输入。各框架的维护者尚未审阅这些适配器。
  • GPUI 位于 ddc9eda925,gpui-fast 位于 86a8228229。zgui 位于 082337db。这些数据截至 2026 年 9 月和 11 月,取三轮测试中的最佳成绩。
  • 网格中标记 † 的单元格未通过其正确性校验:如上所述,Floem 的标签和按键编辑,以及 Slint 的字体大小。其他所有图表中的单元格均通过。
  • 60 面板的结果是 gpui-fast 的 retained_bench 加上我的补充,在测试模式下测得,因此不包含 GPU 工作。那里的保留(Retention)关闭后仍然是 gpui-fast。
  • Zed 计划中的数字来自其作者,取自实验分支。
  • Rust 示例已针对随附的框架进行类型检查。Vizia 和 Freya 的示例来自它们各自的源码,并做了删减。

参考文献

来源:Hacker News · blog.zortax.de