Skip to content

The Engine Seam

脚本引擎位于一条内部接口之后。这条分界线(seam)之上的一切——元素描述 arena、把描述变成真实元素的 materialize、CallScope、样式表、主题、能力模型、浮层 Host 、hot-reload——都与引擎无关,只有引擎模块知道脚本值长什么样。

bash
cargo run -p gpui-shell -- examples/js_todolist

QuickJS 经由 rquickjs 随发行版一起提供——后者 vendored 的是 quickjs-ng 这个分支——它也是今天唯一的引擎。它仍然放在 quickjs 这个 cargo feature 之后,不启用任何引擎构建是编译错误,而不是编出一个什么都不导出的 crate。

为什么会有这条分界线

引擎选择是这个运行时里唯一无法在纸面上判定的决定。

其余的设计都可以从 GPUI 的元素模型推出来,在白板上就能争清楚。引擎不行,因为整套方案的成立与否取决于一个数字:脚本代码描述一个真实界面需要多久。 builder 链上的每一次方法调用都是一次跨语言边界,如果这笔单次调用成本过高,任何设计都救不回来。

变的是这个数字拿来跟什么比。脚本的一次 render 不再等于一帧渲染之后,一次描述在应用状态变动时构建,然后被之后的每一帧复用,直到状态再次变动——所以下面这笔成本是按用户操作付,不是按重绘付。这让边界成本不像从前那么关键,但它并没有变成免费,而且仍然是决定要不要引入第二个引擎的那个数字。

所以这条分界线是一种“不必提前判断正确”的做法。决定交给实测,而第二个引擎会是一个新模块,不是一次重写。

JavaScript 是默认,理由只有一条,而且是产品理由不是技术理由:应用代码用它写出来更好读。 呈现权在脚本手上,应用的绝大部分就是组合元素、写样式、处理事件——这类代码的可读性直接决定这个运行时值不值得用。类、箭头函数、模板字符串与解构,恰好落在这类代码上。附带收益是 JavaScript 在模型训练语料里覆盖最好,这对三种场景之一是决定性的。

代价也如实写出来:QuickJS 没有 JIT——它是字节码解释器,热点循环与单次调用成本原则上都赢不了带 JIT 的引擎。这是一笔真实的取舍,而下面的基准正是它一旦要紧就会显形的地方。

那次实测

这里有三笔不同的成本,把它们当成一笔正是最初的错误。基准测试描述一个 40 × 5 的样式化单元格网格——443 个描述节点,每个约十次记录操作——并把每笔成本分开报告:

bash
cargo test -p gpui-shell --release --lib benchmark -- --nocapture
测的是什么443 个节点什么时候付
A脚本 → Snapshot1.4 ms应用状态每变动一次
BSnapshot → GPUI 元素0.7 ms每一帧
C一整帧的缓存重绘1.8 ms一行 JavaScript 都没跑每一帧

要用 release 跑,否则数字没有意义。本页所有绝对数值都来自一台 MacBook Pro(M3,8 核,24 GB)上的 release 构建,会随机器变化。

C 是断言,不只是计时。 对一个未变化的 View 重绘五十次,一行 JavaScript 也没有跑过。哪怕有一帧跑了,就说明运行时退回到了按帧收取脚本成本的老路上——那时基准是直接失败,而不是只变慢一点。

只测一个规模,看不出这三笔成本里哪几笔会随规模增长,所以第四个测试把同一个面板一路放大到 8,403 个节点。它挂在 --ignored 之后,因为最大的那一档要跑好几秒:

bash
cargo test -p gpui-shell --release --lib benchmark -- --ignored --nocapture

描述一次,443 个节点是 1.1 ms;面板放大到 2,103、4,203、8,403 个节点,则依次是 5.1、10.3、20.5 ms。而一整帧——也就是 B 加上 GPUI 的布局与绘制,即 C 量的那件事——对应是 1.3、5.9、12.0、27.0 ms。两笔都随节点数接近线性增长;不增长的是 JavaScript——每一档的每一帧都是零行。这三件事因此说清了:

  • 4,203 个节点是 Snapshot 决定结果的那个规模。 12 ms 一帧稳在 60 FPS;若每帧都重建描述则要 22 ms,直接掉帧。比这更小的规模,两种做法都有余量——在过度解读那个倍数之前,这一点值得先知道。
  • 描述这笔成本没有消失,只是挪了位置。 8,403 个节点的 20 ms 是用户操作时才付,不是每秒付六十次;但它仍然是 20 ms,所以单次调用成本依旧是评判第二个引擎的那个数字。
  • 超过几千个节点之后,账单根本不在脚本一侧。 那个规模上 27 ms 一帧、其间一行 JavaScript 都没跑,花的是 materialize、布局与绘制。这么大的 View 该做的是虚拟化,换个更快的引擎也推不动它。

把 A 对照设计给出的预算,答案仍是“在预算之内,但余量比预期少”:目标是一次脚本 render 花 1.5 ms,实测达标;但那份预算是按 800 个节点、每次记录操作约 150 ns 推出来的,而实测是 443 个节点上约 320 ns,三倍规模的面板放不进一次描述。变的是这件事有多要紧。按老模型,120 FPS 下每一秒会有 168 ms 花在重复描述一个没人改过的界面上;现在同一个面板在用户真正改动时花 1.4 ms,重绘则是 0.7 ms。设计为超大面板列出的三条手段——压低单次调用成本、缓存未变化的子树、对长列表做虚拟化——依然还没有实现,但它们现在是优化项,不再是前提条件。

同一次实测还带来了两个实现选择,今天在运行时里看得到:

  • 元素是共用同一个原型的普通对象,样式方法由一段 JavaScript prelude 遍历名字列表装到该原型上。不是每个元素一个 class,不是每次属性访问新建闭包,也不是 3000 个 Rust 闭包。
  • 带诊断的 Proxy 原型不是默认。Proxy 包住原型以便说出拼错的方法名,代价是整个描述过程的约 30%;所以运行时保留普通原型,只在一次渲染失败后用诊断原型重跑一遍,纯粹为了产出那条信息。见 Styling

实时行情负载实测

合成基准把各项成本拆开测;Longbridge 行情终端则让它们在同一个真实负载中同时发生。下面的样本来自 release 构建,窗口处于活动状态,显示器分辨率为 3,840 × 2,160、刷新率为 144 Hz。Watchlist 持续接收实时行情,同时显示选中标的的详情与五日价格图。目标是 120 FPS,即每帧预算 8.33 ms

通过可选的运行时计数器按一秒区间采样,并将脚本描述成本与 native materialize 分开:

测量项实测范围
完整 JavaScript render 加 Spec recording每次脏渲染 12.0–13.5 ms
Snapshot materialize每次 0.93–1.08 ms
行情更新触发的脚本渲染每秒 8–20 次
窗口活动时的 materialize每秒 59–78 次

一次活动窗口下的 FPS HUD 样本为 69 FPS、帧时间 10.9 ms、掉帧率 18.3%。HUD 的测量包含 GPUI layout 与 paint,因此不能与前两项运行时计数直接互换,但它确认了端到端负载没有达到 8.33 ms 的目标。

有效结论比“JavaScript 很慢”更具体:未变化的 Snapshot 能在约 1 ms 内 materialize,明显低于一帧预算;但行情造成一次脏更新时,应用会重建并记录完整描述,完成 materialize 之前总计要花约 12–13.5 ms。因此,这个负载的主要成本是反复使根 Script View 失效;只优化 native materializer 无法恢复 120 FPS。

这些数据有意排除了 debug 构建,也排除了窗口失去活动状态后的样本。两者都会显著改变调度与帧呈现,FPS 不适合用于架构比较。这组数据也是工作负载实测,不替代上面可复现的 crate 基准:行情频率、可见内容、硬件与显示时序都会改变绝对值。

线程与内存

VM 与 GPUI 的 App 共用一个线程——主线程——在同一个进程里。ShellRuntime 是一个内部用 RefCellRc,既不是 Send 也不是 Sync。这里没有 worker,也没有第二个 VM。

Host 进程。主线程上,GPUI 的 App 与 QuickJS VM 通过 FFI 边界互相调用普通函数。后台 worker 处理计时器与阻塞 I/O,再回到前台执行器 settle,期间不接触 VM。内存分为四块:上限 256 MiB 的 JavaScript 堆、由 Snapshot 拥有的描述 arena、按 Snapshot generation 索引的回调 arena,以及只存活一次绘制的 GPUI 帧 arena。Host 进程。主线程上,GPUI 的 App 与 QuickJS VM 通过 FFI 边界互相调用普通函数。后台 worker 处理计时器与阻塞 I/O,再回到前台执行器 settle,期间不接触 VM。内存分为四块:上限 256 MiB 的 JavaScript 堆、由 Snapshot 拥有的描述 arena、按 Snapshot generation 索引的回调 arena,以及只存活一次绘制的 GPUI 帧 arena。

后台工作从不接触 VM。计时器(cx.sleepcx.timer)在那里倒计时,文件、进程、fetch、TCP 与 WebSocket 也把阻塞工作交给那里。结果回到前台执行器 settle,所以 JavaScript 续体仍在主线程、在一个 Task scope 里执行。元素产出之后,GPUI 也会用自己的线程完成自身工作。

做性能分析时,有三条推论要紧:

  • 一次 builder 调用就是一次函数调用。 它只跨过 FFI 边界,此外别无其他——没有序列化,没有 IPC 往返,除了参数本身的转换之外也没有拷贝。基准测试按记录下来的操作数报告这笔成本,四档面板测下来落在 240–340 ns
  • 脚本工作仍与 UI 共用线程。 文件、进程、fetch、TCP 与 WebSocket 会把阻塞工作交给后台 worker,再回到 foreground executor settle;但 JavaScript 计算与 HostModule 调用仍和 GPUI 在同一线程上,必须保持有界。
  • 失控的脚本无法从另一个线程抢占。 切断它的是解释器自己的中断——render 里 50 ms、事件处理器里 500 ms——而且 catch 吞不掉它。

内存分成四块,各有各的归属,也各有各的释放时机:

是什么待在哪里什么时候释放
对象、闭包、模块作用域QuickJS 堆,上限 256 MiB它自己的 GC 跑过,或者运行时销毁
元素描述 arenaRust 侧;移交给它产出的那份 Snapshot那份 Snapshot 销毁时
已注册的回调Rust 侧的 arena,按 Snapshot 的 generation 索引那份 Snapshot 销毁并退役它那一代时
GPUI 元素GPUI 自己的帧 arena构建它们的那次绘制结束时

一个 View 持有的是两份 Snapshot 而不是一份:当前生效的那份描述,以及被它替换掉的上一份。上一份要多留一代,因为一个已经在途的帧可能仍在读它,提前释放会把那一帧还需要的回调一并退役掉。

跨过边界的东西没有一个是对象。元素句柄是指向 arena 的一个整数下标;由 Host 留存的状态——InputState 的 rope、光标与选区——待在一个 GPUI 实体里,脚本通过句柄寻址它;每一个参数与返回值都是纯数据。

链接它要付多少

Host 在取这个依赖之前必须知道两个数:二进制大多少,内存多占多少。

测的是本仓库里最小的两个真实程序,这样得到的是这个 crate 的代价,而不是某个应用碰巧还装了什么的代价:

hello_worldgpui-shelljs_todolist增加
二进制,stripped12.6 MiB26.1 MiB+13.5 MiB
二进制,unstripped16.5 MiB33.8 MiB+17.3 MiB
常驻内存67 MiB81 MiB+14 MiB

hello_world 是 41 行 Rust,跑在 gpuigpui-component 上——一个窗口和一个计数器。gpui-shell CLI 是能跑起一个脚本应用的最小 Host;这里它跑的是 examples/js_todolist,四个模块共 519 行 JavaScript,背后是一个活的 QuickJS 运行时。内存取四次运行的中位数,丢弃缓存还冷时读数偏高的第一次;二进制是 --release、workspace 默认 profile,用 strip(1) 处理。

+13.5 MiB 是个常数,这是这里最有用的一点。 同一对测量放到组件 gallery 上——一个体量五倍于此的程序——stripped 增加的还是 13.5 MiB,只是占比从 +107% 变成 +19.8%。两次独立测量在三位有效数字上一致,这才让它成为关于 gpui-shell 的事实,而不是对某一个应用的一次读数。

内存那两行看起来矛盾,其实不矛盾。在 gallery 上这个差异落在测量噪音里:那个构建多次运行读数在 194–208 MiB 之间,而 14 MiB 本来就小于它自身的波动范围。最小程序能分辨出来,是因为 67 MiB 里没有那么多东西可以把它藏起来。

二进制大在哪

主要不是 QuickJS。解释器本身一到两 MB,剩下的是随它一起来的标准运行时。fetchwebsocketcrypto 带进了 hyperrustlsringh2、一份 webpki 根证书库和压缩相关的 crate,而 gpui-component 一个都不带——hello_world 里没有 HTTP、没有 TLS、也没有 tokio。整个这套栈都是从这个 crate 进来的。

这也解释了这张表更早的一版为什么写的是 +4.7 MiB:那是标准运行时之前的数字。fsnetcryptofetchwebsocketzlib 都是之后才加进来的。

而且今天没有"只要元素表面、不要这些"的配置。quickjs 是唯一的引擎 feature 而且是 default,用 --no-default-features 构建会直接 compile_error!,标准运行时也在这同一个 feature 里面。把两者拆开是新工作,不是把一个已经存在的开关暴露出来。

有两处看起来能省、实际不能的地方,都是实测出来的,不是推出来的:

  • 去掉那五个 Shell 并不注册的上游 crate——llrt_fetchllrt_fsllrt_netllrt_osllrt_console,它们此前只为一处编译期断言而成为依赖——二进制一个字节都不变。它们那些重量级 feature 解析到的 crate,其它依赖本来就会拉进来。不过它们还是被移除了:14 个不占字节的 crate 仍然要付编译时间和供应链审计面,而且依赖一个 Shell 刻意不用的上游 fetch,读起来就像它在用。
  • reqwest 收窄到 fetch.rs 真正用到的范围——去掉 charsetmultipartsocksstreammacos-system-configuration,这几个那个文件一个都够不着——省下 0.1 MiB

所以这 13.5 MiB 里没有水分。它就是 hyperrustlsring 和解释器本身;Host 只要想要 fetchwebsocketcrypto 里的任何一个,就得把它们全部链接进来。

每个运行时

上面这些数字对应一个运行时。挂多个的 Host——比如每个插件一个的插件 Host——每次都要付一遍引擎的构造成本:一个 QuickJS runtime 和 context、模块注册表、全局对象、 Host 安装器,以及一段 43 KB 的 prelude,每次构造都会解析一遍。Capabilities 里那个 256 MiB 的堆上限是天花板,不是预留;脚本不分配就不会占用。

分界线两侧各有什么

这个比例本身就是这条分界线存在的论据:上面是真正的设计,下面是“脚本值长什么样”。

分界线之上——与引擎无关分界线之下——由引擎实现
渲染 Snapshot:脚本 render 一次产出什么、之后的帧复用什么把引擎值转换成运行时里与引擎无关的值类型
元素描述 arena、一次性检查与调试树模块系统的形态——ES module 加解析器,还是 require 加路径表
materialize:把描述变成真实 GPUI 元素,纯 Rust方法派发——共享原型上的函数,还是 __index 元方法
CallScope:phase、generation,以及整个 crate 唯一的 unsafe回调句柄类型
样式表、有参样式与拼写建议把与引擎无关的错误类型转成该语言自己的异常
默认 token 调色板与颜色 token 解析View 如何定义——class extends View,还是元表
能力模型与路径解析沙箱中与语言相关的部分
长度与颜色的转换
与引擎无关的错误类型、回调 arena、错误浮层
ScriptViewShellRoot、hot-reload

左侧的模块,源码里没有一处出现 VM 的名字。这才是这条分界线为真的证据:它不是一个 trait,而是“crate 的其余部分只通过十来个入口触达引擎、此外别无他途”这一事实。

在这里用 trait 反而更糟。两个句柄类型——View 类与 View 实例——在 QuickJS 一侧各自带着生命周期标注,硬套一层 trait 只会把这份复杂度搬进类型系统,而不会消掉它。

契约里最吃重的一条规则关于什么时候而不是做什么:引擎的 build_snapshot 是进入脚本 render 的唯一入口,而且没有任何东西按帧调用它。 一个会见缝插针地渲染的引擎——重绘时、悬停时、定时器到点时——会把脚本成本重新压回帧预算,而这正是这条分界线要防的耦合。基准 C 就是抓这件事的。

可移植性

如果将来真的增加第二个引擎,脚本在两者之间不可移植。 它们会是不同的语言: View 在 JavaScript 里是 class Counter extends View,换一门语言就是别的写法。

必须相同的是围绕它的其余一切——绑定接口、渲染协议、phase 规则、能力模型、错误信息。设计提出的要求是行为层面的:同一个用例在两个引擎下必须产出同一棵描述树,同样的应用活动必须触发同样次数的脚本 render。这正是防止这条分界线腐烂成两个各行其是的运行时的东西。

已知缺口:异步还没有完全落在分界线之上

这条分界线的契约目前不覆盖异步。

QuickJS 要求 Host 自己去清空 job 队列——没人来问,await 之后的代码就永远不执行——而这不是每个引擎都有的形态。所以调度器无法整体落在分界线之上。它需要引擎再提供两个操作:把一个 Host 任务变成脚本可等待的值,以及跑完待执行的 job。

Promise job 会在 Host 调用边界被 drain;render 如果只是发现还有 pending job,会排入一次 foreground drain,而不是在 paint 路径上执行任意 continuation。这保住了核心不变量:异步 continuation 可以让 View 失效,但一帧绝不会仅仅因为自己是一帧就重新进入 JavaScript。

在这两条被处理之前,调度器是 QuickJS 专属的。它将来要遵守的规则,与任何新增能力的规则一样:除非确实无法表达,否则加在分界线之上。

为什么不用 WebAssembly,也不用独立进程

这条分界线会引出的两个问题。

gpui-shell 把 VM 跑在Host 进程内、主线程上,与 GPUI 的 App 在一起。正是这一点,才让单次记录调用停在 240–340 ns。独立进程会在每一次记录 builder 调用上加一次 IPC 往返;即便有了 Snapshot 把频率降下来,这份预算依然没有。同样的理由也解释了为什么没有 Worker:VM 与 App 都是主线程独占的。

wasm 目标是分界线画在这个位置的另一个理由。QuickJS 是纯 C,能编到 WebAssembly;不是每个候选引擎都能,有些还会生成机器码,这在禁止可写可执行内存的平台上是一项约束。这些事实都不决定今天的引擎,但它们是“引擎是架构的一个参数,而不是架构的一部分”这句话被写下来的原因。