メインコンテンツまでスキップ

Maglev - V8の最速最適化JIT

· 約19分
[Toon Verwaest](https://twitter.com/tverwaes), [Leszek Swirski](https://twitter.com/leszekswirski), [Victor Gomes](https://twitter.com/VictorBFG), Olivier Flückiger, Darius Mercadier, and Camillo Bruni — スープを台無しにしないには十分な人数のシェフがいること

Chrome M117では、新しい最適化コンパイラMaglevを導入しました。Maglevは既存のSparkplugとTurboFanコンパイラの間に位置し、高速な最適化コンパイラとしての役割を果たし、十分に良いコードを十分に速く生成します。

2021年まで、V8には2つの主要な実行階層がありました。Ignition(インタープリタ)と、ピーク性能を重視したV8の最適化コンパイラTurboFanです。すべてのJavaScriptコードは最初にIgnitionのバイトコードにコンパイルされ、それをインタープリトして実行されます。実行中、V8はプログラムの挙動を追跡し、オブジェクトの形状や型を記録します。ランタイムの実行メタデータとバイトコードの両方が最適化コンパイラに供給され、高性能でしばしば推測的な機械コードが生成され、インタープリタよりもはるかに速く実行されます。

これらの改善は、スタートアップ、遅延、およびピーク性能を測定する伝統的な純JavaScriptベンチマークのコレクションであるJetStreamのようなベンチマークで明確に見られます。TurboFanはV8がこのスイートを4.35倍速く実行する助けになります!JetStreamでは、以前のベンチマーク(廃止されたOctaneベンチマークのような)と比較して定常状態の性能を重視する度合いが減少しましたが、シンプルな項目が多いため、最適化されたコードが依然として時間の大部分を占めています。

SpeedometerはJetStreamとは異なる種類のベンチマークスイートです。これは、シミュレーションされたユーザーインタラクションのタイミングを通じてウェブアプリの応答性を測定するために設計されています。より小さな静的な単独のJavaScriptアプリではなく、スイートには人気のフレームワークを使用して構築されたウェブページ全体が含まれます。ほとんどのウェブページの読み込み時と同様に、Speedometerの項目は厳密なJavaScriptループを実行する時間がはるかに少なく、ブラウザの他の部分とやり取りするコードの大量の実行に多くの時間を費やします。

TurboFanはSpeedometerでも依然として大きな影響を与えます:1.5倍以上速く実行します!しかし、その影響はJetStreamよりも明らかに減少しています。この違いの一部は、完全なページが純粋なJavaScriptの実行時間が少なくなることに起因します。しかし一部は、ベンチマークがTurboFanによって最適化されるほど熱くならない関数に多くの時間を費やしていることによります。

最適化された実行と未最適化の実行を比較するウェブ性能ベンチマーク

::: note この投稿のすべてのベンチマークスコアは、13インチM2 Macbook Air上でChrome 117.0.5897.3を使用して測定されました。 :::

IgnitionとTurboFanの間の実行速度とコンパイル時間の違いが非常に大きいため、2021年に新しいベースラインJITとしてSparkplugを導入しました。これは、バイトコードをほぼ瞬時に等価な機械コードにコンパイルするように設計されています。

JetStreamでは、SparkplugはIgnitionと比較してパフォーマンスをかなり改善します(+45%)。TurboFanも加わった場合でも、パフォーマンスの大幅な改善(+8%)が見られます。SpeedometerではIgnitionに対して41%の改善が見られ、TurboFanの性能に近づき、Ignition + TurboFanと比較しても22%の改善をもたらします。Sparkplugは非常に高速なので、非常に広範囲に展開しやすく、一貫したスピードアップを得ることができます。もしコードが容易に最適化されるだけでなく、長期間実行される厳密なJavaScriptループに依存しない場合、これは素晴らしい追加になります。

Sparkplugを追加したウェブ性能ベンチマーク

しかし、Sparkplugの単純さは、その提供できる速度改善の上限を比較的低くします。これはIgnition + SparkplugとIgnition + TurboFanの間の大きな差によって明らかに示されています。

ここで登場するのがMaglevです。これはSparkplugのコードよりもはるかに高速なコードを生成し、TurboFanよりもはるかに速く生成する新しい最適化JITです。

Maglev: 単純なSSAベースのJITコンパイラ

このプロジェクトを始めたとき、SparkplugとTurboFanの間のギャップを埋めるために2つの進むべき道が見えました。一つはSparkplugが採用しているシングルパスアプローチを使ってより良いコードを生成することを目指す方法、もう一つは中間表現(IR)を持つJITを構築する方法です。コンパイル中に全くIRがない場合、コンパイラが大きく制限される可能性が高いと考えたため、従来の静的単一代入(SSA)に基づくアプローチを採用し、TurboFanのキャッシュに優しくない柔軟な「ノードの海」の表現の代わりに、CFG(制御フローグラフ)を使用することに決めました。

コンパイラそのものは、高速で作業しやすいように設計されています。最小限のパスセットと、専門化されたJavaScriptセマンティクスをエンコードした単一のシンプルなIRを含んでいます。

前処理

まずMaglevはバイトコードを事前に処理し、ループを含む分岐ターゲットやループ内での変数への代入を特定します。この処理ではどの変数のどの値がどの式をまたいで必要になるかというライブネス情報も収集します。この情報により、後でコンパイラが追跡する必要がある状態の量を削減できます。

SSA

コマンドライン上に出力されたMaglev SSAグラフ

Maglevはフレーム状態の抽象的な解釈を行い、式評価の結果を表すSSAノードを作成します。変数への代入は、それらのSSAノードを対応する抽象インタプリタレジスタに格納することでエミュレートされます。分岐やスイッチの場合、すべてのパスが評価されます。

複数のパスが統合される場合、抽象インタプリタレジスタ内の値は、いわゆるPhiノードを挿入することで統合されます。Phiノードは、実行時にどのパスを取ったかに応じてどの値を選ぶかを知っています。

ループでは、変数がループ本体内で代入される場合、ループ終了部分からループヘッダーに向かって「時間をさかのぼる」形でデータがフローすることができます。このとき、事前処理のデータは役立ちます。すでにどの変数がループ内で代入されるかを知っているため、ループ本体を処理し始める前にループPhiを事前に作成できます。ループの最後でPhi入力に適切なSSAノードを設定できます。これにより、ループ変数を「修正」する必要がなく、Phiノードの割り当てを最小限に抑えながら、SSAグラフ生成を一回の前進パスで実行できます。

既知のノード情報

可能な限り高速であるために、Maglevは同時にできるだけ多くの作業を行います。一般的なJavaScriptグラフを構築してからその後の最適化フェーズでそれを削減するという、理論的には整然としているものの計算コストが高い方法を取る代わりに、Maglevはグラフ構築中に可能な限り多くを実行します。

グラフ構築中、Maglevは非最適化実行中に収集されたランタイムフィードバックメタデータを調べ、観察された型に基づいて専門化されたSSAノードを生成します。たとえば、o.xがあり、ランタイムフィードバックからoが常に特定の形状であることがわかっている場合、Maglevはランタイムでoが予想通りの形状を持つかを確認するSSAノードを生成し、その後、オフセットによる単純なアクセスを行う安価なLoadFieldノードを生成します。

さらに、Maglevはoの形状が判明したことを示すサイドノードを作成し、後続の形状チェックが不要になる場合もあります。もしMaglevが後で何らかの理由でフィードバックがないoの操作に遭遇した場合、コンパイル中に学習したこの種の情報は、フィードバックの第二のソースとして使用できます。

ランタイム情報はさまざまな形式で提供されることがあります。一部の情報はランタイムで確認する必要がありますが(前述の形状チェックなど)、一部の情報はランタイムチェックなしで使用することが可能です。この場合はランタイムへの依存関係を登録します。実質的に定数であるグローバル(初期化後からMaglevがその値を確認するまで変更されていないもの)もこのカテゴリに分類されます。Maglevはこれらを動的にロードして確認するコードを生成する必要がなく、コンパイル時に値をロードして機械コードに直接埋め込むことができます。ランタイムがそれらのグローバルを変更する場合、それがその機械コードを無効化および非最適化することも保証されます。

情報の中には「不安定」なものもあります。このような情報は、コンパイラがそれが変更される可能性がないことを確信している限りにおいてのみ利用することができます。たとえば、オブジェクトを新しく割り当てたばかりであれば、それは新しいオブジェクトであり、高価な書き込みバリアを完全にスキップできます。他の潜在的な割り当てが発生すると、ガベージコレクタがそのオブジェクトを移動させる可能性があるため、そのチェックを発行する必要があります。一方「安定」している情報もあります。もし特定の形状を持つオブジェクトが他の形状に遷移したことが一度もない場合、このイベント(特定の形状からの遷移)に依存関係を登録し、未知の副作用を持つ関数への呼び出し後であってもオブジェクトの形状を再確認する必要はありません。

非最適化

Maglevは、実行時に確認する投機的な情報を利用できるため、Maglevコードはデオプティマイズできる必要があります。この機能を実現するために、Maglevはデオプティマイズできるノードに抽象的なインタープリタフレーム状態を付加します。この状態はインタープリタのレジスターをSSA値にマッピングします。この状態はコード生成中にメタデータに変換され、最適化された状態から非最適化状態へのマッピングを提供します。デオプティマイザーはこのデータを解釈し、インタープリタフレームやマシンレジスターから値を読み取って、それを解釈のための必要な場所に配置します。TurboFanで使用されているのと同じデオプティマイゼーションメカニズムを基に構築されているため、ほとんどのロジックを共有し、既存のシステムのテストを活用することができます。

表現選択

JavaScriptの数値は、仕様によれば64ビット浮動小数点値を表します。ただし、エンジンが常にそれを64ビット浮動小数点として保存する必要はありません。実際には多くの数値は小さな整数(例えば配列インデックス)であるためです。V8は数値を31ビットのタグ付き整数(内部的には「小整数(Small Integers)」または「Smi」と呼ばれる)としてエンコードし、メモリを節約し(ポインタ圧縮により32ビット)、性能を向上させることを試みています(整数演算は浮動小数点演算よりも高速です)。

数値を多く使用するJavaScriptコードを高速化するには、値ノードに最適な表現が選択されることが重要です。インタープリタやSparkplugと異なり、最適化コンパイラは値の型を把握した後に値をアンボックス(JavaScript値ではなく生の数値として操作)し、厳密に必要な場合のみ再度ボックス化します。浮動小数点値は、ヒープオブジェクトを割り当てる代わりに直接浮動小数点レジスターで渡されることができます。

Maglevは主に例えば二項演算のランタイムフィードバックを見て、SSAノードの表現について学習し、Known Node Infoメカニズムを通じてその情報を前方に伝播します。特定の表現を持つSSA値がPhiノードに流れ込む場合、すべての入力をサポートする正しい表現を選択する必要があります。ループPhiノードは再び難しい問題となります。ループ内からの入力はPhiの表現を選択する前に閲覧されるためです—これはグラフ構築における「タイムトラベル」の問題と同じ理由です。このため、Maglevはグラフ構築後、ループPhiノードの表現選択を実行する別のフェーズを持っています。

レジスター割り当て

グラフ構築と表現選択が完了すると、Maglevは生成したいコードの種類をほぼ把握し、クラシカルな最適化の観点から「終了」します。ただし、実際にコードを生成できるようにするには、機械コードを実行する際にSSA値がどこに存在するか、つまりマシンレジスターに格納する場合とスタックに保存する場合を選択する必要があります。これがレジスター割り当てを通じて行われます。

各Maglevノードには入力および出力要件があり、一時的に必要な要件も含まれます。レジスター割り当て器はグラフ上を単一前方で歩きながら、抽象的な機械レジスターの状態を維持し、ノードの要件を満たし、それらの要件を実際の位置に置き換えます。これらの位置はコード生成時に使用できます。

まずプレパスがグラフを実行してノードの線形ライブ範囲を見つけます。これにより、SSAノードが不要になった場合にレジスターを解放できるようになります。このプレパスでは使用チェーンを追跡することも行います。値が将来でどれくらい必要とされるかを知ることで、どの値を優先し、どれを破棄するか(レジスターが足りなくなった場合)を決定するのに役立ちます。

プレパスの後にレジスター割り当てが実行されます。レジスターの割り当てはいくつかの簡単で局所的なルールに従います。値がすでにレジスターにある場合、そのレジスターを可能であれば使用します。ノードはグラフウォーク中にどのレジスターに格納されているかを追跡します。ノードがまだレジスターを持っていない場合で、レジスターが空いている場合は選択されます。ノードはそのレジスターに入っていると更新され、抽象的なレジスター状態はノードを含むことが更新されます。空いているレジスターがない場合でレジスターが必要な場合、別の値をレジスターから押し出します。理想的には、既に別のレジスターにあるノードを選び、これを「無料で」ドロップします。それ以外の場合、長期間必要とされない値を選択し、スタックにスピルします。

分岐のマージ時には、入力分岐からの抽象的なレジスター状態がマージされます。可能な限り多くの値をレジスター内に保持するよう努めます。これにより、レジスター間の移動が必要になる場合があり、スタックから値をアンスピルし、「ギャップ移動」と呼ばれる移動を使用する必要がある場合があります。分岐のマージにPhiノードがある場合、レジスター割り当てはPhiノードに出力レジスターを割り当てます。MaglevはPhiノードの入力と同じレジスターに出力することを好み、移動を最小化します。

SSA の値がレジスタよりも多く生存している場合、一部の値をスタックにスピルし、後でアンスピルする必要があります。Maglev の精神に則り、これをシンプルに保ちます:値がスピルする必要がある場合、定義時(値が生成された直後)に遡って即座にスピルするよう指示され、コード生成がスピルコードの出力を処理します。定義は値のすべての使用箇所を「支配」することが保証されています(使用箇所に到達するためには定義を通過しなければならなず、従ってスピルコードも通過します)。これにより、スピルされた値はコード全体の期間で正確に 1 つのスピルスロットを持つことになり、生存期間が重なる値は重ならないように異なるスピルスロットが割り当てられます。

表現選択の結果として、Maglev のフレーム内の一部の値がタグ付きポインタ(V8 の GC が理解し考慮する必要があるポインタ)であり、一部が非タグ付き(GC が考慮する必要のない値)になります。TurboFan は、どのスタックスロットがタグ付き値を持ち、どれが非タグ付き値を持っているかを正確に追跡し、スロットが異なる値に再利用されるにつれてこれを調整することによってこれに対処しています。しかし、Maglev ではこの追跡に必要なメモリを削減するために、よりシンプルにすることを選びました:タグ付き領域と非タグ付き領域にスタックフレームを分割し、この分割点だけを記録します。

コード生成

生成したい式とその出力や入力をどこに置くかを知った後、Maglev はコード生成の準備が整います。

Maglev ノードは「マクロアセンブラ」を使用してアセンブリコードを直接生成する方法を知っています。例えば、CheckMap ノードは、入力オブジェクトの形状(内部的には「マップ」と呼ばれる)を既知の値と比較し、オブジェクトが形状に合わない場合にデオプティマイズするアセンブラ命令を出力する方法を知っています。

ややトリッキーなコードの一部はギャップ移動を処理することです:レジスタ割り当て器によって作成された移動要求は、値がどこかに存在し、それを別の場所に移動させる必要があることを認識しています。ただし、そのような移動が連続している場合、先行する移動が後続の移動が必要とする入力を破壊する可能性があります。Parallel Move Resolver は、すべての値が正しい場所に収まるように移動を安全に実行する方法を計算します。

結果

ここで紹介したコンパイラは、Sparkplug よりも明らかに複雑でありながら、TurboFan よりもシンプルです。その成果はどうでしょうか?

コンパイル速度に関しては、Sparkplug より約10倍遅く、TurboFan より約10倍速い JIT を構築することに成功しました。

JetStream でコンパイルされたすべての関数における各コンパイル層のコンパイル時間比較

これにより、Maglev を TurboFan を使用するよりもずっと早い段階で展開できます。依拠するフィードバックがまだあまり安定していなくても、後でデオプティマイズおよび再コンパイルするコストは大きくありません。また、TurboFan を使用するタイミングを少し遅らせることも可能となり、Sparkplug を使用する場合よりもはるかに高速に動作します。

Sparkplug と TurboFan の間に Maglev を挿入することにより、ベンチマークが顕著に改善されます:

Maglev による Web パフォーマンスベンチマーク

Maglev は実際のデータでも検証され、Core Web Vitals の改善が確認されています。

Maglev がはるかに高速にコンパイルすること、そして TurboFan での関数コンパイルまでの待機時間を長くする余裕ができたことから、表向きには現れにくい副次的なメリットも生まれます。このベンチマークでは主スレッドのレイテンシに焦点を当てていますが、Maglev はオフスレッド CPU 時間を大幅に削減することで V8 の全体的なリソース消費を大幅に削減します。プロセスのエネルギー消費は taskinfo を使用して M1 または M2 ベースの MacBook 上で簡単に測定できます。

ベンチマークエネルギー消費量
JetStream-3.5%
Speedometer-10%

Maglev はまだ完全ではありません。まだ取り組むべき作業、試してみるべきアイデア、収穫しやすい結果がたくさん残っています — Maglev がより完成すれば、より高いスコアの獲得やエネルギー消費のさらなる削減が期待できます。

Maglev は現在デスクトップ版 Chrome で利用可能で、モバイルデバイスにもまもなく展開される予定です。