バイトコードVM
既定の実行経路はツリーウォーカーで、各ASTノードを訪問して値を返します。何百万回も呼び出されるホット関数では、ノードごとのディスパッチのオーバーヘッドがボトルネックになります。Phasisはその上に、投機的なバイトコードからPHPクロージャーへのコンパイラーを重ね、このオーバーヘッドを削減します。
VMが動作するタイミング
すべてのJsFunctionには、ホット呼び出しのカウンターがあります。しきい値(既定値は100)を超えると、エンジンはsrc/Bytecode/JsToPhp.phpを通じて関数本体をPHPクロージャーへコンパイルしようとします。
関数本体が既知の形に当てはまる場合、コンパイルは成功します。
- 型を予測できるローカル変数への代入(数値、オブジェクト、関数、配列、文字列)。
- 数値の算術演算。
- オブジェクト型ローカル変数と
this上のメンバー読み取り(this.v、obj.x)。 - オブジェクト型ローカル変数に対するメソッド呼び出し(
obj.method(args))。レシーバーは$thisValueとして渡されます。 new Ctor(args)コンストラクターの呼び出し。左辺のローカル変数をobject型に昇格させ、オブジェクト以外が返された場合はBailoutで保護します。- 単純な条件の
for/whileループ。 - 評価可能な式を伴う
return。 - 型付き条件を使う
if/else。
次の要素が関数本体に含まれるとコンパイルは失敗し、関数はツリーウォーカー上にとどまります。
with文。try / catch / finally(完了時のセマンティクスを正しく扱うため、ツリーウォーカーで処理)。eval()呼び出し。argumentsの直接変更。- コンパイラーがまだ低レベル化できないもの。
コンパイルに成功すると、エンジンは関数の呼び出し経路をコンパイル済みクロージャーに切り替えます。それ以降の呼び出しでは、ASTノードごとではなく、関数呼び出し1回分のオーバーヘッドでネイティブのPHPが実行されます。
フォールバック
コンパイル済みの高速経路は、数値は数値のまま、プロパティは同じ形状のままという前提を置きます。実行時に前提が崩れると、クロージャーはBailoutを投げ、その呼び出しの実行はツリーウォーカーで再開されます。
このとき、仕様への準拠が最も重要になります。フォールバックした呼び出しを、失敗した高速経路の副作用を一切観測せずに最初から再実行できる必要があります。Phasisは2つの方法でこれに対応します。
- 副作用のない前段 — 投機実行より前の副作用をコンパイラーがスキャンします。投機地点より前に関数がI/O(console.log、ホスト関数)を行う場合、高速経路はそれを省略せず、確定してから続行します。
- コンパイル時の拒否 — 状態を変更する
fn()が関わる非数値のlet r = fn()など、観測可能な副作用の巻き戻しが必要な形は、コンパイラーがコンパイルを拒否します。関数はツリーウォーカー上にとどまります。
問題となる例:高速経路で非数値の代入がfn()を実行してBailoutを投げた後、ツリーウォーカーで再実行されます。その結果、2組の副作用を伴ってfn()が2回実行されます。コンパイル時に拒否すれば、fn()は高速経路では実行されず、ツリーウォーカーでの1回だけが観測されます。
VMディスパッチ(第2層)
PHPクロージャーにコンパイルされる関数もあれば、src/Bytecode/VM.phpのさらに単純なバイトコードインタープリターに進む関数もあります。バイトコードはswitchディスパッチで処理される、opcode(LOAD_LOCAL、STORE_PROP、CALL_METHOD、…)のフラットな配列です。
VMは、PHPクロージャーにコンパイルするほどではないものの、AST走査を省く価値があるほどホットな関数に使われます。組み込みオブジェクト(Array.prototype.map、String.prototype.split、…)のメソッド呼び出しは、VM内の高速経路を通り、汎用のcallFunctionトランポリンを迂回します。
カスタム呼び出しスタック
Op::CALL、Op::CALL_METHOD、Op::NEW_CALLは、インライン呼び出し経路を使い、JS呼び出しスタック全体をVM::execute内に保持します。JS呼び出しごとに新しいPHPフレームは作りません。呼び出し時には、呼び出し元の状態(pc、コードストリーム、オペランドスタック、ローカル変数、環境、this、厳格モードフラグ)をVMごとのsavedFramesプールにあるSavedFrameインスタンスへ保存し、ディスパッチループのローカル変数を呼び出し先に切り替えます。Op::RETは保存したフレームを取り出して復元します。例外は保存フレームを巻き戻ってから、ツリーウォーカーに戻ります。
適格性はJsFunction上にメモ化されます。通常の呼び出しではvmInlineCallCache(BCコンパイル済みの本体、bind()にバインドされておらず、ネイティブでもない)、new式ではvmInlineConstructCache(さらにisConstructableでなく、派生クラスでもなく、インスタンスフィールドやプライベートメソッドの初期化子もない)が条件です。それ以外は、従来どおりcallFunction / vmNewExpressionトランポリンを通ります。
アーキテクチャ上の副次的な利点として、PHPスタックの上限がJSの再帰深度を制限しなくなります。現在の唯一の上限はCallStack::maxDepthで、4 096に設定されています。正当なtest262ケースで必要な深さを十分に上回り(大半は2 kフレーム未満)、異常な無限再帰テストはランナーのタイムアウト時間内に失敗する値です。トランスパイル済みクロージャー経路(JsToPhp)には、インライン化されないフォールバック用に独自の8 192フレームガードがあります。
フレームスナップショットジェネレーター
単純なジェネレーター本体 — yield*も、try/catch/finally内のyieldも、awaitもないもの — はバイトコードにコンパイルされます。コンパイラーは、本体が一時停止する箇所に新しいOp::YIELDオペコードを出力します。Op::YIELDはディスパッチ状態(cf、pc+1、stack、sp、locals、env、thisValue、strict)をGeneratorSnapshotヒープオブジェクトに保存し、VM::executeからYieldResultセンチネルを返します。実行を制御するJsGeneratorはスナップショットを保存し、次のnext() / throw()でVM::executeに渡して、オペランドスタック先頭の再開値を置き換えます。
対応するサブセットでは、これによりPHP-Fiber経路が置き換わります。コンパイラーが拒否する形式では、Fiberベースのドライバー(executeGeneratorBody)が引き続き使われます。たとえば、yield*委譲、tryブロック内でyieldするジェネレーター、非同期ジェネレーター、awaitを含む非同期関数です。JsGeneratorはデュアルモードで、各next() / throw() / return()で、インスタンスにスナップショットがあるかFiberがあるかを確認し、適切な経路に振り分けます。
ベンチマークで調整できる設定項目はありません。適用可否はコンパイル時に決まり、JsFunctionにメモ化されます。スナップショット経路ではyieldごとにPHPのコンテキスト切り替えが1回減り、それ以外ではFiber経路に変更はありません。
インラインキャッシュ
Op::LOAD_MEMBERは、プロトタイプの識別情報をキーとするPCごとのインラインキャッシュを持ちます。これは、インスタンス上のプロトタイプメソッドを使うパターン(c.sum(...)、arr.push(...)、すべてのクラスメソッドのディスパッチ)に使われます。ヒット条件は、レシーバーにその名前の独自スロットがなく、キャッシュされたプロトタイプ参照がレシーバーのgetPrototype()と同一で、プロトタイプのPropertyMap::$versionが保存時のバージョンと一致していることです。
キャッシュの無効化にはPropertyMap::$versionを使います。構造の変更(set / delete)では無条件に値を増やします。データスロットの値の上書きでは、オブジェクトが別のオブジェクトの[[Prototype]]として初めて認識されたときに有効になる書き込みバリアフラグPropertyMap::$isUsedAsProtoがtrueの場合に限り、値を増やします。プロトタイプとして使われないインスタンスオブジェクトでは、obj.x = vごとにIC機構のコストはかかりません。
ゲッターアクセサーはキャッシュされません(読み取りのたびに再実行する必要があります)。
PropertyMapは、書き込みのたびに無条件で増える2つ目のカウンター$mutationVersionも持ちます(書き込みバリアはありません)。JSON.stringifyキャッシュは、訪問したすべてのネストされたオブジェクト/配列でこのカウンターを記録するため、o.a = 99のようなインスタンスだけの変更も、ICの$versionがカウンターの増加を省略する場合に、キャッシュされた文字列を正しく無効化できます。
JSON.stringifyキャッシュ
JsObjectは$jsonCacheStringと$jsonCacheVersionsを持ちます(簡易文字列化処理で訪問した、ネストされた各オブジェクトの[obj, mutationVersion]ペアのリスト)。次回、replacerもspaceも指定せずにJSON.stringify(o)を呼び出すと、簡易高速経路は記録済みのすべてのバージョンに変更がないことを確認し、再走査や再エンコードをせずにキャッシュ文字列を返します。JSON.parse(JSON.stringify(o))のような頻出するディープクローン処理では、文字列化部分が約2倍高速になります。
キャッシュはミス時(記録したバージョンのいずれかが異なる場合)に通知なしで破棄され、次の走査で再生成されます。toJSON、ゲッター、アクセサー、シンボル、または[[PrimitiveValue]]スロットを含むオブジェクトは仕様準拠のシリアライザーに切り替わり、キャッシュには格納されません。そのため、キャッシュ経路で扱われるのは構造が均一なデータだけです。
組み込み機能の高速経路
最大のパフォーマンス向上は、VMに特定の組み込み機能を認識させることで得られました。
Atomics.load/store/compareExchangeと、loadSpinHook/storeNotifyHookによる協調スケジューリング — Atomicsのスピンループテストを4件復旧。Date.prototype.getTimezoneOffsetと、直接ハッシュマップを使うDSTキャッシュ — SpiderMonkeyのDSTストレステストで45 %高速化。String.fromCharCodeと、インラインASCII高速経路 — 一般的なスイープ1回あたり約100万回のディスパッチを削減。decodeURI/decodeURIComponentと、hex2binによる一括デコードを使う12文字の%XX%XX%XX%XX超高速経路。Object.keys/Object.values/Object.entriesと、プロパティマップへの直接アクセス。Array.prototype.map/filter/forEach/reduceと、基盤となるPHP配列のネイティブ反復処理。
各高速経路には「シェイプチェック」があり、レシーバーが想定された形状に一致しない場合(たとえば、通常のPHPstringに裏付けられていないTypedArrayビューe.g)、VMは仕様に準拠した低速経路に移ります。
この仕組みで行わないこと
このバイトコードVMは、V8でいうJITではありません。関数ごとにネイティブ機械語を生成せず、ICの単相プロトタイプ識別チェックを超えて、観測した型に基づく推測もしません。関数境界のインライン化は、呼び出し先の本体を呼び出し元のバイトコードに変換するのではなく、カスタムコールスタック(JS呼び出しごとに余分なPHPフレームを作らない)で近似しています。
エンジンでJITに最も近いのはJsToPhpトランスパイラーです。ホットな関数本体のPHPソースを生成し、PHP 8.5のトレーシングJITがネイティブコードにコンパイルします。JsToPhpはeval()を経由するため、初回のコンパイルコストがかかり、トレーシングJITはユーザーコードと同様に関数を検出する必要があります。eval()の手順を省き、opcacheのオペコードを直接出力すれば差をさらに縮められます。Benchmarks § Roadmapを参照してください。
ベンチマーク
bench/microbench.jsは、十数個の小規模ベンチマーク(loop-arith、loop-fib、fn-recurse、fn-deep-recurse、obj-create、obj-prop、proto-method、gen-iterate、arr-push、arr-map、str-concat、str-split-join、json-roundtrip、closure、destructure)を実行します。中央値の実行時間:
php bench/run.php
現在の数値は、各benchワークフロー実行後にBENCH.mdへコミットされます。benchとtest262のマトリクスワークフローは、どちらも手動で開始します。