大家好,我是搬砖工呀。
先祝大家十一快乐,假日漫漫。我们今天继续来学习 GPUI 的最最新变种:gpui-fast。
GPUI 以其强大的性能,特别是虚拟不定长列表的完美支持,赢得了开发者的一致好评。昨天一个读者热心在评论区提醒我,说 GPUI 只是及时模式(Immediate Mode),并没有保留模式(Retained Mode)。哎,看来我们都被他项目主页骗了(主页说有的呀!)。这不今天这个真保留模式就出来了。
https://github.com/longbridge/gpui-fast
gpui-fast,依然是长桥隆重推出的 GPUI 的Retained Mode版本。用来解决以下问题:
通过保留模式,显著降低 CPU 的占用,最高可达到 -80%
强悍的改进
一个有 60 个 Panel 的窗口,每个 Panel 有 60 个 Label 标签,我们每帧改变不同的 Panel 数量。
| 每帧 Panel 改变数量 | GPUI | GPUI-fast |
|---|---|---|
| None, still | 3.31 ms | 0.19 ms (−94%) |
| One | 6.72 ms | 0.71 ms (−89%) |
| Six | 7.27 ms | 1.46 ms (−80%) |
| All sixty | 10.33 ms | 7.38 ms (−29%) |
除了全部变动,真的有 −80% 的 CPU 降低,很强。特别对关心续航的移动平台,简直太合适了。
演示
GPUI Fast 强悍的保留模式
我们文章分以下几个部分:
GPUI-Fast
GPUI-Fast相比原GPUI,只努力做好两件事:
- Retained Mode(保留模式):只重画自上帧以来变了的部分。已经实现。
- Window composition(窗口合成,下一步):把 WebView 这类原生视图画进 GPUI 窗口内部,同时 GPUI 自己的 Popover、菜单、对话框仍然盖在它们之上。他们准备把上游还在评审的 zed#62379 搬到这个实验分支上先试。
为何不直接提交pr?
这两件事都要对 GPUI 做比较深的改动,按现在 Zed 的评审速度,先在这里实验尝鲜,再逐块往上游 Zed 的 GPUI 提。
改动原则
fast 整个仓库坚持在两条规则下运行:
- GPUI 现有 API 不变。新能力加在它旁边,应用不必重写 UI 代码就能选用。
- GPUI 自己的代码尽量少改。gpui-fast 的代码住在上游文件旁边的
fast/目录里,上游文件只留小的钩子,上游的改动照旧 merge 进来。每一处改动读起来都像“对当前 GPUI 的一小段 diff”,可以一块一块交给上游。
其他分支区别
注意区分 pre 和 fast。
| 项目 | 定位 |
|---|---|
| gpui-pre | 把上游 GPUI 的未修改快照发布到 crates.io,让 GPUI Kit 这类库能依赖一个已发布的 GPUI。gpui-fast 是另一个实验,不属于它。 |
| gpui-ce | 社区维护的 GPUI |
| gpui-fast | 焦点更窄:只为 GPUI 试 Retained Mode 和 window composition。进了上游的东西,最终会惠及上面两个项目和 Zed 本身。 |
接入 Fast
就是换一行依赖:
[dependencies] gpui = { git = "https://github.com/longbridge/gpui-fast" }以上是如何使用 GPUI-Fast,下面我们来梳理这个项目存在的必要性。
GPUI 的问题
GPUI 自己的 README 文件里,自我描述是“a hybrid immediate and retained mode, GPU accelerated, UI framework for Rust”—— 它并不承认自己是纯立即模式,那个 “retained” 的一半就是AnyView::cached,老外有时候是真的脚滑呀!
再一起往下看
1.1 一帧要读三遍元素树
GPUI 中一帧渲染的三个过程
| 方法 | 做什么 | 写进哪里 |
|---|---|---|
request_layout | view 渲染成 element,element 向 Taffy 申请布局节点 | 元素树的形状 |
prepaint | Taffy 计算布局,元素被摆放,注册 hitbox、dispatch node 与监听器 | hitbox / dispatch / listener |
paint | 元素把图元写进场景,交给 GPU | scene primitives |
一帧的产物存放在窗口Frame上的扁平、只追加的数组里:hitbox、dispatch node、listener、element state,以及场景的图元。窗口同时持有两帧——屏上的那帧、正在画的那帧——画完交换。
最大问题在于:上游默认每次都把这三个过程从头调用一遍。渲染每个 view、建一棵新的元素树、建一棵新的 Taffy 树、重新 shape 文本、重画整个场景——哪怕只是表格里一个数字变了。
这就是GPUI的最大问题,他几乎每帧都是全窗口渲染。
1.2 GPUI唯一的Cache
GPUI 里确实有一个跳过工作的机制,但它窄得可怜:
- 一个被缓存的 view(
AnyView::cached)会记住自己上一帧写进 Frame 的区间(PrepaintStateIndex、PaintIndex); - 当它没被 notify且bounds 没变时,直接把这些区间从上一帧复制过来,而不是重画自己 —— 复制由
Window::reuse_prepaint/Window::reuse_paint完成。
这其实就是保留模式的种子。但它有三个硬限制:
- 最小单位是“整棵子树”。没有比“缓存这一整块”更细的选项,也不能在一个必须重建的子 view 两侧复用父级。
- 判定条件只有两个:有没有被 notify、画在不在原位。它不看这个 view 实际读了什么。
- 盖不到的地方太多—— 未显式
cached的 view 一律每帧重建,而这正是绝大多数 view。
1.3 “改一点点”很昂贵
其一,逐层向上重建。notify 一个 view,会把它周围一圈也标脏 —— 因为要走到它,就必须经过它们。上游的选择是“那就全都重建”。于是:一个根 view 里挂着侧栏、工具栏和页面,页面深处的一点改动会重建整窗。
其二,布局与文本的缓存非常脆。上游在每帧结束时清空整棵 Taffy 树;而 Taffy 的set_style/set_children/set_node_context无条件把该节点及其所有祖先的布局缓存标脏 —— 值没变也照脏。文本更麻烦:一个测量自己的文本叶子持有一个闭包,给节点换新闭包就会脏掉它和它上面的每一层,而文本恰是每帧都可能变的那部分(表格里跳动的价格)。
fast 的架构设计
fast 保留 GPUI 帧的渲染设计,而不是另建一棵 UI 树。但它有如下约束条件。
2.0 三条约束
fast 有以下三条约束:
| 约束 | 原话含义 | 它换来了什么 |
|---|---|---|
| 从头画的帧就是规格 | 一个保留帧正确,当且仅当它等于上游 GPUI 在同一状态下从头画出的帧 | 没有第二套“正确”要讨论,而且它可以被机械测试 |
| 公开 API 保持上游的 | 为上游 GPUI 写的代码原样编译运行;不需要任何 opt-in,除了 cached view 上游本来就要求的那一条规则 | 应用迁移成本 ≈ 0,也逼着实现不能靠“让用户配合”来简化 |
| 上游的代码是上游的 | 逻辑住在fast/,上游文件只留小钩子,由script/check-upstream检查 | Zed 的改动持续 merge 得进来,且每块改动可以单独提上游 |
它保留的是“帧的工作”,不是一棵并行的 UI 树。
2.1 fast 只是把现有机制泛化
| GPUI | gpui-fast 的做法 |
|---|---|
只有显式cached的 view 会被复用 | 每个view(任何摆放的Entity<V: Render>或AnyView)都是一棵保留子树 |
| 判定:没被 notify + bounds 没变 | 判定:它实际依赖的东西都没变 + 画在原位 |
| 复用是“全有或全无” | 可以在必须重建的嵌套 view 两侧复用父级(splice) |
| 只有 prepaint / paint 的区间被复制 | 布局节点、文本测量、路径三角化、场景重叠顺序都跨帧保留 |
说明一下:就是 fast 加强了对一帧变动的检测,不再是 GPUI 的简单粗暴,更细致,更精细。
2.2 下一帧怎么把它认回来
任何跨帧保留的东西,都必须能和下一帧来申请它的那个 element 对上。GPUI Fast 没有为此新建一棵持久的 view 节点树,而是复用了 GPUI 已有的两套身份:
view →GlobalElementId。一个保留 view 的记录由它的GlobalElementId找回 —— 从根到这个 view 的ElementId路径。路径里包含它自己的 entity id,因为上游为每个 view 都压入ElementId::View(entity_id)。这恰好就是上游给 element state(滚动位置、hover 状态、文本输入状态)用的身份,所以**“一个 view 被保留”与“上游认为它是同一个 element”是同一个条件**。
hash 只算一次并缓存(每帧为每个查找去哈希一条路径太贵),但相等性比较的是完整路径:先比 hash 再比路径。两个不同 view 的 hash 碰撞,不会让其中一个复用另一个的记录。
布局节点 → 一个 64 位的 key。它只是“用来找到缓存条目的钥匙”,不是身份:路径哈希、或元素的ElementId、或它在uniform_list/list里的下标。命中的节点在被使用之前要被对齐三件事:
- style:与本次请求比对(比对转换到 Taffy 时读到的每个字段的指纹;debug 构建会对每个指纹命中再做一次完整比较),不同才重写;
- children:按节点 id 列表精确比较,不同才重写;
- 测量:一个测量过的叶节点,只有在新输入与它当初测量时的输入相同(同样的文本、runs、文本样式),或“在所有 Taffy 用过的约束下重测都得到同样尺寸”时才保留测量结果。一个自己测过自己、却被一个不测量的 element 认领走的节点,会失去它的测量。
所以两个跨帧 hash 到同一个 key 的 element,拿到的是一个 style / children / 测量都被改写成第二次所要求的内容的节点 ——结果是同样的布局,代价是一次节点改写。帧内第二个想认领同一个 key 的 element 会拿到一个全新节点,而不是共享。key 决定“试着用哪份缓存”,正确性不依赖它。
为什么不做持久化节点树。最直觉的替代方案是维护一棵持久的 view 节点树(比如放在 slot map 里),每个节点持有自己的布局子树和渲染输出。GPUI 的元素树每帧重建是设计使然,而它已有的状态本来就带身份;再建一棵平行树会重复那套身份,并要求重构窗口的绘制代码 —— 那会直接违反第三条约束,让这个 fork 变得难以保持合并。
性能的问题不在重建节点树上,而在对节点数的三次调用上。
这里我在移植 GPUI 的时候,也是一样的处理。通过全局 ID 来区分上下两帧是同一个 View。
2.3 必须重画
复用只有在“影响这个 view 绘制结果的每一处变化都是可观测的”时才安全。文档记录了四类会被记下的读:访问过的实体、读过的全局量(带 generation;只问存在性的cx.has_global只依赖存在性)、带 version 的ScrollHandle/ListState、以及窗口的环境输入(mouse_position/modifiers/capslock,输入事件后比对取值,变了就重画读过它们的 view)。
关键在于读被记成两个集合:
- subtree dependencies:它读到的全部,包含嵌套在它里面的 view 读的 → 回答“这棵子树能原样复用吗?”
- own dependencies:它自己读的,排除其中保留的嵌套 View → 回答“它本身没变,只是里面某个 view 变了?”
这个区分是整节设计的支点,它把下面两件事分开了:
它自己变了 → 重建它 它没变、里面某个变了 → 把那个内层 view 拼进去(splice),外层不动注意:特别是第二点,我们在滚动里面,滚动里面的内容确实变化,但是滚动的 View 本身大小可视没用变化的,GPUI 这时候已经带着外层 View 一起刷新了。
其中第二类最值得单独拎出来:一个实体被 update 但没 notify(cx.subscribe/cx.observe的处理器每次事件都会更新它的订阅者,不管它是否关心这个事件)只对“自上一帧起被 notify 过的 view 内部”算变了。这恰好等于上游会整棵重建的那一批,所以行为等价、成本更小。反过来,一个实体被 notify 但没被 update(滚轮、拖动滚动条、动画)会被重画,而只读过它的 view不算变 —— 因为它持有的东西没变。
另外两类失效来源也在这里:画在哪里(bounds、content mask、继承的文本样式、opacity 任一不同,输出就不能直接复制;若布局节点仍有效,它在原节点上按父级给的大小重建,不牵连四周),以及怎么被 hover / 交互(只有发生交互的最内层 view 重建,外层从上一帧画过去)。
例外:
窗口刷新时(window.refresh()、resize、焦点变化)、拖动时、inspector 取元素时、辅助功能激活时,什么都不保留,每帧从头画。
2.4 如何重绘
对每个摆放的 view,一帧会从五条路径里选一条:
| 路径 | 何时 | 做什么 |
|---|---|---|
| Reused | 它依赖的都没变,且画在原位 | 布局取自保留的节点;prepaint / paint 区间、以及嵌套子树的记录,整段从上一帧复制 |
| Spliced | 它变脏只是因为某个嵌套 view 变了 | 输出按段从上一帧复制,只在缺口处重建那个变了的内层 view |
| Built at its retained layout | 它读的没变,但它移动了、或继承的属性变了 | 在保留的节点上重新渲染;若它要的布局不同,就在父级给的边界内重新布局,下一帧转为从头建 |
| Prebuilt | 内层 view 已经为一次“结果没能拼接”的 splice 提前建好 | 外层直接接管这个已建好的内层 view,而不是把它建两遍 |
| Built | 其余一切 | 按上游的方式渲染、布局、prepaint、paint,并记录它读了什么 |
这条 splice 的逻辑要说清两句话:
- 它只是优化,永远有回退。如果那个变了的内层 view 要求不同的布局(新节点、被改写的 style 或 children、不再成立的测量),外层就退回完整重建,并接管已经建好的内层 view(即 Prebuilt 那一行),绝不建两次。
- 内层 view 能单独重建,有个前提:它必须是以
Entity或AnyView摆放的 —— 也就是 view 几乎总是被摆放的方式。
2.5 布局与文本
把 Taffy 节点跨帧留着,本身不是优化。因为set_style、set_children、set_node_context会无条件把该节点及其每一个祖先的布局缓存标脏。
真正的优化是“值没变就不写”:每个保留节点记住“上一帧收到的那次请求”(style 指纹、children 列表、文本叶子的测量输入),先比再写。
文本要单独处理,因为它的输入是已知且可比较的(其它测量叶子大多不行,只能每帧换闭包、每帧脏):
- Carry:同一位置、同样的 text / runs / text style → 新 element 直接继承上一帧的测量结果,节点保持 clean。只改了颜色或装饰时,测量照样继承,装饰就地改写。
- Replay:文本变了(表格里跳动的价格)→ 节点保留一张“自上次被标脏以来,Taffy 用哪些约束测过它、各得到什么尺寸”的日志;新文本用同样的约束、同样的顺序重测。如果每个尺寸都一样,那么 Taffy 为该节点和它上面每一层缓存的东西依然成立,节点保持 clean。只有尺寸真的变了的文本,才脏到它的行、它的列表和上面的窗口。
2.6 场景与其它保留项
- 重叠顺序:场景给每个图元一个 ordering(比它压住的所有更早图元的最大 ordering 大 1),这一步上游用 R-tree 做空间查询。GPUI-fast 换成均匀网格(几千个小 bounds 的场景下更快),更重要的是:复用子树的图元 bounds 与上一帧相同,所以它们的 ordering 是回放而不是重算 —— 一帧里什么都没动时,这步从“每个图元一次查询”变成“一次复制”。
- dispatch node 的复用区间一次写入;路径(sparkline、折线、环形)按相对首点三角化后缓存三角形;glyph run 每个 run 只算一次渲染;element id 与绝对 bounds 每帧缓存、不再重复哈希。
- 指纹:节点 style 是否变了,由 style 的 64 位指纹判定。release 构建信任它,debug 构建会把每个命中再做一次完整比较,专门用来抓被指纹漏掉的字段。
2.7 应用要做什么
安全的复用 要求 影响渲染的每一处变化都是可观测的这是任何 reactive / 增量系统的边界,不是 GPUI Fast 特有的。
| 状态 | 谁负责 |
|---|---|
实体(读、update、notify)、全局量、ScrollHandle/ListState、hover 与交互、窗口的指针位置 / 修饰键 / caps lock | GPUI Fast 自动跟踪,变化只失效读过它的 view |
Rc<RefCell<T>>/Cell<T>(在实体之外)、原子量、线程局部或静态可变状态、Instant::now()、文件与网络状态,以及任何其它内部可变性 | 应用自己负责。它们不是“不支持”,改变它们的东西必须同时触发一次 GPUI 通知(cx.notify(),或一次带 notify 的entity.update),否则 view 会一直显示上次构建时的内容 —— 上游对 cached view 本来就有同样要求 |
还有两条“每帧都变 = 每帧都重建”的坑,值得抄进 code review 清单:
- 在 prepaint / paint 里 notify 实体或写全局量,即使值相同也算变了;
- 一个每帧都 notify 自己状态的、可调整大小的面板,会让所有读该状态的 view 都无法被保留。
排查用一条环境变量就够:GPUI_VIEW_RETENTION=0,它让每个 view 都从头画,用来“排除 / 确认”保留模式本身是不是那个可疑点。
Fast 的保留模式
第二节讲的是“它怎么做到”,这一节讲“它对外承诺什么、怎么证明、怎么测量”。
3.1 一个 view 的依赖清单
以“被什么更新”为主线重新排一遍,因为这决定了你会不会写错代码:
| 依赖种类 | 细节 |
|---|---|
| 被什么更新 | updated 且 notified(在非绘制期,由任务、监听器、action 触发)→ 对所有读过它的 view 算变了;notified 且正在绘制中→ 同样 |
| updated 但没 notify→ 只对“自上一帧起被 notify 过的 view 内部”算变了(因为上游会连它下面的一切一起重建) | |
notified 但没 updated→ 它自己被重画;只读过它的 view 不算变。一个只想重画自己的定时器,应该cx.notify(entity_id)而不是去 update 拥有这个驱动器的 view | |
| 读到了什么 | 访问过的实体(含没被 observe 的 model、含自己的实体);读过的全局量(cx.has_global只依赖存在性);ListState/ScrollHandle的 version;窗口的指针位置、修饰键、caps lock |
| 画在哪里 | bounds、content mask、继承的文本样式、opacity |
| 被什么 hover 过 | view 记录自己被画到时各 hitbox 的 hover 状态;只有最内层那个 view 重建 |
两个必须记住的写法约束:
- 靠内部可变性改实体内容是不行的:
entity.read(cx).cell.borrow_mut()这样改,读它的 view 看不到变化 —— 必须用update。 - 窗口每帧会向聚焦的文本输入查一堆东西(是否接受文本、选区、某个范围的 bounds,走
ElementInputHandler)。这些查询会 update 那个输入的实体,但不算 change,除非它在被问的时候 notify。
3.2 每棵保留子树一份记录
每一帧为每棵保留子树保存一份记录:它的 hitbox / dispatch node / listener / 图元写到了哪里、它读了什么、被哪些 hover 画过、持有哪些布局节点。
记录放在帧里,而不是放在 element state 里—— 因为它指向的那些区间属于某一帧。这个细节带来一个关键能力:
同一棵子树被复用时,它的记录、以及嵌套在它里面的所有子树的记录,会被一起复制并平移到副本落地的位置。这就是“以后外层不得不重建时,内层仍然能单独复用”的原因—— 否则一个被重建的 view 会把它里面的一切都重建一遍。
3.3 Cached 视图
Entity::cached(style)/AnyView::cached(style)是上游 API,行为按上游文档走。在这个实现里:
- 它也是普通的保留子树,所以当它读过的实体或全局量变了,它同样会被重建——而不只是“看有没有被 notify”;
- 它被 notify 时,会在它保留的布局节点上单独重建,外层从上一帧画过去——就像任何嵌套 view 一样。
3.4 验证
因为“从头画的帧就是标准”,验证就是把两者直接对比:
| 手段 | 做什么 |
|---|---|
Oracle 测试(crates/gpui/src/fast/tests/oracle.rs) | 驱动两个窗口走同一串随机历史:一个增量绘制,一个每帧忘掉保留的一切。要求每一帧完整场景都相同:layers、shadows、quads、underlines、单色 / 亚像素 / 多色 sprite(即文本、图标、图片)及其重叠顺序,还有 paths;每个 hitbox 的 bounds、content mask、behavior 也要相同。它还断言增量那一侧真的复用了布局节点与子树,防止“靠从不保留”蒙过去 |
单元测试(fast/tests/与代码同文件) | 逐个机制覆盖:复用、按每类依赖触发的重建、被移动的 view、拼接、layout key、文本 carry 与 replay、bounds 网格、dispatch 复制、布局节点不泄漏 |
gpui_perf --headless --verify | 每个基准场景保留开 / 关同步跑,逐帧比对 quads、text、icon、image sprite 与 underline 的 bounds、clip、color(不比对 paths 与 shadows,那份由 oracle 管) |
GPUI_VIEW_RETENTION=0 | 运行时把每帧都从头画,用来在应用里定位问题 |
但文档把边界也写清楚了:这些检查建立的是“对它们跑过的那些状态变化序列,保留输出等于从头输出”。它们无法覆盖应用在依赖跟踪之外读的隐藏状态——一个读了隐藏状态、而该状态变化时没有通知的 view,任何这类测试都看不见。
4. 上游同步
一个被我们重写过的文件,每次同步都会冲突;一个只有一行钩子的文件,几乎永远不冲突。
所以整条流程的设计目标就一句:让上游更新落下来时,冲突只落在钩子行上。
4.1 执行规则
- 逻辑只放在
crates/<crate>/src/fast/,一个主题一个文件(fast/retained.rs、fast/dependencies.rs、fast/layout.rs……);一个主题需要多个文件时用fast/<topic>/。其它上游 crate 需要时也有自己的src/fast/。新增能力的测试放fast/tests/<topic>.rs或该主题文件的底部。 - 上游文件只放小的钩子,而且每个钩子都要点名
fast:- 一个持有
fast/里定义的结构的字段; - 一行调用
crate::fast::...,或者一个方法体只转发给它。即使写成方法更短也要写全路径—— 写成crate::fast::dependencies::note_notify(&mut self.entities, id),而不是self.entities.note_notify(id),这样合并的人一眼就能分辨哪一行是我们的; - 一次可见性放开(
fn→pub(crate) fn),好让fast/能碰到上游的东西; - 一个叫
fast_...的标识符(当需要把一个普通类型的字段 / 参数 / 局部变量穿过上游代码时); mod行;- 当我们把某个上游文件整体换成自己的重写时,用
#[path = "fast/<file>.rs"] mod <name>;重定向 —— 原上游文件保持上游原样、不被使用。
- 一个持有
- 上游文件里不许有新类型、新算法、新簿记、新测试,也不许重排、重命名、重排格式:不需要改的代码保持与上游逐字节一致。
fast/之外一律写全路径(crate::fast::<topic>::Name),不许出现use crate::fast::…,好让每个钩子自己说明代码住在哪。唯一例外是gpui.rs一行一个地导出test-support条目。任何 glob import 或 re-export(pub use fast::*)都不允许。- 不新增公开 API。gpui-fast 改的是“GPUI 怎么画”,不是“GPUI 提供什么”:它的公开 API 就是上游的。测试与
gpui_perf需要的东西只在test-support下编译,一行一个从gpui.rs导出。 - 新文件只放
fast/内,或自家的 crate(如crates/gpui_perf:基准、示例、测量应用);文档放docs/。
哪些目录属于上游,记录在根目录的UPSTREAM文件里:本仓库crates/里除gpui_perf之外的每一个目录,加上tooling/perf—— 一共 26 条。
zed_repository = "https://github.com/zed-industries/zed" zed_commit = "7960b2a7c9568e90fbe0727332149e5b2a5fd57a" import_commit = "11a44c4882f32789a8292b3f0682ffa4e6b6e79e" # 保存那次导入、原样未改的提交import_commit那一行很关键:因为有了它,git show <import_commit>:<path>就能直接拿到上游版本的任何文件,不需要本地有一个 zed checkout。
4.2script/check-upstream:把规则变成会失败的检查
它把上游目录里的每个文件与“上游自己的版本”逐文件比较(默认取自己历史里的import_commit,加--zed <path>就对一个真实的 Zed checkout),检查工作区,所以要在提交前跑。命中任一条即失败:
- 上游目录里新增 / 删除了
src/fast/之外的文件(LICENSE*除外); - 某个 hunk 增加超过8行(
--max-hunk); - 某个文件增加超过40行、或删除超过20行;
- 一个改过的
.rs文件的某个 hunk,加进去的行没有一行点名fast(一个路径如crate::fast::...、mod fast;、#[path = "fast/..."],或一个以fast_开头的标识符——一处提及即可覆盖整个 hunk,因为 rustfmt 可能把一次调用折成几行)。只删行的 hunk、只改use或空行的 hunk 豁免; - 二进制文件有差异;
- 任何文件(
fast/也包括)glob-importfast; fast/之外的文件出现use fast(只允许 public 的pub use fast::…导出)。
除了 glob 规则,任何src/fast/目录下的文件从不检查。只因为可见性放开而与上游不同的一行,按定义就是钩子:它被记在表格的pub(crate)列,不占任何预算。
有正当理由的例外,一行一条写进script/upstream-allowlist,带上理由,也可以抬高预算(hunk=N/added=N/removed=N)或允许任意改动(any)。通常的理由只有两类:钩子密集的枢纽文件;某个上游函数体被换成了对fast/的转发,例如:
crates/gpui/src/view.rs removed=210 # ViewElement's cache-by-bounds is replaced by fast::retained's retained views删除的行最贵。上游对被我们删掉的代码做的每一次改动,都会在每次同步时冲突,而且必须手工移植回fast/。
实测数字(文档写作时):28 个受跟踪的上游文件与上游不同;其中crates/gpui/src里的19 个文件合计 +464 / −737 行——删得比加得多,因为有几个方法体被换成了对fast/的一次调用。
4.3 取一个新上游:五步
- 选新 commit。在一个 zed checkout 里定下新 commit;在本仓库从上一个 vendor commit(第一次是
import_commit)开分支,按UPSTREAM里的tracked目录整体替换,提交为新的 vendor commit(zed: import <hash>)——这个提交里只有上游代码。 - 合并。把这个分支 merge 进我们的分支。冲突应该只落在钩子行上:保留上游的代码,把我们那一行钩子放回去。
- 手工移植。对每个用
#[path = "fast/…"]重定向过的文件,看上游在原始文件里改了什么(git diff <旧 vendor commit> <新 vendor commit> -- <file>),手工移植进我们的同名实现。这类文件不会冲突,所以最容易漏。 - 更新记录。把
UPSTREAM里的zed_commit与import_commit换成新的;上游增删了我们用到的 crate,就同步改tracked。 - 验证。跑
script/check-upstream、测试与 clippy。
检查失败时的修法也写好了:把改动挪进一个fast/模块,在原处留一个点名它的钩子;用git diff <import_commit> -- <file>看看到底哪里不一样。
5. 性能
全部为 release 构建、Linux、主线程每帧 CPU。“上游”是同一份代码把每个 view 从头画(等于GPUI_VIEW_RETENTION=0)。
headless,60 个面板 view × 每面板 64 个 label(retained_bench):
| 每帧被 notify 的面板数 | 上游(从头画) | gpui-fast | 变化 |
|---|---|---|---|
| 0(静止) | 3.31 ms | 0.19 ms | −94% |
| 1 个 | 6.72 ms | 0.71 ms | −89% |
| 6 个 | 7.27 ms | 1.46 ms | −80% |
| 全部 60 个 | 10.33 ms | 7.38 ms | −29% |
真实窗口里的gpui_perfshowcase(组件画廊按 GPUI Kit 的写法:243 页的侧栏、每页把状态放在带 key 的实体里的组件分区、5000 行数据表、5000 条消息的gpui::list、每 2 秒变一次并被大多数 view 读的全局实体,以及一个被行情流更新的、停靠着的行情工作区),分别在 gpui-fast 与上游gpui-pre0.3.7 快照上构建,按 32 px/帧滚动(相当于快速拖动滚动条):
| 场景 | 上游 gpui-pre | gpui-fast | 变化 |
|---|---|---|---|
| 一个旋转动画在跑 | 5.91 ms / 85% CPU | 0.73 ms / 11% CPU | −88% |
| 滚动侧栏 | 5.95 ms / 86% | 0.79 ms / 12% | −87% |
| 滚动一页组件 | 5.96 ms / 82% | 0.79 ms / 12% | −87% |
| 滚动数据表 | 3.07 ms / 44% | 0.54 ms / 8% | −82% |
| 每 33 ms 刷新表格 | 11% CPU | 2% CPU | — |
| 滚动列表 | 2.66 ms / 39% | 0.39 ms / 6% | −85% |
| 持续灌入行情报价 | 5.06 ms / 43% | 1.35 ms / 15% | −73% |
| 滚动工作区的自选列表 | 5.23 ms / 77% | 1.33 ms / 24% | −75% |
| 在工作区的自选列表上悬停 | 5.16 ms / 42% | 1.32 ms / 14% | −74% |
复现命令:
cargorun-pgpui_perf--release----autocargorun-pgpui_perf--release--no-default-features--featuresupstream ----auto总结
个人理解,Fast 在 GPUI 的原基础,提供了一套工程化的解决方案,可以在应用层不需要改变的前提下,自动识别变化区域,大大降低 CPU 的占用。对下游用户,并不需要太多的改动。
以上就是今天全部内容,多多关注点赞,您的支持是我更新的最大动力。我们下期见。