我们差点被自己的跑分"骗了":OrcaTerm 从落后 17 倍到 6 项第一,只用了两刀
起因:一次"好得不真实"的跑分。结尾:10 项测试 6 项第一,滚动场景屠榜。中间:发现我们的 benchmark 一直在"作弊"——而作弊的,是我们自己写的队列。
一、先讲一个"高兴了三分钟"的故事
那天团队里有人惊呼:OrcaTerm 的 vtebench 跑分出来了,有几项居然比 Warp 还快。
要知道 vtebench 是业界测终端渲染性能的标尺:固定、可复现的 VT/ANSI 输入,测从输入到处理完成的耗时。Warp 和 Ghostty 是什么水平?公认的终端性能天花板。我们一个刚做客户端不久的产品,跑赢了?
大家高兴了大概三分钟。然后有人发现不对劲:
- 测试经常跑不完,中途就停了;
- 跑的过程中屏幕会长时间空白;
- 最终产出的数据量明显偏少。
一个能"跑赢 Warp"的终端,为什么会跑不完测试?
二、反转:作弊的是我们自己的队列
顺着"屏幕空白"往下挖,挖出来一个藏得很深的问题。
流量控制模块里有个 maxQueueBytes=8MB 的队列上限。高压场景下,它触发了一串灾难性的连锁反应:
- 一次较长卡顿后,xterm 暂停渲染,数据停止送往渲染侧;
- 待处理数据持续堆积,超过 8MB 后,队列把最早的 chunk 扔了;
- 关键来了——这些被扔掉的数据之前已经收到 ACK,后端认为它们早就交付完成;
- 于是屏幕残缺,但测试探针提前命中结束条件,跑出一个被大幅低估的耗时。
翻译一下:终端丢了一部分内容没画,跑分却显示它"更快"。
这就像外卖系统显示"订单已完成",其实取餐区空间不够,最早的几份餐被清掉了。顾客拿到的托盘是空的,系统却已经结单。你要是只看"出餐速度",数字当然漂亮——但这成绩是假的。
ACK 语义一旦被队列丢弃破坏,上层协议的正确性就无从谈起。 这比慢更可怕:慢你看得见,假你看不见。
我们把队列上限一路调到 64MB,测试终于稳定跑完、数据完整。然后,真实基线出来了——
OrcaTerm 与 Warp、Ghostty 差距:3 到 17 倍。
刚才的"跑赢",只是一个 bug 送给我们的幻觉。
三、找瓶颈:不是画得慢,是消息碎成渣
修好尺子,开始量差距。桌面端是 Tauri 架构:Rust 后端管 PTY/SSH 连接和数据,React 前端用 xterm.js 渲染。一条终端输出链路被我们切成六段:
[0] PTY 读取 → [1] Rust 数据处理 → [2] Tauri IPC 传输
→ [3] JS 收包 → [4] 流量控制排队 → [5] xterm 渲染
前半段 [0]–[2] 跑在 Rust 线程,后半段 [3]–[5] 跑在前端 WebView 线程,两段并行、靠反压信号互相影响——这点很关键,后面会考。
三层埋点跑完,数据说话:
| 环节 | 占比/数值 | 说明 |
|---|---|---|
| 后端反压等待 | 65.0% | 前端消费不过来,后端在干等 |
| 前端入队 | 63.3% | onmessage 是第一热点 |
| xterm 渲染时延 | median 86.6ms | 从 write 到画完 |
| 每帧字节 | 约 1KB | 8KB 缓冲,但每次 read 只吐约 1KB |
| 1MB 数据帧数 | 约 1025 帧 | 即约 1025 次 IPC + 1025 次 onmessage |
| 有效吞吐 | 703 KB/s | 低得离谱 |
怕埋点骗人,又用 Safari Web Inspector Timelines 做了独立验证。三条轨道同时作证:
- Network(蓝):密集蓝条铺满,IPC 帧之间几乎没有间隙;
- JavaScript(紫):紫色竖条密不透风,全在处理
onmessage; - Layout & Rendering(绿):稀稀拉拉几根绿条,大片空白——渲染被活活挤没了;
- CPU 常年 120%+。
真相浮出水面:问题不在"画得慢",在"消息碎成渣"。 Rust 侧每次只发约 1KB 的小帧,1MB 数据要经历约 1025 次 IPC 往返。主线程忙着拆快递,xterm 渲染连时间片都抢不到;前端一慢,反压又把后端摁住。就像后厨每做完一小口菜就单独跑一趟前台,前台忙着签收零碎包裹,反而没时间把菜端上桌。
四、第一刀:合批——1025 帧打到 18 帧
方向明确:把碎消息合并了再发。参数试了很多轮,最后定两条规则:
- 攒够 64KB 立刻发——帧数暴降,又不会单帧大到压垮渲染;
- 超过 5ms 强制发——小数据不许躺在队列里。
但这里有个差点翻车的坑。 只在读取循环里检查超时是不够的:读取阻塞等数据时,小数据会被卡死。症状特别诡异——粘贴一段文本,只显示第一个字符,敲一下空格才补全。
解法是让 PTY 读取和 5ms 定时器并行竞速,谁先到谁触发发送。高吞吐时安心合批,低吞吐时小数据也绝不滞留。
战报:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 1MB IPC 帧数 | 约 1025 | 18 | 57 倍下降 |
| 平均每帧字节 | 约 1KB | 约 57KB | 57 倍提升 |
| 有效吞吐 | 703 KB/s | 9,209 KB/s | 13 倍提升 |
| 后端反压等待 | 65.0% | 52.3% | 再降 12.7pt |
分场景耗时(ms):
| 场景 | 优化前 | 优化后 |
|---|---|---|
| dense_cells(密集字符写入) | 413.9 | 34.3 |
| scrolling_fullscreen(全屏滚动) | 2711.2 | 96.8 |
| scrolling_top_region(顶区滚动) | 2187.2 | 57.8 |
| sync_medium_cells(同步语义输出) | 2522.0 | 108.6 |
一刀下去,大部分场景快了 90% 以上。
五、第二刀:调两个参数,又快一个数量级
合批打完,反压等待还有 52%——前端只要慢一下,后端立刻被摁停。为什么?
反压阈值从"保护机制"变成了"断流制造机"。 原阈值 500KB,而合批后单帧已经 57–64KB——也就是说,前端只要晚消费不到 10 帧,后端就被暂停。刚吃饱的链路,又被自己掐了脖子。
两个参数,各改一个数量级:
- 反压阈值:500KB → 10MB(约单帧的 175 倍,给前端留足消费缓冲);
- FIFO 渲染队列:8MB → 50MB(实测单轮 vtebench 总输出约 132MB/192s、峰值堆积远低于 50MB,xterm 内部也是 50MB 队列——覆盖峰值、不再丢包,继续加大边际收益趋零)。
有意思的是:xterm 渲染时延在两轮优化中始终纹丝不动(约 80ms)。第二刀砍的全是链路和反压段——这轮优化根本没碰渲染本身。找对瓶颈,比瞎优化重要得多。
再上报一次:scrolling 从 576ms 干到 25.9ms,各场景再降 74%–98%。
六、终局战报:10 项测试,6 项第一
两刀之后,与优化前比:整体提升 13 到 206 倍(scrolling_top_small_region 约 206 倍),p50 普遍进入 8–20ms 区间。
横向对比(vtebench P50,ms,越低越好)。数据来自 OrcaTerm 官方已公开发布的性能测试文章,各终端版本与测试环境以原文为准):
| 场景 | OrcaTerm | Ghostty | Warp | WezTerm | iTerm2 | Terminal.app |
|---|---|---|---|---|---|---|
| scrolling | 20.71 | 24.80 | 41.06 | 71.00 | 42.10 | 151 |
| scrolling_bottom_region | 14.98 | 40.68 | 20.97 | 64.37 | 559.39 | 132 |
| scrolling_fullscreen | 20.55 | 37.46 | 26.58 | 87.81 | 162.88 | 173 |
| scrolling_top_region | 16.06 | 27.41 | 66.89 | 87.14 | 2812.75 | 135 |
| dense_cells | 8.89 | 6.59 | 7.02 | 9.87 | 95.77 | 21 |
| unicode | 48.81 | 6.30 | 13.37 | 99.10 | 83.15 | 5 |
10 项中 6 项第一、8 项前三,scrolling 场景全部第一。 滚动恰恰是构建日志刷屏时用户体感最强的部分——从"卡成 PPT"到"屠榜"。
七、五条可以抄走的经验
- 先修尺子,再量差距。 尺子本身在说谎时,所有优化都是玄学。
- 双重独立证据。 埋点 + Timeline 互相印证,才敢下结论;两条线程流水线是并行的,层内占比不能跨层相加。
- 合批三件套:大小 + 超时 + 竞速。 缺一样都会有回归——我们缺竞速时,粘贴只出首字符。
- 反压阈值必须和单帧尺寸保持数量级比例,阈值小于约 10 帧的量时,保护就变成断流制造机。
- 没动的指标会告诉你瓶颈在哪。 渲染时延全程 80ms 不变,说明这轮打中的是链路,下一仗才是渲染本身。
八、还没打完的仗
- xterm 渲染侧仍是约 80ms,解码、OSC 解析、入队还是前端第一热点——评估 xterm.js 升级与渲染路径裁剪;
- unicode 场景 p99 仍接近 400ms,偶发飞刺待收口;
- 10MB / 50MB 是固定负载下的经验值,慢速 SSH 和本地 PTY 吞吐天差地别,下一步让参数随实测吞吐自适应浮动,而不是写死。
从"被跑分欺骗"到"揭穿自己"再到"两刀翻盘"——性能优化最有趣的从来不是最后那张战报表,而是中间那些差点让你走错方向的假象。
注:本文技术细节与测试数据整理自 OrcaTerm 团队已公开发布的研发复盘文章;"跑分失真"问题亦为官方原文中公开复盘过的历史 bug,相关细节以原文为准。
OrcaTerm 已提供 Web 端、桌面客户端和微信小程序,欢迎来 orcaterm.com 用你自己的构建任务和刷屏日志实测一把。