Static Roots: コンパイル時に定数アドレスを持つオブジェクト
あなたはundefined
、true
、その他のコアJavaScriptオブジェクトがどこから来るのか考えたことがありますか?これらのオブジェクトは、任意のユーザー定義オブジェクトの基本単位であり、最初に存在している必要があります。V8はこれらを固定不変なルートと呼び、それらは専用のヒープ – 読み取り専用ヒープに住んでいます。これらは頻繁に使用されるため、迅速なアクセスが重要です。そしてコンパイル時にメモリアドレスを正しく予測するほど迅速な方法はないでしょうか?
例として、非常に一般的なIsUndefined
API関数を考えてみましょう。参照するためにundefined
オブジェクトのアドレスを調べる代わりに、たとえばオブジェクトのポインタの末尾が0x61
で終わっているかどうかを確認することで、それが未定義であることを知ることができるのならどうでしょう。これがV8のstatic roots機能が達成することです。この投稿では、これを実現するために乗り越えた障壁を探ります。この機能はChrome 111で導入され、VM全体にわたるパフォーマンス向上をもたらし、特にC++コードやビルトイン関数の速度を向上させました。
読み取り専用ヒープのブートストラップ
読み取り専用オブジェクトを作成するのに少し時間がかかるため、V8はそれらをコンパイル時に作成します。V8をコンパイルするために、最初に最小限のプロト-V8バイナリmksnapshot
がコンパイルされます。これにより、共有読み取り専用オブジェクトとビルトイン関数のネイティブコードが作成され、スナップショットに書き込まれます。その後、実際のV8バイナリがコンパイルされ、スナップショットと共にバンドルされます。V8を開始するには、スナップショットをメモリにロードするだけで、その内容をすぐに使用できます。以下の図は、スタンドアロンd8
バイナリの簡略化されたビルドプロセスを示したものです。
d8
が起動すると、すべての読み取り専用オブジェクトはメモリの固定された場所に配置され、移動することはありません。コードをJITする際には、たとえばundefined
をそのアドレスで直接参照することができます。ただし、スナップショットを構築する際やlibv8のC++をコンパイルするときには、アドレスはまだ不明です。これは、ビルド時に未知の2つの要因によります。一つ目は読み取り専用ヒープのバイナリレイアウトであり、二つ目はその読み取り専用ヒープがメモリ空間のどこに配置されるかです。
アドレスを予測する方法は?
V8はポインタ圧縮を使用します。完全な64ビットアドレスの代わりに、オブジェクトをメモリ4GB領域の32ビットオフセットで参照します。プロパティのロードや比較など多くの操作において、そのケージ内の32ビットオフセットだけでオブジェクトを一意に識別できます。そのため、メモリ空間内で読み取り専用ヒープがどこに配置されるか知らないという問題は実際には問題ではありません。読み取り専用ヒープをポインタ圧縮ケージの開始位置に配置するだけで、それに既知の場所を与えます。たとえばV8ヒープ内のすべてのオブジェクトのうち、undefined
は常に最小圧縮アドレスを持ち、0x61バイトから始まっています。これにより、任意のJSオブジェクトの完全なアドレスの下位32ビットが0x61である場合、それがundefined
であることがわかります。
これはすでに便利ですが、スナップショットやlibv8でこのアドレスを使用したいと思っています – 見かけ上循環した問題です。しかし、mksnapshot
がビットで完全に同一な読み取り専用ヒープを決定的に作成することを保証すれば、これらのアドレスをビルド間で再利用できます。libv8自体でそれらを使用するためには、基本的にV8を2回ビルドします:
最初の段階でmksnapshot
を呼び出し、生成された成果物はすべての読み取り専用ヒープ内オブジェクトのケージベースからのアドレスが含まれたファイルだけです。ビルドの第2段階ではlibv8を再コンパイルし、フラグが設定されることで、undefined
を参照する場合には常にcage_base + StaticRoot::kUndefined
を使うことを保証します。もちろん、静的オフセットundefined
はstatic-roots.hファイルに定義されています。多くのケースでは、これによってlibv8を作成しているC++コンパイラやmksnapshot
内のビルトインコンパイラがより効率的なコードを作成できるようになり、代替案として常にグローバルなルートオブジェクトの配列からアドレスをロードする必要がなくなります。圧縮済みアドレスundefined
が0x61
にハードコードされたd8
バイナリを作成することになります。
まあ、倫理的にはこれが動作の仕方ですが、実際にはV8を一度しかビルドしません – 誰もそんなことをする時間はありません。生成されたstatic-roots.hファイルはソースリポジトリにキャッシュされ、読み取り専用ヒープのレイアウトを変更した場合にのみ再作成する必要があります。
その他の応用
実用性の話をすると、静的ルートはさらなる最適化を可能にします。例えば、共通のオブジェクトをまとめることで、アドレス範囲チェックとしていくつかの操作を実装することができました。例えば、すべての文字列マップ(つまり、異なる文字列型のレイアウトを記述するhidden-classメタオブジェクト)は互いに隣接しているため、オブジェクトが文字列である場合、そのマップの圧縮アドレスが0xdd
から0x49d
の間にある必要があります。また、truthyオブジェクトはそのアドレスが少なくとも0xc1
以上でなければなりません。
V8におけるJITコードの性能だけがすべてではありません。このプロジェクトが示したように、C++コードへの比較的小さな変更が大きな影響を与えることもあります。例えばSpeedometer 2は、V8 APIとV8とその埋め込みエンベッダーとの相互作用を試験するベンチマークですが、静的ルートのおかげでM1 CPU上でスコアが約1%向上しました。