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

可変ヒープ数でV8をターボチャージ

· 約7分
[Victor Gomes](https://twitter.com/VictorBFG)、ビットシフター

V8では、JavaScriptのパフォーマンス向上に常に努めています。この努力の一環として、最近JetStream2のベンチマークスイートを見直し、パフォーマンスの問題を解消しました。この投稿では、async-fsベンチマークで大幅な2.5倍の改善を達成し、全体スコアに著しい向上をもたらした特定の最適化について詳しく説明します。この最適化はベンチマークから着想を得ましたが、実際のコードでも類似のパターンが見られます。

async-fsベンチマークは名前の通り、非同期操作に重点を置いたJavaScriptファイルシステムの実装です。しかし、意外なパフォーマンスのボトルネックが存在します。それはMath.randomの実装です。結果が一貫するように、独自の決定論的なMath.random実装が使用されています。その実装は以下の通りです:

let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();

ここで重要なのはseed変数です。この変数はMath.randomが呼び出されるたびに更新され、擬似乱数列を生成します。特に、このseedScriptContextに格納されています。

ScriptContextは特定のスクリプト内でアクセス可能な値を格納するストレージ場所となります。内部的には、これはV8のタグ付き値の配列として表現されます。64ビットシステム向けのデフォルトのV8構成では、各タグ付き値は32ビットを占有します。各値の最下位ビットはタグとして機能します。0は31ビットの_スモール整数_(SMI)を意味します。実際の整数値は直接格納され、一ビット左にシフトされます。1はヒープオブジェクトへの圧縮ポインタを意味し、その圧縮ポインタ値は1加算されます。

ScriptContextのレイアウト: 青いスロットはコンテキストメタデータおよびグローバルオブジェクト(NativeContext)へのポインタを示します。黄色のスロットは非タグ付けの倍精度浮動小数点値を示します。

このタグ付けにより、数値の格納方法が区別されます。SMIは直接ScriptContextに格納されます。より大きな数値や小数部分を持つ数値は変更不能なHeapNumberオブジェクトとしてヒープに間接的に格納され、ScriptContextはそれらへの圧縮ポインタを保持します。このアプローチは多様な数値型を効率的に処理し、一般的なSMIケースを最適化します。

ボトルネック

Math.randomのプロファイリングでは、2つの主要なパフォーマンス問題が明らかになりました:

  • HeapNumberの割り当て: スクリプトコンテキスト内のseed変数専用のスロットは通常の変更不能なHeapNumberを指しています。Math.random関数がseedを更新するたびに、新しいHeapNumberオブジェクトをヒープ上に割り当てる必要があり、これが大きな割り当てとガベージコレクションの負担を招きます。

  • 浮動小数点演算: Math.random内の計算は基本的に整数操作ですが(ビットシフトや加算を使用)、コンパイラはこれを完全に活用できません。seedが汎用のHeapNumberとして格納されるため、生成されたコードはより遅い浮動小数点命令を使用します。コンパイラはseedが常に整数として表現可能であることを証明できませんでした。コンパイラが32ビット整数範囲について推測したとしても、64ビット浮動小数点から32ビット整数への変換は費用のかかるプロセスであり、損失のないチェックも必要となります。

解決策

これらの問題に対処するため、私たちは2つの部分からなる最適化を実装しました:

  • スロットタイプ追跡 / ミュータブルヒープナンバースロット: スクリプトコンテキスト定数値追跡(初期化され変更されていないlet変数)を拡張し、型情報を含むようにしました。スロット値が定数か、SMIHeapNumber、または汎用タグ付き値であるかを追跡します。また、スクリプトコンテキスト内でミュータブルヒープナンバースロットという概念を導入しました。これは、JSObjectsにおけるミュータブルヒープナンバーフィールドに似ています。不変のHeapNumberを指す代わりに、スクリプトコンテキストスロットがそのHeapNumberを所有し、そのアドレスを漏らさないようにします。これにより、最適化されたコードにおいて更新のたびに新しいHeapNumberを確保する必要がなくなります。所有されたHeapNumber自体がインプレースで変更されます。

  • ミュータブルヒープInt32: スクリプトコンテキストスロットタイプを拡張して、数値がInt32範囲内に収まっているかどうかを追跡できるようにしました。範囲内の場合、ミュータブルHeapNumberは値を生のInt32として記憶します。必要に応じて、doubleへの移行はHeapNumberの再割り当てを必要としないという追加のメリットをもたらします。例えばMath.randomでは、コンパイラがseedが整数演算で一貫して更新されていることを観測し、そのスロットをミュータブルInt32を含むとマークすることができます。

スロットタイプ状態遷移図。緑の矢印はSMI値を保存することによる遷移を示します。青い矢印はInt32値を保存することによる遷移を表し、赤い矢印は倍精度浮動小数点値を保存することによる遷移を表します。Other状態は吸収状態として機能し、さらなる遷移を防ぎます。

これらの最適化は、コンテキストスロットに格納された値の型に依存するコードを導入する点に注意が必要です。JITコンパイラによって生成された最適化コードはスロットが特定の型(ここではInt32)を含むことに依存します。もしseedスロットに異なる型の値(例えば浮動小数点数や文字列)が書き込まれると最適化コードはデオプティマイズが必要になります。これは正確性を確保するために必須です。そのためスロットに格納された型の安定性は、最高のパフォーマンスを維持するために非常に重要です。Math.randomの場合、アルゴリズム内のビットマスキングにより、seed変数が常にInt32値を保持することが保証されています。

結果

これらの変更により、不思議なMath.random関数が大幅に高速化されました:

  • 割り当てなし / 高速インプレース更新: seed値はスクリプトコンテキストのミュータブルスロット内で直接更新されます。Math.random実行時には新しいオブジェクトが割り当てられることはありません。

  • 整数演算: スロットがInt32を含むという知識を持つコンパイラは、非常に最適化された整数命令(シフト、加算など)を生成できます。これにより、浮動小数点演算のオーバーヘッドを回避できます。

Mac M1でのasync-fsベンチマーク結果。スコアが高いほど優れています。

これらの最適化の総合効果はasync-fsベンチマークで驚くべき~2.5倍の速度向上をもたらします。これは全体的なJetStream2スコアで~1.6%の改善を寄与します。これは、一見単純なコードが予期せぬ性能のボトルネックを作り出し、ターゲットを絞った小さな最適化がベンチマークだけでなく大きな影響を与える可能性があることを示しています。