Sparkplug — 最適化を行わないJavaScriptコンパイラー
高性能なJavaScriptエンジンを作るには、TurboFanのような高度な最適化コンパイラーだけでは足りません。特にウェブサイトの読み込みやコマンドラインツールなどの短命のセッションでは、最適化コンパイラーが最適化を開始する前に、または最適化されたコードを生成する時間を確保する前に多くの作業が行われます。
このため、2016年以降、合成ベンチマーク(Octaneなど)の追跡をやめて実際の性能を測定する方向に移行し、それ以来、最適化コンパイラーの外側でのJavaScriptの性能改善に努めてきました。これにはパーサー、ストリーミング、オブジェクトモデル、ガベージコレクターの並列処理、コンパイルコードのキャッシュなどの作業が含まれており、私たちは決して退屈することはありませんでした。
しかし、JavaScriptの初期実行性能を向上させるために進んだとき、インタープリターの最適化に限界に直面します。V8のインタープリターは非常に最適化されていて非常に高速ですが、インタープリターの機能の一部であるバイトコードの解読オーバーヘッドやディスパッチオーバーヘッドなど、取り除くことのできない固有のオーバーヘッドがあります。
現在の二重コンパイラーモデルでは、最適化されたコードへの段階的アップがこれ以上速くできません。最適化を高速化する努力はしていますが、最適化パスを減らしてしまうとピーク性能を削減してしまうため、ある時点でさらに速度を上げるにはそれしか方法がありません。さらに悪いことに、安定したオブジェクト形状のフィードバックがまだ得られていないため、最適化を早く開始することができません。
ここで登場するのがSparkplugです。V8 v9.1でリリースされる新しい非最適化JavaScriptコンパイラーで、IgnitionインタープリターとTurboFan最適化コンパイラーの間に位置します。
高速なコンパイラー
Sparkplugは非常に高速にコンパイルすることを目的としています。本当に非常に高速です。その結果、ほぼいつでもコンパイルできるようになり、TurboFanコードよりもはるかに積極的にSparkplugコードへ段階的アップを行えるようになります。
Sparkplugコンパイラーを高速化するためのいくつかの工夫があります。まず、Sparkplugは既にバイトコードにコンパイルされた関数を扱い、バイトコードコンパイラーが変数解決、括弧が実際にアロー関数かどうかの判断、デストラクチャリング文の糖衣構文還元などの作業のほとんどを行っています。そのため、SparkplugはJavaScriptのソースからではなくバイトコードからコンパイルし、それらについて心配する必要がありません。
二つ目の工夫として、Sparkplugは通常のコンパイラーが生成する中間表現(IR)を生成しません。その代わり、Sparkplugはバイトコードを1回の直線的なパスで機械コードに直接コンパイルし、そのバイトコードの実行に一致するコードを生成します。実際にこのコンパイラーは、switch
文をfor
ループの中に入れており、固定のバイトコードごとの機械コード生成関数にディスパッチしています。
// Sparkplugコンパイラー(要約版)。
for (; !iterator.done(); iterator.Advance()) {
VisitSingleBytecode();
}
IRが欠如しているため、コンパイラーは非常に局所的なピープホール最適化を除き、限定的な最適化しか行えません。また、中間のアーキテクチャ非依存の段階がないため、サポートする各アーキテクチャごとに実装全体を個別に移植する必要があります。しかし、これらは問題とはなりません。高速なコンパイラーはシンプルなコンパイラーであり、コードは非常に移植が簡単です。また、Sparkplugは重い最適化を必要としません。なぜなら、パイプラインの後の段階で優れた最適化コンパイラーがあるからです。
::: note 技術的には、現在、バイトコードを2回通しています。1回目でループを発見し、2回目で実際のコードを生成します。ただし、最終的には最初の処理を取り除く予定です。 :::
インタープリター互換フレーム
成熟したJavaScript VMに新しいコンパイラーを追加するのは困難な作業です。標準的な実行以上のさまざまなサポートが必要になります。V8にはデバッガーがあり、スタックウォーク型のCPUプロファイラーや例外のスタックトレース、ティアアップへの統合、ホットループに対する最適化コードへのオンスタック置換などが存在します。これらは多岐にわたります。
しかし、Sparkplugはこれらの問題の多くをシンプルに解決する巧妙な仕組みを採用しています。それは、“インタープリター互換のスタックフレーム”を維持するという方法です。
少し補足しましょう。スタックフレームとはコード実行が関数状態を保存する方法です。新しい関数を呼び出すたびに、その関数のローカル変数用に新しいスタックフレームが作成されます。スタックフレームはフレームポインター(開始位置を示す)とスタックポインター(終了位置を示す)によって定義されます。
::: note
ここで、おそらく半分くらいが「この図は意味が分からない、スタックは当然逆方向に増えるはずだ!」と叫んでいることでしょう。心配しないでください。このボタンを作りました:
:::
関数が呼び出されると、戻りアドレスがスタックにプッシュされます。戻りアドレスは関数が終了する際に取り除かれ、どこに戻るべきかを知るために利用されます。その関数が新しいフレームを作成すると、以前のフレームポインターをスタックに保存し、自身のスタックフレームの始点を新しいフレームポインターに設定します。このようにして、スタックには一連のフレームポインターが生成され、それぞれが以前のフレームを指し示します。
::: note 厳密に言えば、これは生成されたコードが採用する慣習であり、必須条件ではありません。ただし、非常に一般的な慣習であり、スタックフレームが完全に省略されたり、デバッグ用のサイドテーブルでスタックフレームを走査する場合にのみ例外となることがあります。 :::
これはすべてのタイプの関数に共通するスタックの一般的なレイアウトです。さらに、引数の渡し方や関数がフレーム内に値を保存するための慣習が存在します。V8では、JavaScriptフレームの慣習として、引数(レシーバーを含む)は関数が呼び出される前に逆順でスタックにプッシュされ、そのあとスタックの最初のいくつかのスロットには以下が配置されます:現在呼び出されている関数、その呼び出しに使用されるコンテキスト、および渡された引数の数。このフレームレイアウトが「標準的」なJSスタックフレームです。
このJS呼び出し規約は最適化されたフレームとインタープリテッドフレームの間で共通しており、たとえばデバッガーの性能パネルでコードをプロファイリングする際に最小限のオーバーヘッドでスタックを走査することを可能にしています。
Ignitionインタープリターの場合、この慣習はさらに明確になります。Ignitionはレジスタベースのインタープリターであり、仮想レジスタ(機械レジスタとは異なります)を保持してインタープリターの現在の状態を保存します。これにはJavaScript関数のローカル変数(var/let/constの宣言)や一時値が含まれます。これらのレジスタはインタープリターのスタックフレームに保存され、実行中のバイトコード配列へのポインターおよびその配列内の現在のバイトコードのオフセットも同様に保存されます。
Sparkplugは意図的にインタープリターのフレームに一致するレイアウトを作成および維持します。インタープリターがレジスタ値を保存する場合、Sparkplugも同様に値を保存します。これはいくつかの理由で行われます:
- Sparkplugのコンパイルを簡素化するためです。Sparkplugはインタープリターの挙動をそのまま鏡像のように反映でき、インタープリターのレジスタからSparkplugの状態へのマッピングを保持する必要がありません。
- コンパイル速度が向上します。バイトコードコンパイラーがレジスター割り当ての困難な作業をすでに行っているためです。
- システムとの統合がほぼトリビアルになります。デバッガー、プロファイラー、例外のスタックアンワインド、スタックトレースの描画など、これらの操作はすべて実行中の関数の現在のスタックを発見するためにスタック走査を実行します。Sparkplugを使用してもこれらの操作はほぼ変更なしで機能し続けます。なぜなら、これらの操作にとっては、インタープリターフレームしか存在しないように見えるからです。
- これにより、オンスタック代替 (OSR) が簡単になります。OSR は、現在実行中の関数が実行中に置き換えられる場合のことです。現在、これは解釈実行中の関数がホットループ内にある場合(そのループの最適化されたコードに層を上げるとき)や、最適化されたコードがデオプティマイズされる場合(層を下げて解釈実行に戻るとき)に発生します。Sparkplug フレームがインタープリターフレームを反映することにより、解釈実行用に設計された任意の OSR ロジックが Sparkplug にもそのまま適用できます。さらに良いことに、インタープリターコードと Sparkplug コードをほぼゼロのフレーム変換オーバーヘッドで入れ替えることが可能になります。
インタープリターのスタックフレームに対して行った小さな変更点の1つは、Sparkplug コードの実行中にバイトコードのオフセットをリアルタイムで更新しないことです。その代わりに、Sparkplug コードのアドレス範囲と対応するバイトコードオフセットの間の双方向マッピングを保持します。これは、Sparkplug コードがバイトコードのリニアな歩行から直接生成されるため、比較的単純なエンコードマッピングです。スタックフレームアクセスが Sparkplug フレームに対して「バイトコードのオフセット」を知りたい場合、このマッピングを調べて対応するバイトコードのオフセットを返します。同様に、インタープリターから Sparkplug に OSR を行いたい場合、このマッピング内の現在のバイトコードのオフセットを調べ、対応する Sparkplug 命令にジャンプすることができます。
スタックフレームには現在使用されていないスロットが1つあることに気付くかもしれません(そこには本来、バイトコードのオフセットがあった場所です)。ただし、スタックの残りを変更せずにそのままにしておく必要があります。このスタックスロットを再利用し、現在の実行中の関数の「フィードバックベクター」をキャッシュするために使用します。このベクターにはオブジェクトの形状データが格納されており、ほとんどの操作で読み込む必要があります。OSR 処理時に正しいバイトコードオフセットまたは正しいフィードバックベクターのいずれかをこのスロットに切り替えるように注意すればよいだけです。
このようにして、Sparkplug のスタックフレームは次のようになります:
ビルトインへの委任
Sparkplug は実際のところ、独自のコードをほとんど生成しません。JavaScript のセマンティクスは複雑であり、最も簡単な操作を実行するためでさえ多くのコードを必要とします。Sparkplug がこのコードを各コンパイルごとにインラインで再生成することを強制すると、いくつかの理由で問題があります:
- 生成するコードの量が多くなるため、コンパイル時間が著しく増加する,
- Sparkplug コードのメモリ消費量が増加する,
- Sparkplug 用に多くの JavaScript 機能のコード生成を再実装する必要があり、それがさらなるバグやセキュリティのリスクを引き起こしやすい.
その代わりに、ほとんどの Sparkplug コードは「ビルトイン」と呼ばれるものに依存して実行されます。これらは、バイナリに埋め込まれた小さなマシンコードのスニペットで、実際の作業を実行します。このビルトインコードは、インタープリターが使用するものと同じか、少なくともインタープリターのバイトコードハンドラーと大部分のコードを共有しています。
実際のところ、Sparkplug コードは基本的にビルトインコールと制御フローだけで構成されています:
「要するに、ではこれには何の意味があるのか? Sparkplug はインタープリターと同じ作業をしているのでは?」と考えるかもしれません――その指摘はまったく間違っていません。多くの点で、Sparkplug はインタープリター実行の「シリアル化」にすぎません。同じビルトインを呼び出し、同じスタックフレームを維持します。それでもなお、これだけでも意味があります。なぜなら、インタープリターの削除不可能なオーバーヘッド(オペランドデコードや次のバイトコードのディスパッチなど)を削除(正確には事前コンパイル)するからです。
インタープリターは、CPU の最適化を多く妨げることが明らかです:静的オペランドがインタープリターによって動的にメモリから読み取られるため、CPU は値を予測するか停滞する必要があります。次のバイトコードにディスパッチするには、分岐予測が成功する必要があり、予測や仮説が正しい場合でも、すべてのデコードとディスパッチコードを実行する必要があります。その結果、さまざまなバッファーやキャッシュで貴重な空間が消費されます。CPU は実質的にマシンコードのインタープリター自身です。この視点から見ると、Sparkplug は Ignition バイトコードを CPU バイトコードに変換する「トランスパイラー」であり、関数を「エミュレーター」実行から「ネイティブ」実行に移行させるものです。
パフォーマンス
では、Sparkplug は現実の世界でどれくらい効果を発揮するのでしょうか? Chrome 91 を使い、いくつかのベンチマークを複数のパフォーマンスボットで Sparkplug 有効時と無効時に実行し、その影響を確認しました。
結論を先に言うと、私たちは非常に満足しています。
::: note 以下のベンチマークは、さまざまなオペレーティングシステムで動作するさまざまなボットをリストアップしています。ボット名にオペレーティングシステムが prominently 表示されていますが、実際のところ結果にはそれほど影響しないと考えています。それよりも、各マシンの CPU やメモリ構成の違いが主な要因だと信じています。 :::
Speedometer
Speedometer は実世界のウェブサイトフレームワークの使用を再現しようとするベンチマークです。人気のあるフレームワークを使用して TODO リスト管理 Web アプリを構築し、TODO の追加や削除時のパフォーマンスをストレステストします。我々はこれを、実世界の読み込みやインタラクションの行動を反映する優れた方法として捉えており、Speedometer の改善が実世界のメトリクスにも反映されることを繰り返し確認してきました。
Sparkplug を使用すると、Speedometer のスコアが 5~10% 改善されます(どのボットを見ているかによります)。
ブラウジングベンチマーク
Speedometerは素晴らしいベンチマークですが、それは物語の一部に過ぎません。さらに、私たちは「ブラウジングベンチマーク」のセットも持っています。これは、実際のウェブサイトのセットを記録・再生し、少しの操作をスクリプト化して、現実世界でのさまざまな指標がどのように動作するかについて、より現実的な視点を得る方法です。
これらのベンチマークでは、メインスレッド上でV8(コンパイルと実行を含む)に費やされる時間の合計を測定する指標「V8メインスレッド時間」を見ることにしました(ストリーミングパーシングやバックグラウンドでの最適化されたコンパイルは含まれません)。これにより、他のベンチマークノイズの要因を除外しながら、Sparkplugがどれだけ効率的に機能するかを確認する最良の方法となります。
結果はさまざまであり、非常にマシンやウェブサイトに依存しますが、全体的に見れば非常に良好です:おおむね5〜15%程度の改善が見られます。
::: figure ブラウジングベンチマーク上でのV8メインスレッド時間中央値の改善(10回の繰り返し)。誤差棒は四分位範囲を示しています。
:::
結論として:V8には新しい超高速の非最適化コンパイラが導入されており、実世界のベンチマークでV8のパフォーマンスを5〜15%向上させています。これはすでにV8 v9.1で--sparkplug
フラグの裏で利用可能であり、Chrome 91で展開される予定です。