Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 开发日志 006

大家好!欢迎来到 Ghostty 👻 的官方开发日志!

本期开发日志将聚焦于速度 🚀。我将展示我们在过去几周里让 Ghostty 变得极快、极快的多种方式。衡量终端模拟器速度的方式有很多。或许我会在另一篇博文中逐一定义这些方式,但今天我只想说明,本期开发日志中展示的所有性能工作都将围绕IO throughput(IO吞吐量)展开。

IO throughput 是指在终端模拟器中运行的程序(shell、neovim、tmux、cat 等)向终端模拟器推送字节并得到处理的速率。这一指标具有非常实际的影响,无论是持续跟踪大量日志输出,还是不小心将大文件倾倒到终端(我们都经历过)。

如果你错过了之前的开发日志,或者想进一步了解 Ghostty 是什么,请查看本网站上的 Ghostty 页面


通过 SIMD 提升纯文本吞吐量 (#1472)

程序通过单一流(“pty”)与终端模拟器通信。该流可以包含纯文本控制字符

纯文本会直接打印到终端,并按原样发送。例如,要向终端写入“Hello, World!”,程序会向 pty 写入经 UTF-8 编码的1Hello World!

控制字符用于执行各种操作,例如设置样式属性(前景色、下划线、加粗等)、移动光标、删除行、更改键盘协议等。控制字符有多种格式,但几乎所有控制字符2的共同点是它们都以转义字符(十六进制 0x1B)开头。

一种简单但典型的做法是在 for 循环中读取字节:

const bytes = read();
for (byte in bytes) {
  if (byte == 0x1B) {
    // Process control sequence
  } else {
    // Process plain text
  }
}

这种方式一次只查看一个字节。但自 20 世纪 90 年代末以来,计算机就已经能够使用 SIMD(单指令多数据)指令(“Single Instruction Multiple Data”)一次对多个字节执行操作。根据 Valve 最新的 Steam Hardware Survey,超过 99% 的活跃计算机支持在一条 CPU 指令中查看 16 到 32 个字节

我们常常依赖编译器为我们优化代码,从而不必去考虑单个 CPU 指令的细枝末节。不幸的是,编译器在autovectorization(自动向量化)方面表现 notoriously 不佳,除了相对简单的循环外,编译器很少能有效地自动向量化。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

而 Ghostty 现在读取并将 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 而言)文本的速度大致与仅仅将数据读入内存的速度相当。

在真实世界的wall time(实际耗时)中,这些优化使 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 倍,日文则进一步提升了 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 sequence(CSI 序列)快速路径解析器。

CSI 序列是 Neovim 等程序使用的最常见的控制序列之一。它们用于改变文本样式、应用颜色、移动光标、滚动屏幕等。

该快速路径使用标量(非 SIMD)指令实现,其工作原理主要是避免 VT 解析状态机,并乐观地假定 CSI 语法有效(即十进制编码的数字、分号和单个 ASCII 终止符)。

我们捕获了在 Neovim 中执行某些操作时的 pty 流,并以尽可能快的速度重放,在真实世界中验证了 CSI 快速路径将吞吐量提升了 1.4 到 2 倍(取决于测试的工作负载)。热门的DOOM fire 压力测试——一项 CSI 密集型测试——的 FPS 提升了 1.44 倍。

我要再次强调,这一贡献完全来自封闭测试中的另一位贡献者(@Qwerasd5。他们还为本文涵盖的其他优化提供了非常有帮助的反馈和方向。非常感谢!


结语

如引言所述,本期开发日志聚焦于我和社区为让 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 分支。

原文由 Mitchell Hashimoto 发布

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