Ghostty 开发日志 006
原文由 Mitchell Hashimoto 于 发布,订阅该博客
大家好!欢迎阅读 Ghostty 👻 的官方开发日志!
本期开发日志将聚焦于速度 🚀。我会展示近几周里我们让 Ghostty 变得非常、非常快的各种手段。衡量终端模拟器速度的方式有很多,或许我会在另一篇博文中逐一定义,但今天只想说明,本期展示的所有性能工作都将围绕IO 吞吐量展开。
所谓 IO 吞吐量,指的是运行在终端模拟器内的程序(shell、neovim、tmux、cat 等)向终端模拟器推送字节并被处理的速率。这一指标有着非常实际的影响,无论是持续跟踪大量日志输出,还是不小心把大文件直接倒进了终端(我们都经历过),都能体现出来。
如果你错过了之前的开发日志,或想进一步了解 Ghostty 是什么,请查看本站上的 Ghostty 页面。
利用 SIMD 提升纯文本吞吐量(#1472)
程序通过单一数据流(即“pty”)与终端模拟器通信。这个数据流中可以包含纯文本,也可以包含控制字符。
纯文本会直接打印到终端,按原样发送。例如,要在终端上输出 “Hello, World!”,程序只需向 pty 写入经 UTF-8 编码的Hello World!1。
控制字符则用于执行各种操作,例如设置样式属性(前景色、下划线、加粗等)、移动光标、删除行、切换键盘协议等。控制字符有多种格式,但几乎所有这类序列2都有一个共同点:以转义字符(十六进制 0x1B)开头。
一种简单却常见的做法,是用 for 循环逐字节读取:
const bytes = read();
for (byte in bytes) {
if (byte == 0x1B) {
// Process control sequence
} else {
// Process plain text
}
}这种方式一次只检查一个字节。但自上世纪 90 年代末以来,计算机就已经能够利用SIMD 指令(“单指令多数据”)一次性处理多个字节。根据 Valve 最新 Steam 硬件调查,超过 99% 的活跃设备都支持在一条 CPU 指令内同时查看 16 到 32 个字节。
我们常常依赖编译器来帮我们优化代码,从而不必操心每一条 CPU 指令的细节。遗憾的是,编译器在自动向量化方面表现一向不佳,除了极其简单的循环外,很少能有效实现自动向量化。SIMD 正是一个罕见的例外:手写汇编(或接近汇编的代码)往往能比最先进的优化编译器带来显著更好的性能。
用 SIMD 来查找上述控制序列,思路大致如下:
const bytes = read();
for (chunk of 16 bytes in bytes) {
if (chunk contains 0x1B) {
// Control sequence in here
} else {
// Chunk is entirely plain text
}
}假设上面两个示例中的比较操作耗时完全相同,那么 SIMD 版本处理字节的速度就会快上 16 倍。现实当然没这么简单,但通过 SIMD 确实能获得惊人的加速效果。
更复杂的是,SIMD 指令集会随 CPU 代际而变化。如果要以二进制形式分发程序,又想充分利用可用的最佳特性,就必须为每种 SIMD 指令集编写不同版本,分别编译,并在运行时通过 CPU 特性检测(例如在 x86 上使用cpuid 指令)来选择正确的版本。
例如,上面的代码写的是“16 字节一块”。但具体是多少,取决于硬件:可能是 16 字节(aarch64 neon 指令集,如 Apple Silicon)、32 字节(AVX2)、64 字节(AVX512)等。而且变化的不只是块大小,实际的 CPU 指令(二进制代码)也各不相同。因此,你必须编写或编译多份代码。
Ghostty 现在正是用 SIMD 大致实现了上面第二段代码的逻辑。此外,我们也用 SIMD 来一次性解码多个字节的 UTF-83。在 Apple Silicon(M3 Max)上,Ghostty 处理 ASCII 输入的速度提升了 7.3 倍:
Benchmark 1: memcpy
Time (mean ± σ): 52.7 ms ± 0.7 ms [User: 41.6 ms, System: 47.6 ms]
Range (min … max): 50.7 ms … 54.6 ms 53 runs
Benchmark 2: scalar
Time (mean ± σ): 382.3 ms ± 4.0 ms [User: 408.6 ms, System: 39.1 ms]
Range (min … max): 376.1 ms … 388.7 ms 10 runs
Benchmark 3: simd (aarch64 neon)
Time (mean ± σ): 52.3 ms ± 0.6 ms [User: 53.8 ms, System: 47.2 ms]
Range (min … max): 51.3 ms … 54.2 ms 54 runs
Summary
simd ran
1.01 ± 0.02 times faster than memcpy
7.32 ± 0.11 times faster than scalar而将 UTF-8 读取并解码为 UTF-32的速度则提升了 16.6 倍:
Benchmark 1: memcpy
Time (mean ± σ): 22.3 ms ± 0.8 ms [User: 11.5 ms, System: 31.4 ms]
Range (min … max): 20.2 ms … 25.5 ms 115 runs
Benchmark 2: scalar
Time (mean ± σ): 344.1 ms ± 1.2 ms [User: 344.4 ms, System: 38.3 ms]
Range (min … max): 341.9 ms … 347.0 ms 10 runs
Benchmark 3: simd (aarch64 neon)
Time (mean ± σ): 20.7 ms ± 0.8 ms [User: 18.9 ms, System: 28.0 ms]
Range (min … max): 19.1 ms … 24.4 ms 127 runs
Summary
simd ran
1.07 ± 0.06 times faster than memcpy
16.60 ± 0.61 times faster than scalar在支持 AVX2 的 x86_64 机器上,结果还要更惊人,不过我手头暂时没有 x86_64 机器来复现上面那样的精确输出,只有测试社区分享的一些传闻数据。
memcpy 这项基准测试仅仅是将字节读入预分配的缓冲区,也就是基本等同于 for { bytes = read() }(加了一些注解以防止编译器将其优化掉)。这意味着在上面两种情况下,我们的 SIMD 流水线读取并解析(对于 UTF-8 而言)文本的速度,已经几乎与单纯把数据读入内存一样快。
换算到真实的墙钟时间,这些优化让 cat 大型 ASCII 文件的速度快了 2 倍,cat 大型日文文件的速度快了 20%。由于存在其他瓶颈,实际提速比上面的基准测试要低得多。好消息是:我们也找到并优化了那些瓶颈,请继续往下看!
预计算查找表优化码点宽度(#1486)
终端由等宽单元格组成的网格构成。当你向终端写入像 A 或 橋 这样的字符时,它必须判断该字符占用多少个单元格。这个过程出乎意料地复杂!详情可参考 Jeff Quast 的这篇优秀博文。
通过性能分析,我们发现每个打印字符有 30% 的耗时都花在了计算码点宽度上。于是我们着手优化它——而且确实做到了。
在一位热心社区成员的指点下,我预先计算了每一个 Unicode 码点对应的宽度,并使用了一个类 Trie 的三级查找表来快速查询码点宽度,既不会占用太多内存,又相对友好地利用了缓存。
通过构建自定义的查找表,我们还能针对终端独有的特殊情况做优化,让速度更快。例如,我们知道控制字符在此之前已被过滤掉,不可能出现;也知道终端最多只处理双宽字符,因此可以把三宽字符(3-em 破折号)直接截为双宽,而无需在运行时再加一次条件判断。每一个 CPU 周期都很重要。
结果不言自明。同样在 Apple Silicon M3 Max 上,ASCII 吞吐量提升了 2.8 倍:
Benchmark 1: noop
Time (mean ± σ): 113.2 ms ± 1.3 ms [User: 88.2 ms, System: 23.5 ms]
Range (min … max): 111.8 ms … 116.5 ms 25 runs
Benchmark 2: wcwidth
Time (mean ± σ): 358.2 ms ± 2.5 ms [User: 331.1 ms, System: 24.7 ms]
Range (min … max): 355.2 ms … 362.3 ms 10 runs
Benchmark 3: ziglyph
Time (mean ± σ): 549.1 ms ± 2.2 ms [User: 505.1 ms, System: 31.8 ms]
Range (min … max): 543.9 ms … 564.2 ms 10 runs
Benchmark 4: table
Time (mean ± σ): 194.0 ms ± 1.4 ms [User: 168.9 ms, System: 23.8 ms]
Range (min … max): 192.6 ms … 197.5 ms 15 runs而均匀分布的随机 UTF-8 吞吐量则提升了约 5 倍:
Benchmark 1: noop
Time (mean ± σ): 127.9 ms ± 1.5 ms [User: 115.8 ms, System: 10.3 ms]
Range (min … max): 126.5 ms … 131.4 ms 23 runs
Benchmark 2: wcwidth
Time (mean ± σ): 136.5 ms ± 1.9 ms [User: 124.3 ms, System: 10.4 ms]
Range (min … max): 134.9 ms … 143.0 ms 21 runs
Benchmark 3: ziglyph
Time (mean ± σ): 620.5 ms ± 1.3 ms [User: 610.5 ms, System: 9.8 ms]
Range (min … max): 619.0 ms … 602.6 ms 10 runs
Benchmark 4: table
Time (mean ± σ): 122.4 ms ± 2.4 ms [User: 110.5 ms, System: 10.4 ms]
Range (min … max): 120.6 ms … 132.4 ms 23 runs在上述基准测试中,ziglyph 是我们之前用于计算码点宽度的旧 API。wcwidth 则是 libc 中计算码点宽度的 API,但它会给出错误的结果(可参阅我关于该主题的完整文章)。无论是 ASCII 还是随机 UTF-8 场景,我们现在都能以比这个简单却有缺陷的 libc API 更快的速度,得到正确的结果。4
在真实的墙钟时间里,这项优化又让 cat ASCII 的耗时进一步缩短了 2.5 倍,cat 日文缩短了 1.5 倍。结合前面讨论的优化,现实中纯文本吞吐量的整体提升在 2 到 5 倍之间,具体取决于 UTF-8 的复杂程度。
优化字素簇断点检测(#1494)
Ghostty 是一个能感知字素簇的终端模拟器。对于每一个打印的码点,Ghostty 都必须判断它是属于一个新的字素簇,还是延续前一个字素簇。这一过程被称为检测字素簇断点(即一个字素簇断开并形成另一个字素簇的位置)。
例如 “AB” 中,码点 U+42 的 B 与码点 U+41 的 A 相邻时会产生断点,形成两个字素簇。而 “🧑🌾” 则由三个码点组成:U+1F9D1 🧑、U+200D 和 U+1F33E 🌾,直到 U+1F33E 🌾 之后才会发生断点。
字素簇断点检测通常是一个不简单的算法。不过我意识到,如果加上终端能够保证的一些额外前提条件(例如不存在控制字符、或为 ASCII),就可以省去足够多的条件分支,使得为所有可能的码点对加上状态计算出的查找表,占用不到 1KB 内存。
我们正是这么做的,将字素簇断点检测的速度提升了近 8 倍,同时仅比什么都不做(仅读取字节)慢 27%:
Benchmark 1: noop
Time (mean ± σ): 51.2 ms ± 1.2 ms [User: 43.6 ms, System: 6.7 ms]
Range (min … max): 49.7 ms … 57.7 ms 55 runs
Benchmark 2: ziglyph
Time (mean ± σ): 519.7 ms ± 0.8 ms [User: 508.2 ms, System: 8.9 ms]
Range (min … max): 518.4 ms … 520.7 ms 10 runs
Benchmark 3: table
Time (mean ± σ): 65.3 ms ± 1.2 ms [User: 57.2 ms, System: 6.9 ms]
Range (min … max): 63.4 ms … 71.1 ms 44 runs
Summary
'noop' ran
1.27 ± 0.04 times faster than 'table'
10.14 ± 0.25 times faster than 'ziglyph'这项优化与前面提到的那些相结合,在真实场景中带来了相当亮眼的成果。
请注意,下表中只有加粗的终端会进行字素簇聚类。未加粗的终端完全不做字素簇断点检测,因此在几乎所有情况下,Ghostty 在执行字素簇断点检测的同时读取纯文本的速度,仍然快于那些完全不做任何处理就读取纯文本的终端。Ghostty (legacy wcswidth) 这一项是 Ghostty 在未启用字素簇聚类时的速度。
| 终端 | time cat japanese-bible.txt (5.4MB) |
|---|---|
| Ghostty (old) | 189ms |
| Ghostty (new) | 73ms |
| Ghostty (legacy wcswidth) | 61ms |
| Alacritty 0.13.1 | 66ms (见备注) |
| iTerm2 3.4.23 | 470ms |
| Kitty (new unreleased SIMD branch5) | 103ms |
| Kitty 0.32.1 | 392ms |
| Terminal.app macOS 14.3 | 124ms |
| WezTerm 20240203-110809-5046fc22 | 140ms |
关于上表,我将该文件 cat 了 10 次并取平均值。从未出现离群值;每次测量的波动范围始终在平均值的 2% 以内。
上面的测试虽然是真实场景,但也非常刻意。我并非想笼统地宣称 Ghostty 在所有场景下都比其他所有终端更快之类。分享以上数据的目的,只是想说明本期开发日志中讨论的这些优化所带来的实际效果。
下面的视频展示了在读取大型日文文本文件时,最受欢迎的 macOS 终端之一与 Ghostty 之间的速度差异。这种情况并非纯属假设:当你在跟踪大量日志输出、或不小心打开大文件时,都会遇到。
请注意,视频在大约 8 秒处似乎出现了一次短暂卡顿,这似乎是录屏软件造成的伪影。实际使用中,我完全没有看到任何停顿。
CSI 序列快路径优化(#1485)
前面提到的所有优化都聚焦于纯文本输出。对于 Neovim 或 tmux 这类重度依赖控制序列的程序,它们帮助不大。为此,一位贡献者实现了一个CSI 序列快路径解析器。
CSI 序列是 Neovim 等程序最常用的控制序列之一,用于改变文本样式、应用颜色、移动光标、滚动屏幕等。
该快路径以标量(非 SIMD)指令实现,主要通过绕过 VT 解析状态机,并乐观地假设 CSI 语法是合法的(即十进制数字、分号和单个 ASCII 终止符)来工作。
我们捕获了在 Neovim 中执行某些操作时的 pty 数据流,并以尽可能快的速度回放,在真实场景中验证了 CSI 快路径将吞吐量提升了 1.4 到 2 倍(取决于测试负载)。广受欢迎的DOOM fire 压力测试——一项 CSI 密集型测试——的帧率也提升了 1.44 倍。
我要再次强调,这一贡献完全来自封闭测试中的另一位贡献者(@Qwerasd)。他们还为本文涵盖的其他优化提供了非常有帮助的反馈和方向。非常感谢!
结语
如引言所述,本期开发日志聚焦于我与社区为让 Ghostty 变快所做的工作。在这一期中,所有工作都 specifically 围绕让 IO 变快展开。还有许多其他性能指标我也计划去改进并撰文分享(输入延迟、启动时间、帧率、内存占用等)。
我们对 Ghostty IO 速度的优化尚未结束。我们已经发现了更多瓶颈,正在研究解决方案。希望能在未来的开发日志中向大家更新进展。
除了速度,自上一篇开发日志以来,Ghostty 还有许多其他改进。我计划在下一篇开发日志中重点介绍其中的一些。最近有人发现了我也在做的故障恢复相关工作,希望之后能对此多写一些。
最后,我们的测试规模已从上一篇开发日志时的约 350 人增长到本篇时的 650 多人。我想感谢整个社区帮助报告问题、贡献代码,并对每个人都友善互助。
如果你想保持关注,可以在 Twitter 或 Mastodon 上关注我(链接在页脚)。本博客也提供RSS 订阅。
Boo. 👻
脚注
随机一篇博客
评论
登录后参与讨论