全部文章
博客版本发布性能工程

五十张卡片,一块画布:CPU 都花到哪去了,以及“团队”卡片

一块放着三张智能体卡片、什么都没在做的画布,吃掉了一整个 CPU 核心。罪魁祸首是一个卡通形象。下面是我们测到了什么、改了什么,以及如今每块画布上都有的那张卡片。

阅读约 6 分钟0.1.117 带来了什么

NeuroSquad 0.1.117 这个版本,你主要是通过“不再发生的事”感受到的:什么都没做时风扇狂转;拖动一个装满卡片的框时一顿一顿;每次缩小到卡片变成磁贴的那个临界点时卡一下。它还加入了“团队”卡片——一块告诉你工作区里每个智能体在做什么的面板。

从最初那几周起,画布的目标就没变过:最多五十张活的卡片——终端、浏览器、智能体——平移、缩放、拖动和调整大小都要流畅。达成目标的规矩也没变过:先测量。本文中的每个数字,都来自一个使用全新配置文件的独立应用实例,由通过 DevTools 协议发送的真实输入事件驱动。

第一轮:五十个终端

第一轮优化发生在公开发布之前,场景是一个有五十张卡片的工作区,其中十二个终端在全速输出。缩放之所以慢,原因很容易被忽略:每张卡片的标题栏都要在屏幕上保持固定尺寸,而它是用 width 和 margin 实现的。这些都是布局属性。缩放的每一帧都会改变每个标题栏的尺寸,进而改变每张卡片主体的尺寸,进而触发每个终端的尺寸观察器——五十次终端重新适配、五十条发往主进程的消息、五十次控制台尺寸调整。每一帧都是。

修复办法是:缩放值每个手势只提交一次,而不是每帧都提交;只有当终端的行列网格真正变化时才调整终端尺寸;缩放进行时,把活的终端网格换成静态占位图。随后,终端从 DOM 渲染器换成了 xterm 的 WebGL 渲染器,大部分时间正是耗在这里:缩放的中位帧时间从 18.2 ms 降到 6.1 ms,p95 从 133 ms 降到 12 ms。

那一轮的一项测量定下了一条至今仍在代码里的规则。把终端藏在占位图后面,让缩放快了一倍(中位数 6.1 对 12.0 ms),却让平移变慢了(平均 19.8 对 16.3 ms):平移只是移动已经绘制好的图层,把五十个网格换出去再换回来,花的比省的还多。所以只有当手势真的改变了缩放时,才会出现占位图。

第二轮:空闲的画布

第二轮始于一个更奇怪的抱怨:什么都没发生的时候,画布也很沉。四路并行的代码审查加上一次针对“Launch day”工作区的实时性能剖析,发现原因不是一个,而是一堆小而持续的开销。其中最大的一个,是个卡通形象。

AI 智能体卡片上有一个显示智能体状态的小吉祥物。它的待机动画是作用在 SVG 子元素上的无限 CSS 动画——而对这类元素,浏览器要在主线程上每一帧都重绘,永无止境。一块缩放在 60% 的画布上放着三张 Claude Code 卡片,什么都不做,渲染进程就占用了一个核心的 98–104%,GPU 占 32–36%。现在只有“工作中”状态会循环,而且动的是外层的 HTML 包装元素,由合成器独立处理;其他状态都只播放几遍,然后停在一个本身就能说明状态的姿势上。

~100% → 15%
渲染进程 CPU,放着三张智能体卡片的空闲画布
~34% → 3%
同一块画布上的 GPU
180 → 66
一条 CSS 规则之后的合成层数量

剩下的是一串清单,每一项都只花一点点,却全都一刻不停地在跑:

  • 画布下带 * 和 :has() 的选择器。每次在画布面板上切换一个类,都要花 25–34 ms 重新计算样式,因为“手势下的所有后代元素”这类规则必须对每张卡片的每个节点重新匹配。改为只列出真正有动画的元素后,降到了约 1.5 ms。
  • 面板上的 CSS 变量。缩放级别存放在包含所有卡片的那个元素的自定义属性里,一改动就会让整棵子树(包括终端)的样式失效——每次提交 22–34 ms。现在它是 <style> 标签里的一条规则,只作用于真正读取它的元素。
  • 自带图层的按钮。组件库会给按钮单独分配一个 GPU 图层。在画布里,这意味着多出几十个图层;一条在非按下状态下生效的覆盖规则,把图层数从 180 降到了 66。
  • 卡片里的定时器。需要轮询的卡片——端口、预算、站会、网页监视——现在只在所属工作区打开且窗口可见时才计时,并且同一周期共用一个时钟,而不是各开一个定时器。
  • 没人在看的浏览器卡片一直在推送画面帧。现在只有当某个窗口正在显示这张卡片时,画面帧才会流动:在概览中和隐藏的工作区里为 0,回来后恢复。

拖动框、框选、跨越概览阈值缩放、在概览中平移——这些手势期间的长任务都消失了。平移和拖动所需的渲染进程 CPU 大约减半;一次平移的性能追踪中,样式计算时间从 311 ms 降到 53 ms,布局时间从 378 ms 降到 28 ms。

当卡片小到看不清时

缩得足够小,终端就只是一个灰色矩形。低于 55%(阈值可在“设置”中调整)时,每张卡片会改为显示一块磁贴:它的图标、名称,以及对这类卡片最重要的一项信息——智能体的状态、待办清单上的 3/11、看板的各列、笔记的第一行。

卡片主体仍然以原本的尺寸挂载在下面——终端没有被杀掉,甚至没有重新适配。磁贴按屏幕像素绘制,所以在任何缩放下都清晰可读。跨越阈值时,重新渲染的卡片主体是零个,每块磁贴各渲染一次;平移则什么都不渲染。

缩小到 55% 以下的 Launch day 工作区:每张卡片都是一块显示状态、清晰可读的磁贴
缩小后的“Launch day”:每张卡片都变成一块显示其主要信息的磁贴,而终端仍在下面继续运行。

这里有个微妙之处。藏在磁贴后面的终端虽然看不见,却没有暂停——终端库自带的可见性检测感知不到 CSS 的可见性——所以它会继续渲染没人看的输出。现在的做法是:卡片把输出攒起来,等你放大回来时一次性写入。只有一类数据块例外:程序向终端提出的询问(“你是什么终端?”“光标在哪?”)是对话的一部分,必须当场回答。延迟的回答曾经以字面量 1;2c 的形式出现在 bash 提示符里——这是实测中发现并修复的。

“团队”卡片

画布上智能体一多,“谁在等我?”这个问题就不该需要你把画布巡视一遍。“团队”卡片在一块面板上回答它:工作区里的每个 AI 智能体,按等你处理、工作中、已完成和空闲分组,并显示它的 CLI、模型以及当前这一轮已经运行了多久。点击磁贴可以筛选;点击某一行,镜头就会飞到那张卡片。

可交互复刻——动手试试
团队状态5 个智能体1 个等你处理
“团队”卡片的复刻,用的是应用自己的文字。时钟在走;先点一块磁贴,再点一行。

模型这一栏比看上去更讲究。它显示的是智能体自己的会话日志里记录的最近一次请求所用的模型;没有的话,就用卡片上设置的模型;再没有的话,就用 CLI 自己配置文件里的默认值。三者都没有时,它显示“默认模型”——而不是瞎猜。

Launch day 工作区中的“团队”卡片,列出三个 Claude Code 智能体及其状态
真实的卡片。当某个智能体停下来问你问题时,它那一行会泛起一次琥珀色。

“团队”卡片会被添加到每个工作区一次,位于最左侧卡片的左边。删掉之后它就不会再回来——你可以从菜单里重新添加。把“一次”做对,还多修了一个问题:最初的版本在工作区保存的布局加载完成之前就放下了这张卡片,结果画布从头重新排布了所有卡片。首次打开已有工作区时,所有卡片可能会堆在一个角落里。这就是本版本“修复”一栏里列出的那个 bug。

添加卡片菜单也变了:智能体排在最前面,本机已安装的会被标出,多了一个搜索框,空白画布会以大号智能体磁贴开场。完整列表见 0.1.117 的更新日志。