可変ヒープ数でV8をターボチャージ
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
が呼び出されるたびに更新され、擬似乱数列を生成します。特に、このseed
はScriptContext
に格納されています。
ScriptContext
は特定のスクリプト内でアクセス可能な値を格納するストレージ場所となります。内部的には、これはV8のタグ付き値の配列として表現されます。64ビットシステム向けのデフォルトのV8構成では、各タグ付き値は32ビットを占有します。各値の最下位ビットはタグとして機能します。0
は31ビットの_スモール整数_(SMI
)を意味します。実際の整数値は直接格納され、一ビット左にシフトされます。1
はヒープオブジェクトへの圧縮ポインタを意味し、その圧縮ポインタ値は1加算されます。
このタグ付けにより、数値の格納方法が区別されます。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変数)を拡張し、型情報を含むようにしました。スロット値が定数か、
SMI
、HeapNumber
、または汎用タグ付き値であるかを追跡します。また、スクリプトコンテキスト内でミュータブルヒープナンバースロットという概念を導入しました。これは、JSObjects
におけるミュータブルヒープナンバーフィールドに似ています。不変のHeapNumber
を指す代わりに、スクリプトコンテキストスロットがそのHeapNumber
を所有し、そのアドレスを漏らさないようにします。これにより、最適化されたコードにおいて更新のたびに新しいHeapNumber
を確保する必要がなくなります。所有されたHeapNumber
自体がインプレースで変更されます。 -
ミュータブルヒープ
Int32
: スクリプトコンテキストスロットタイプを拡張して、数値がInt32
範囲内に収まっているかどうかを追跡できるようにしました。範囲内の場合、ミュータブルHeapNumber
は値を生のInt32
として記憶します。必要に応じて、double
への移行はHeapNumber
の再割り当てを必要としないという追加のメリットをもたらします。例えばMath.random
では、コンパイラがseed
が整数演算で一貫して更新されていることを観測し、そのスロットをミュータブルInt32
を含むとマークすることができます。
これらの最適化は、コンテキストスロットに格納された値の型に依存するコードを導入する点に注意が必要です。JITコンパイラによって生成された最適化コードはスロットが特定の型(ここではInt32
)を含むことに依存します。もしseed
スロットに異なる型の値(例えば浮動小数点数や文字列)が書き込まれると最適化コードはデオプティマイズが必要になります。これは正確性を確保するために必須です。そのためスロットに格納された型の安定性は、最高のパフォーマンスを維持するために非常に重要です。Math.random
の場合、アルゴリズム内のビットマスキングにより、seed変数が常にInt32
値を保持することが保証されています。
結果
これらの変更により、不思議なMath.random
関数が大幅に高速化されました:
-
割り当てなし / 高速インプレース更新:
seed
値はスクリプトコンテキストのミュータブルスロット内で直接更新されます。Math.random
実行時には新しいオブジェクトが割り当てられることはありません。 -
整数演算: スロットが
Int32
を含むという知識を持つコンパイラは、非常に最適化された整数命令(シフト、加算など)を生成できます。これにより、浮動小数点演算のオーバーヘッドを回避できます。
これらの最適化の総合効果はasync-fs
ベンチマークで驚くべき~2.5倍
の速度向上をもたらします。これは全体的なJetStream2スコアで~1.6%
の改善を寄与します。これは、一見単純なコードが予期せぬ性能のボトルネックを作り出し、ターゲットを絞った小さな最適化がベンチマークだけでなく大きな影響を与える可能性があることを示しています。