Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 開發日誌 006

哈囉!歡迎來到 Ghostty 👻 的官方開發日誌!

本篇開發日誌將聚焦於速度 🚀。我將展示過去幾週我們讓 Ghostty 變得真的、真的非常快的各種方式。衡量終端機模擬器速度的方式有很多。或許我會在另一篇部落格文章中逐一定義這些方式,但今天我只想說明,本篇開發日誌中所展示的所有效能工作,都將圍繞著 IO throughput(I/O 吞吐量) 展開。

IO throughput 是指在終端機模擬器內執行的程式(shell、neovim、tmux、cat 等)將位元組推送至終端機模擬器並完成處理的速度。這項指標在現實世界中有著非常實際的影響,從追蹤大量日誌輸出,到不小心將大型檔案傾印到終端機上(我們都遇過)皆是如此。

如果您錯過了先前的開發日誌,或想進一步了解 Ghostty 是什麼,請參閱本網站上的 Ghostty 介紹頁面


使用 SIMD 提升純文字吞吐量(#1472)

程式會使用單一串流(即「pty」)與終端機模擬器通訊。此串流可包含純文字控制字元

純文字會直接印至終端機,並以原樣傳送。舉例來說,若要在終端機上寫入「Hello, World!」,程式會將 Hello World! 以 UTF-8 編碼1寫入 pty。

控制字元用於執行各種操作,例如設定樣式屬性(前景色彩、底線、粗體等)、移動游標、刪除行、變更鍵盤協定等。控制字元有各種格式,但幾乎所有控制字元2的共同點在於皆以跳脫字元(十六進位 0x1B)開頭。

一個單純但常見的做法是在 for 迴圈中逐一讀取位元組:

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

這種方式一次只檢查一個位元組。但自 1990 年代末以來,電腦便已能使用SIMD(單指令多資料)指令(「Single Instruction Multiple Data」)一次對多個位元組執行運算。根據 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 個位元組(例如 Apple Silicon 上的 aarch64 neon 指令集)、AVX2 的 32 個位元組、AVX512 的 64 個位元組等。而且不僅大小會變化,實際的 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 而言)文字的速度,大致與單純將資料讀入記憶體的速度相當。

在現實世界的實際時間中,這些最佳化讓 cat 大型 ASCII 檔案的速度快了 2 倍,而 cat 大型日文檔案的速度快了 20%。由於其他瓶頸的關係,實際加速幅度遠低於上述基準測試。好消息是:我們也發現並最佳化了那些瓶頸,請繼續閱讀!


適用於碼點寬度的預先計算查詢表(#1486)

終端機由等寬儲存格組成的網格所構成。當您將 A 這類字元寫入終端機時,它必須判斷該字元佔用了多少個儲存格。這個過程出乎意料地複雜!詳情請參閱 Jeff Quast(傑夫·奎斯特)的這篇精彩部落格文章

透過效能剖析,我們發現每個印出字元所花費時間的 30% 都耗在計算碼點寬度上。因此我們著手對其進行最佳化,而且確實做到了。

多虧一位非常熱心的社群成員指引,我預先計算了每一個 Unicode 碼點值的碼點寬度,並使用類似 trie 的三階段查詢表來快速查詢碼點寬度,同時不會占用過多記憶體並保持相對的快取友善性。

透過建立我們自己的自訂查詢表,我們得以針對終端機獨有的特殊情況進行處理,並使其更快。例如,我們知道在此之前的階段已過濾掉控制字元,因此不可能出現控制字元;我們也知道終端機最多只能處理雙寬字元,因此可以將三寬字元(3-em dash)限制為雙寬,而無需在執行時增加額外的條件判斷。每一個 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 是一款 grapheme(字素)感知終端機模擬器。對於每個印出的碼點,Ghostty 都必須判斷該碼點是屬於新的 grapheme 還是前一個 grapheme。此過程稱為偵測grapheme break(字素邊界)(即 grapheme 斷開並形成另一個 grapheme 的位置)。

例如「AB」中,碼點 U+42 的 B 在與碼點 U+41 的 A 相鄰時會斷開,形成兩個 grapheme。然而,「🧑‍🌾」有三個碼點:U+1F9D1 🧑、U+200D 和 U+1F33E 🌾。該 grapheme 直到 U+1F33E 🌾 之後才會斷開

Grapheme break detection 通常是一個不簡單的演算法。然而,我發現若加入終端機可以保證的一些額外先決條件(例如零控制字元或 ASCII),就能消除足夠多的條件判斷,使得計算所有可能的碼點配對加上狀態的查詢表僅需不到 1KB 的記憶體。

因此我們這麼做了,並讓我們的 grapheme break detection 速度提升了將近 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'

這項成果結合先前提及的最佳化,帶來了令人印象深刻的現實世界成效。

請注意,下表中僅有粗體的終端機有進行 grapheme clustering(字素叢集)。非粗體的終端機完全不執行 grapheme break detection,因此在幾乎所有情況下,Ghostty 在執行 grapheme break detection 的同時讀取純文字的速度,仍快於那些完全不做任何處理就讀取純文字的終端機。Ghostty (legacy wcswidth) 項目為未啟用 grapheme clustering 時 Ghostty 的速度。

終端機time cat japanese-bible.txt 耗時(5.4MB)
Ghostty (old)189ms
Ghostty (new)73ms
Ghostty (legacy wcswidth)61ms
Alacritty 0.13.166ms (see note)
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 sequence 是 Neovim 等程式最常使用的控制序列之一。它們用於變更文字樣式、套用色彩、跳動游標、捲動畫面等。

此快速路徑以純量(非 SIMD)指令實作,其運作方式主要是避開 VT 解析狀態機,並樂觀地預期有效的 CSI 語法(即十進位編碼的數字、分號以及單一 ASCII 終止字元)。

我們擷取了在 Neovim 中執行一些操作時的 pty 串流,並以盡可能快的速度重播,實際驗證了 CSI 快速路徑將吞吐量提升了 1.4 至 2 倍(視測試的工作負載而定)。熱門的 DOOM fire 壓力測試——一項大量使用 CSI 的測試——其 FPS 提升了 1.44 倍。

我要再次強調,這項貢獻完全來自封閉測試中的另一位貢獻者(@Qwerasd)。他們對於本文涵蓋的其他最佳化也提供了非常有幫助的回饋與方向。非常感謝!


結語

如前言所述,本篇開發日誌聚焦於我與社群為了讓 Ghostty 變得快速所做的工作。在此案例中,所有工作都特別圍繞著讓 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 進行翻譯