Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 開發日誌 006

原文由 Mitchell Hashimoto 發布,訂閱此部落格

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

這篇開發日誌的主題是速度 🚀。接下來我會分享過去幾週我們讓 Ghostty 變得超級、超級快的各種方法。衡量終端機模擬器速度的方式有很多,或許之後我會在另一篇文章中逐一定義,但今天就先簡單說明:本篇展示的所有效能優化,都圍繞著IO 吞吐量打轉。

IO 吞吐量指的是在終端機模擬器內執行的程式(例如 shell、neovim、tmux、cat 等)能以多快的速度將位元組推送給終端機模擬器並完成處理。這個指標在現實世界中影響非常直接——無論是追蹤不斷大量輸出的日誌,還是手誤把大檔案直接傾印到終端機上(大家都遇過吧)。

如果你錯過了之前的開發日誌,或想更了解 Ghostty 是什麼,請參考本站上的 Ghostty 介紹頁面


透過 SIMD 提升純文字吞吐量(#1472)

程式透過單一串流(也就是「pty」)與終端機模擬器溝通。這個串流可能包含純文字,也可能包含控制字元

純文字會直接印到終端機上,原封不動地傳送。舉例來說,要在終端機上印出「Hello, World!」,程式只要把 Hello World! 以 UTF-8 編碼1寫入 pty 就好。

控制字元則用來做各種事情,例如設定樣式屬性(前景顏色、底線、粗體等)、移動游標、刪除行、切換鍵盤協定等等。控制字元有很多種格式,但幾乎所有控制字元2都有一個共同點:它們都以跳脫字元(escape character,十六進位 0x1B)開頭。

一個直觀但常見的做法,是用 for 迴圈逐一讀取位元組:

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

這種方式一次只看一個位元組。但自 90 年代末以來,電腦就能利用 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 個位元組(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 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 倍。加上前面提到的優化,現實世界中純文字吞吐量的整體提升,依 UTF-8 的複雜度不同,落在 2 到 5 倍之間。


優化的字素叢集斷點偵測(#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(舊版)189ms
Ghostty(新版)73ms
Ghostty (legacy wcswidth)61ms
Alacritty 0.13.166ms (see note)
iTerm2 3.4.23470ms
Kitty(尚未發布的新 SIMD 分支5103ms
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 的測試——FPS 則提升了 1.44 倍。

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


結語

如同前言所述,這篇開發日誌聚焦在我與社群為了讓 Ghostty 變得更快所做的努力。在這次的案例中,所有工作都圍繞著讓 IO 更快。還有許多其他效能指標我也計畫持續改進並撰文分享(例如輸入延遲、啟動時間、畫面更新率、記憶體使用量等)。

我們還沒有完成對 Ghostty IO 速度的改進。我們已經發現更多瓶頸,也正在研究解決方案。希望能在未來的開發日誌中再向大家更新進展。

除了速度之外,自上次開發日誌以來,Ghostty 還有許多其他改進。我計畫在下一篇開發日誌中多談談其中一些。有人最近發現了我也在做的故障復原相關工作,之後我也希望能多寫一點相關內容。

最後,我們的封測人數已從上次開發日誌時的約 350 人,成長到這次開發日誌時的超過 650 人。我想感謝整個社群協助回報問題、貢獻程式碼,並對每個人都保持友善與互助。

如果想掌握最新消息,歡迎在 Twitter 或 Mastodon 上追蹤我(頁尾有連結)。這個部落格也有提供 RSS 訂閱

Boo. 👻

註腳

  1. 終端機有切換文字編碼的機制,歷史上預設僅支援 ASCII,但現今大多數終端機都預設為 UTF-8。

  2. 換行(0x0A)、歸位(0x0D)、定位點(0x09)等也屬於控制字元,但相較於以 escape 為前綴的控制序列,它們的數量非常有限。

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

  4. 這個 API 其實並非「有問題」,它的行為完全符合文件說明,只是其寬度結果通常不是大家預期的那樣。

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

本文章由 muse-spark-1.2-contributor 進行翻譯

留言