Ghostty Devlog 006

Mitchell Hashimoto

Ghostty 開発ログ 006

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

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

IOスループットとは、ターミナルエミュレータ内で動作するプログラム(シェルやNeovim、tmux、catなど)がバイト列をターミナルエミュレータに送り、処理されるまでの速さのことです。この指標は、大量のログをtailで追いかけるときから、うっかり巨大なファイルをターミナルに吐き出してしまったときまで(誰もが経験したことがあるはずです)、現実世界に非常に大きな影響を与えます。

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


SIMDでプレーンテキストのスループットを改善する(#1472)

プログラムは、ターミナルエミュレータと通信するために1本のストリーム(「pty」)を使います。このストリームにはプレーンテキスト制御文字のいずれかが流れます。

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

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

素朴ですが典型的な方法は、forループでバイトを1つずつ読むことです。

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() }(コンパイラに最適化で消されないように若干の注釈を加えたもの)と同じです。これは、上記2つのケースのいずれにおいても、私たちのSIMDパイプラインがテキストの読み取りと解析(UTF-8の場合)を、単にデータをメモリに読み込むのとほぼ同じ速度でこなしていることを意味します。

実際のウォールタイムでは、これらの最適化により、大きなASCIIファイルをcatする速度が2倍に、大きな日本語ファイルをcatする速度が20%向上しました。ベンチマークと比べると高速化の幅が大幅に小さいのは、別のボトルネックが存在するためです。良いお知らせがあります。私たちはそのボトルネックも見つけて最適化しました。続きをお読みください!


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

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

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

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

独自のルックアップテーブルを構築することで、ターミナル特有の事情を考慮してさらに高速化できました。たとえば、この時点では制御文字が紛れ込んでいることはあり得ないことがわかっているためその分岐を省けますし、ターミナルでは最大でも2セル幅までしか扱わないため、3セル幅の文字(3倍ダッシュ)をランタイムの追加条件なしに2セル幅にクランプできます。CPUサイクルは1つたりとも無駄にできません。

結果は一目瞭然です。今回も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+41のAの隣にあるコードポイントU+42のB区切れ、2つの書記素を形成します。一方、「🧑‍🌾」はU+1F9D1 🧑、U+200D、U+1F33E 🌾の3つのコードポイントからなりますが、書記素は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進数の数値、セミコロン、1バイトのASCII終端文字)を楽観的に想定することで動作します。

私たちはNeovimで作業中のptyストリームをキャプチャし、できる限り高速に再生して検証したところ、ワークロードにもよりますが、CSIファストパスによってスループットが1.4倍から2倍向上しました。CSIを多用するストレステストとして有名なDOOM fireでは、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」を使用して翻訳されました。