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.1 | 66ms (see note) |
| 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 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. 👻
註腳
隨機一篇部落格