Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 開発ログ 006

原文は Mitchell Hashimoto により に公開されました。 このブログを購読する

こんにちは! Ghostty 👻 の公式開発ログへようこそ!

今回の開発ログはスピード 🚀 にフォーカスします。ここ数週間で Ghostty を本当に、本当に高速にするために行ったさまざまな取り組みを紹介します。ターミナルエミュレータの速度を測る方法は数多くあります。それぞれの指標については別のブログ記事で定義するかもしれませんが、今回はこの開発ログで紹介するパフォーマンス改善はすべてIO スループットに関するものだとだけお伝えします。

IO スループットとは、ターミナルエミュレータ内で動作するプログラム(シェル、neovim、tmux、cat など)がバイト列をターミナルエミュレータに送り、処理されるまでの速度のことです。この指標は現実の世界でも大きな影響を持ちます。大量のログ出力を tail する場合から、うっかり大きなファイルをターミナルにダンプしてしまったときまで(誰もが経験したことがあるはずです)。

過去の開発ログを見逃した方や、Ghostty とは何かについてもっと知りたい方は、このサイトの Ghostty ページをご覧ください。


SIMD によるプレーンテキストのスループット改善 (#1472)

プログラムは単一のストリーム(「pty」)を使ってターミナルエミュレータと通信します。このストリームにはプレーンテキスト制御文字のいずれかが含まれます。

プレーンテキストはそのままターミナルに直接表示され、送られた通りに出力されます。例えば、ターミナルに「Hello, World!」と書き込む場合、プログラムは Hello World! を UTF-8 でエンコードされた1状態で pty に書き込みます。

制御文字は、スタイル属性(前景色、下線、太字など)の設定、カーソルの移動、行の削除、キーボードプロトコルの変更などを行うために使われます。制御文字にはさまざまな形式がありますが、ほぼすべて2に共通しているのは、エスケープ文字(16進数で 0x1B)から始まるということです。

素朴ですが典型的なアプローチは、for ループでバイトを読み取る方法です:

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

これは一度に1バイトずつ見ていく方法です。しかし90年代後半以降のコンピュータは、SIMD 命令(「Single Instruction Multiple Data」)を使って一度に複数バイトを処理できるようになっています。Valve の最新のSteam ハードウェア調査によると、稼働中のコンピュータの99%以上が、1つの 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
  }
}

上記の2つの例で、比較演算にかかる時間がまったく同じだと仮定すると、SIMD の例ではバイトの処理が16倍速くなります。現実はそれほど単純ではありませんが、SIMD によって驚異的な高速化を得ることができます。

さらにややこしいことに、SIMD 命令セットは CPU の世代によって異なります。プログラムをバイナリ形式で配布し、利用可能な最高の機能を活用したい場合、それぞれの SIMD 命令セットごとにバリエーションを書き、それぞれをコンパイルし、CPU 機能検出(x86 におけるcpuid 命令など)を使って実行時に適切なバージョンを選択しなければなりません。

例えば、上記のコードでは「16バイトのチャンク」と書かれていますが、ハードウェアによっては、これが16バイト(Apple Silicon のような aarch64 neon 命令セット)、AVX2 なら32バイト、AVX512 なら64バイトなどになります。そして異なるのはサイズだけではなく、実際の CPU 命令(バイナリコード)自体も異なります。したがって、コードを複数回書いたりコンパイルしたりする必要があるのです。

Ghostty は現在、上記の2つ目のコードサンプルとほぼ同じ処理を SIMD を使って実行しています。さらに、UTF-8 のデコード3も一度に複数バイトを SIMD で行っています。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

x86_64 マシンで AVX2 が利用可能な場合、結果はさらに劇的ですが、上記のような正確な出力を再現できる x86_64 マシンが手元になく、テストコミュニティから共有された話でしか確認できていません。

memcpy のベンチマークは、単にバイト列を事前に確保されたバッファに読み込むだけのものです。つまり基本的には for { bytes = read() } と同じです(コンパイラがこれを最適化で削除しないようにするためのアノテーション付き)。これは、上記のどちらのケースでも、私たちの SIMD パイプラインがテキストの読み取りと(UTF-8 の場合の)パースを、単にデータをメモリに読み込むのとほぼ同じ速度で行っていることを意味します。

実際の経過時間(wall time)では、これらの最適化により、大きな ASCII ファイルを cat するのが2倍、大きな日本語ファイルを cat するのが20%高速になりました。スピードアップが上記のベンチマークより大幅に小さいのは、他のボトルネックがあるためです。良いニュースは、私たちはそれらも見つけて最適化したということです。続きをお読みください!


コードポイント幅のための事前計算ルックアップテーブル (#1486)

ターミナルは等幅セルのグリッドで構成されています。A のような文字をターミナルに書き込むとき、その文字が何セル分を占めるかを判断しなければなりません。この処理は驚くほど複雑です!詳細については、Jeff Quast 氏によるこちらの素晴らしいブログ記事をご覧ください。

プロファイリングの結果、1文字を出力するのにかかる時間の30%が、コードポイント幅の計算に費やされていることがわかりました。そこで私たちはこの部分の最適化に取り組み、そして見事にやり遂げたのです。

とても親切なコミュニティメンバーの助言のおかげで、すべての Unicode コードポイント値についてコードポイント幅を事前に計算し、trie のような3段階ルックアップテーブルを使って、メモリを使いすぎず、かつ比較的キャッシュフレンドリーなまま高速にコードポイント幅を参照できるようにしました。

独自のカスタムルックアップテーブルを構築することで、ターミナル特有の特殊な状況を考慮し、さらに高速化することができました。例えば、この時点までに制御文字はフィルタリングされているため存在し得ないことがわかっていますし、ターミナルが扱えるのは最大でも全角(幅2)までなので、三倍幅の文字(3-em ダッシュ)を実行時に追加の条件分岐なしで全角にクランプできます。1 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

実際の経過時間では、この最適化により ASCII を cat する時間がさらに2.5倍、日本語ではさらに1.5倍改善しました。先に述べた最適化と合わせると、実際のプレーンテキストのスループットは、UTF-8 の複雑さに応じて全体で2倍から5倍高速になりました。


グラフェム区切り検出の最適化 (#1494)

Ghostty はグラフェムを認識するターミナルエミュレータです。出力されるすべてのコードポイントについて、Ghostty はそのコードポイントが新しいグラフェムの一部なのか、前のグラフェムの一部なのかを判断しなければなりません。この処理はグラフェム区切り(グラフェムが別のグラフェムへと区切れる点)の検出として知られています。

例えば「AB」では、コードポイント U+42 B はコードポイント U+41 A の隣では区切れが発生し、2つのグラフェムが形成されます。一方、「🧑‍🌾」は3つのコードポイント 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 (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

上記について補足すると、ファイルを10回 cat して平均を取りました。外れ値は一度もなく、すべての測定値の範囲は常に平均の2%以内に収まっていました。

上記のテストは現実世界のものですが、非常に作為的でもあります。Ghostty があらゆるシナリオであらゆる他のターミナルより高速だとか、そのような包括的な主張をしようとしているわけではありません。上記のデータを共有する意図は、この開発ログで議論した最適化が現実世界でどのような効果をもたらしたかを示すことにあります。

下の動画は、人気の macOS ターミナルの1つと Ghostty で、大きな日本語テキストファイルを読み取る速度の違いを示しています。この状況は単なる仮定ではありません。大量のログを tail しているときや、うっかり大きなファイルを開いてしまったときなどにも当てはまります。

動画では8秒あたりで一瞬引っかかるように見えますが、これはスクリーン録画ソフトによるアーティファクトのようです。実際には、まったく停止は見られませんでした。


CSI シーケンスのファストパス最適化 (#1485)

これまでに述べた最適化はすべて、プレーンテキスト出力に焦点を当てたものでした。これらは Neovim や tmux のような制御シーケンスを多用するプログラムではあまり役に立ちません。これに対処するため、あるコントリビューターCSI シーケンスのファストパスパーサーを実装しました。

CSI シーケンスは、Neovim などのプログラムで使われる最も一般的な制御シーケンスの1つです。テキストスタイルの変更、色の適用、カーソルの移動、画面のスクロールなどに使われます。

ファストパスはスカラ(非SIMD)命令で実装されており、主に VT 解析の状態機械を回避し、有効な CSI 構文(すなわち10進数でエンコードされた数値、セミコロン、単一の 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 ブランチを使用しました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント