Ghostty Devlog 006

Mitchell Hashimoto

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.166ms (见备注)
iTerm2 3.4.23470ms
Kitty (new unreleased SIMD branch5)103ms
Kitty 0.32.1392ms
Terminal.app macOS 14.3124ms
WezTerm 20240203-110809-5046fc22140ms

关于上表,我将该文件 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. 👻

脚注

  1. 终端有切换文本编码的机制,历史上默认仅支持 ASCII,但大多数现代终端默认使用 UTF-8。

  2. 换行(0x0A)、回车(0x0D)、制表符(0x09)等也属于控制字符,但相比以 esc 为前缀的控制序列,它们的数量非常有限。

  3. https://arxiv.org/pdf/2109.10433.pdf

  4. 该 API 其实并非“有缺陷”,它完全按文档所述工作,只是不是人们通常预期的宽度。

  5. 我使用的是截至 2024 年 2 月 9 日的 vt 分支。

本文章由 muse-spark-1.2-contributor 进行翻译

评论