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

短い組み込み呼び出し

· 約6分
[トゥーン・ヴァーワエスト](https://twitter.com/tverwaes)、The Big Short

V8 v9.1では、一時的に組み込み機能をデスクトップで非組み込み化しました。組み込み機能の活用はメモリ使用量を大幅に改善しますが、組み込み機能とJITコンパイルされたコード間での関数呼び出しが重大なパフォーマンスペナルティを伴う場合があります。このコストはCPUのマイクロアーキテクチャに依存します。この記事では、この現象がなぜ発生するのか、パフォーマンスの状況、そして長期的にこれを解決するための計画について説明します。

コードの割り当て

V8のジャストインタイム(JIT)コンパイラによって生成されるマシンコードは、VMが管理するメモリページ上に動的に割り当てられます。V8は連続したアドレス空間領域内にメモリページを割り当てます。この領域はランダムにメモリのどこかに位置する場合(アドレス空間配置ランダム化のため)、またはポインタ圧縮のために割り当てられた4GiBの仮想メモリケージ内に位置する場合があります。

V8 JITコードは組み込み機能に非常によく呼び出しを行います。組み込み機能は、本質的にはVMの一部として提供されるマシンコードのスニペットです。例えば、Function.prototype.bindのような完全なJavaScript標準ライブラリ関数を実装する組み込み機能もあれば、高レベルのJSセマンティクスと低レベルのCPU能力の間のギャップを埋めるためのヘルパースニペットもあります。例えば、JavaScript関数が別のJavaScript関数を呼び出したい場合、CallFunctionという組み込み機能を呼び出してターゲットのJavaScript関数をどのように呼び出すかを決定することがよくあります。これには、プロキシか通常の関数であるかの判定や、期待される引数の数などが含まれます。これらのスニペットはVMを構築する際に既知であるため、「組み込まれた」Chromeバイナリ内に含まれ、Chromeバイナリコード領域内に配置されることになります。

直接呼び出し vs 間接呼び出し

64ビットアーキテクチャでは、これらの組み込み機能を含むChromeバイナリはJITコードから任意の遠い位置に配置されます。x86-64命令セットでは、直接呼び出しを使用することはできません。直接呼び出しは、呼び出しのオフセットとして使用される32ビット符号付即値を必要とし、ターゲットが2GiBより遠い場所にある場合があります。その代わりに、レジスタまたはメモリオペランドを介した間接呼び出しを使用する必要があります。そのような呼び出しは予測に大きく依存します。呼び出し命令自体からターゲットが即座に明らかではないためです。ARM64では範囲が128MiBに限定されているため、直接呼び出しを全く使うことができません。これにより、いずれの場合もCPU'の間接分岐予測器の精度に依存することになります。

間接分岐予測の限界

x86-64をターゲットにする場合、直接呼び出しに依存するのが望ましいと言えるでしょう。これにより、ターゲットが命令のデコード後に既知であるため、間接分岐予測器への負担が減少するはずですが、ターゲットをレジスタにロードするために定数またはメモリが必要なくなるという利点もあります。ただし、これはマシンコードに見える明らかな違いだけに留まりません。

Spectre v2の影響により、多くのデバイス/OSの組み合わせが間接分岐予測を無効にしています。このため、そのような構成ではCallFunction組み込み機能に依存しているJITコードの関数呼び出しに非常にコストのかかる停止が発生します。

さらに重要なのは、64ビット命令セットアーキテクチャ(「CPUの高レベル言語」)が遠距離への間接呼び出しをサポートしていても、マイクロアーキテクチャは任意の制限を伴う最適化を実装する自由があります。間接分岐予測器が呼び出し距離が一定距離(例: 4GiB)を超えないと仮定することが一般的であるように見えます。これにより、予測ごとに必要なメモリ量が少なくなります。例えば、Intel Optimization Manualには以下のように明言されています。

64ビットアプリケーションでは、分岐先が分岐点から4 GBを超える場合、分岐予測性能が悪化する可能性があります。

ARM64では直接呼び出しのためのアーキテクチャ的な呼び出し範囲が128 MiBに制限されていますが、AppleのM1チップには間接呼び出し予測に関して同様のマイクロアーキテクチャ的な4 GiBの範囲制限があることが判明しました。4 GiB以上離れた呼び出しターゲットへの間接呼び出しは常に予測が外れるようです。M1の特に大きな再順序バッファ(CPU内で予測される命令を将来的に投機的に実行できるようにするコンポーネント)のため、頻繁な予測ミスは非常に大きな性能ペナルティを引き起こします。

一時的な解決策: 組み込み関数のコピー

頻繁な予測ミスのコストを回避し、可能な限りx86-64で分岐予測に不必要に頼ることを避けるため、十分なメモリを持つデスクトップマシンでV8のポインタ圧縮ケージに組み込み関数を一時的にコピーすることを決定しました。これにより、コピーされた組み込みコードが動的に生成されるコードの近くに配置されます。性能結果はデバイスの構成に大きく依存しますが、以下は性能ボットから得られた結果です:

ライブページから記録されたブラウジングベンチマーク

ベンチマークスコアの向上

組み込み関数を分離すると、影響を受けるデバイスのメモリ使用量がV8インスタンスごとに1.2~1.4 MiB増加します。長期的なより良い解決策として、JITコードをChromeバイナリの近くに配置することを検討しています。その方法で再び組み込み関数を埋め込み、メモリの利点を回復しながら、V8生成コードからC++コードへの呼び出しの性能をさらに向上させることができます。