Zend仮想マシン:実行、コード生成、最適化
前提知識
- ›第1〜3回:アーキテクチャ、データ構造、コンパイルの完全な理解
- ›CPUディスパッチの仕組みの理解(関数ポインタ、computed goto)
- ›SSA形式とコンパイラ最適化パスの基礎知識
Zend仮想マシン:実行、コード生成、最適化
第1〜3回では、PHPソースがzend_op命令の op_array になるまでの全体像を追いかけました。今回はその命令を実際に実行するコンポーネント、Zend仮想マシン(VM)に焦点を当てます。PHPの実行時間のほぼすべてはVMの中で消費されるため、そのアーキテクチャはディスパッチのスループットを極限まで高めることを目的に設計されています。
Zend VMが他の言語ランタイムと一線を画すのは、テンプレートベースのコード生成システムを持つ点です。型に特化したハンドラを手書きするのではなく、PHPスクリプトがハンドラテンプレートを読み込んで数千のバリアントへと展開し、123,000行の生成ファイルを作り出します。これによって、ホットパスからランタイムの型ディスパッチが排除されます。本記事では、このシステム、5つのディスパッチモード、コールフレームのレイアウト、そして実行前にオペコードを変換するSSAベースのオプティマイザを詳しく解説します。
VMコード生成システム
VMは、他のインタープリタには見られない3ファイル構成を採用しています。
Zend/zend_vm_def.h— 型プレースホルダを含む約204個のハンドラテンプレートZend/zend_vm_gen.php— テンプレートを読み込んで特化バリアントを生成するPHPスクリプトZend/zend_vm_execute.h— 約123,000行の生成済み出力ファイル(エグゼキュータからincludeされる)
flowchart LR
DEF["zend_vm_def.h<br/>204 handler templates<br/>with OP1_TYPE, OP2_TYPE placeholders"]
GEN["zend_vm_gen.php<br/>Template expander<br/>Type specialization engine"]
EXEC["zend_vm_execute.h<br/>~123,000 lines<br/>Thousands of specialized handlers"]
OPCODES["zend_vm_opcodes.h<br/>Opcode → handler mapping<br/>Dispatch tables"]
DEF --> GEN
GEN --> EXEC
GEN --> OPCODES
ここで重要になるのが型の特化です。たとえば$a + $bという加算処理を考えてみましょう。実行時に$aと$bはそれぞれIS_CONST、IS_CV、IS_TMP_VAR、IS_VARのいずれかになり得ます。組み合わせは最大で4×4=16通りです。zend_vm_def.hのZEND_ADDテンプレートはOP1_TYPEやOP2_TYPEといったプレースホルダを使用しています。ジェネレータはこれをZEND_ADD_SPEC_CONST_CONST、ZEND_ADD_SPEC_CV_CV、ZEND_ADD_SPEC_CV_CONSTなどの個別関数に展開します。
各特化ハンドラは、オペランドの型をコンパイル時に把握しています。CONSTバリアントはリテラルテーブルへ直接インデックスアクセスでき、CVバリアントはコンパイル済み変数スロットへ直接アクセスできます。つまり、op1_typeに対するランタイムのswitch文が不要になり、オペランドごとに1〜2つの分岐をホットパスから排除できます。
ジェネレータスクリプトはzend_vm_opcodes.hのオペコードからハンドラへのマッピングテーブルも生成します。コンパイル時にコンパイラがzend_opを発行する際、オペランドの型に基づいて適切な特化ハンドラを検索し、関数ポインタをopのhandlerフィールドに直接格納します。
5つのディスパッチモード
生成されたVMは、Cコンパイル時に選択される5つのディスパッチ戦略をサポートしています。モードはZend/zend_vm_opcodes.hの定数で決まります。
| モード | 仕組み | 用途 |
|---|---|---|
| CALL | handler(execute_data) — 間接関数呼び出し |
フォールバック / ポータブル |
| SWITCH | switch(opcode) { case ... } |
デバッグビルド |
| GOTO | GCCのcomputed goto(goto *handler) |
labels-as-values対応のGCC/Clang |
| HYBRID | computed gotoと関数呼び出しの組み合わせ | GCC/Clangでのデフォルト |
| TAILCALL | Clangのmusttail + preserve_none |
最新。Clang 19以降のみ |
flowchart TD
START["Execute next opcode"] --> MODE{"Dispatch mode?"}
MODE -->|"CALL"| CALL["opline->handler(execute_data)<br/>Indirect function call<br/>CPU: predict call target"]
MODE -->|"SWITCH"| SW["switch(opline->opcode)<br/>Jump table<br/>CPU: predict branch"]
MODE -->|"GOTO"| GOTO["goto *opline->handler<br/>Computed goto<br/>CPU: no prediction needed"]
MODE -->|"HYBRID"| HY["Hot path: computed goto<br/>Cold path: function call<br/>Best of both worlds"]
MODE -->|"TAILCALL"| TC["musttail return handler()<br/>preserve_none convention<br/>Near-zero call overhead"]
CALL --> NEXT["Advance opline, repeat"]
SW --> NEXT
GOTO --> NEXT
HY --> NEXT
TC --> NEXT
HYBRIDモード(デフォルト)が最も興味深い設計です。頻繁に実行されるホットなハンドラにはcomputed gotoディスパッチを使用し、関数呼び出し/リターンのオーバーヘッドを回避します。コールドなハンドラはディスパッチループから呼び出される通常の関数です。この構成により、ホットパスの命令キャッシュのフットプリントを小さく保ちながら、コールドなハンドラがキャッシュを汚染することなく大きなコードを持てます。
TAILCALLモードはClang 19以降を必要とする最新の追加機能です。musttail属性で末尾呼び出し最適化を保証し、preserve_none呼び出し規約でレジスタの保存・復元のオーバーヘッドを最小化します。各ハンドラが次のハンドラに末尾呼び出しするため、ディスパッチループそのものが不要になります。
ヒント:
php -i | grep "Virtual Machine"でビルドのVMモードを確認できますが、常に表示されるわけではありません。ビルド出力のZEND_VM_KINDコンパイルフラグを確認するほうが確実です。
ハンドラテンプレートの構造
テンプレートシステムを具体的に理解するために、ZEND_ADDハンドラを見てみましょう。Zend/zend_vm_def.hでは、ハンドラは一貫したパターンに従っています。
-
オペランドの取得:
GET_OP1_ZVAL_PTR/GET_OP2_ZVAL_PTR— 特化された型に応じて異なる展開をするマクロです。IS_CVの場合はCVテーブルへの直接ポインタ、IS_CONSTの場合はリテラル配列へのインデックスアクセスになります。 -
ファストパス: 両オペランドが
IS_LONGかどうかを確認します。そうであれば整数加算を直接実行します。結果がオーバーフローした場合はdoubleパスにフォールスルーします。これが最も一般的なケースで、関数呼び出しのオーバーヘッドを完全に排除します。 -
ミディアムパス: 一方または両方が
IS_DOUBLEかどうかを確認し、浮動小数点加算を実行します。 -
スローパス: 型の変換、オブジェクトの演算子オーバーロード、エラーケースを処理する汎用関数を呼び出します。
-
結果の格納: 結果のzvalスロットに書き込み、
oplineを次の命令に進めます。
flowchart TD
FETCH["Fetch op1, op2<br/>(type-specialized)"] --> FAST{"Both IS_LONG?"}
FAST -->|"Yes"| IADD["Integer add<br/>Check overflow"]
IADD --> OVF{"Overflow?"}
OVF -->|"No"| STORE["Store IS_LONG result"]
OVF -->|"Yes"| DFLOAT["Convert to IS_DOUBLE"]
FAST -->|"No"| DBL{"Either IS_DOUBLE?"}
DBL -->|"Yes"| DADD["Double add"]
DADD --> DSTORE["Store IS_DOUBLE result"]
DBL -->|"No"| SLOW["Slow path:<br/>type coercion,<br/>operator overloading"]
STORE --> NEXT["ZEND_VM_NEXT_OPCODE()"]
DSTORE --> NEXT
DFLOAT --> DSTORE
SLOW --> NEXT
マクロZEND_VM_NEXT_OPCODE()はoplineを次の命令に進め、そのハンドラにディスパッチします。GOTOモードではgoto *(++opline)->handlerになり、CALLモードでは現在のハンドラからのreturnになります(ディスパッチループが次のハンドラを呼び出します)。HYBRIDモードではホットなハンドラにラベルを使用します。
グローバルレジスタピン留め
GCCまたはClangを使ったx86_64環境では、VMは2つの重要な値をCPUレジスタにピン留めします。Zend/zend_execute.cで定義されています。
execute_data→%r14(またはその相当)にピン留めopline→%r15にピン留め
これらはすべてのオペコードディスパッチでアクセスされる値です。レジスタにピン留めすることで、ホットループからメモリロードが排除され、CPUは常に現在のフレームポインタと命令ポインタをすぐに参照できます。
EXECUTE_DATA_DとOPLINE_Dマクロは、ピン留めが利用可能な場合はレジスタ変数宣言に展開され、そうでない場合は通常のローカル変数になります。ベンチマークによると、レジスタピン留めだけで5〜15%の性能向上が得られます。
この手法はGCCとClangがregister ... asm("r14")拡張をサポートしていることを利用しています。グローバルレジスタ変数がサポートされていないアーキテクチャや、コンパイラが関数呼び出し間でのレジスタ保持を保証できない場合、マクロはスタック変数にフォールバックします。
コールフレームのレイアウト
PHPが関数を呼び出す際、VMはCのコールスタックを使いません。代わりに、カスタムのVMスタック上にzend_execute_dataフレームを確保します。このフレームのレイアウトはZend/zend_compile.hで定義されています。
flowchart TB
subgraph frame["zend_execute_data frame on VM stack"]
direction TB
HEADER["zend_execute_data header<br/>opline, func, This, prev_execute_data<br/>return_value, run_time_cache"]
CV["CV slots (Compiled Variables)<br/>[0]: $this (if method)<br/>[1]: $param1<br/>[2]: $param2<br/>[3]: $localVar<br/>..."]
TMP["TMP_VAR / VAR slots<br/>(expression temporaries)"]
EXTRA["Extra args<br/>(variadic overflow)"]
end
CALLER["Caller's frame<br/>(prev_execute_data)"] --> HEADER
HEADER --> CV
CV --> TMP
TMP --> EXTRA
zend_execute_data構造体には以下が含まれます。
opline: 現在の命令ポインタ(ファストパスではレジスタにピン留め)func: 実行中のzend_functionへのポインタThis: メソッド呼び出し時の$thisオブジェクト(関数の場合は特殊な内部値)prev_execute_data: 呼び出し元フレームへのリンクreturn_value: 戻り値の格納先へのポインタ(呼び出し元の結果スロット)
ヘッダの直後にCVスロットが続きます。コンパイル済み変数ごとに1つのzvalが宣言順に並びます。コンパイラは各$variableに数値インデックスを割り当て、VMはEX_VAR(offset)としてexecute_dataからの単純なポインタオフセットでアクセスします。
CVの後には、式の一時変数用のTMP_VARとVARスロットが続きます。これらはコンパイルパス中にコンパイラが割り当て、同時に必要な一時変数の最大数に合わせてサイズが決まります。
関数の引数は、フレームを切り替える前に呼び出し先のCVスロットを事前に初期化することで渡されます。呼び出し規約は、呼び出し先のフレームを確保し、引数をCV[0]、CV[1]、...にコピーしてからexecute_dataを新しいフレームに切り替えるという流れです。
フック可能な関数ポインタ
php-srcの最も重要な拡張パターンの一つが、実行時に差し替え可能なグローバル関数ポインタの使用です。これらはエンジン起動時にZend/zend.cで設定されます。
sequenceDiagram
participant Engine as Zend Engine
participant OPcache as OPcache Extension
participant Profiler as Xdebug/APM
Note over Engine: zend_startup() sets defaults
Engine->>Engine: zend_compile_file = compile_file
Engine->>Engine: zend_execute_ex = execute_ex
Engine->>Engine: zend_execute_internal = NULL
Note over Engine,OPcache: During MINIT
OPcache->>Engine: Save original zend_compile_file
OPcache->>Engine: zend_compile_file = persistent_compile_file
Profiler->>Engine: Save original zend_execute_ex
Profiler->>Engine: zend_execute_ex = profiler_execute_ex
Note over Engine: Runtime compilation
Engine->>OPcache: zend_compile_file("script.php")
OPcache->>OPcache: Check shared memory cache
alt Cache hit
OPcache-->>Engine: Return cached op_array
else Cache miss
OPcache->>Engine: Call original compile_file()
OPcache->>OPcache: Store in shared memory
OPcache-->>Engine: Return op_array
end
主要なフック可能ポインタは3つあります。
zend_compile_file: PHPファイルのコンパイル時に呼び出されます。OPcacheはこれを差し替えてコンパイルを横取りし、キャッシュされたop_arrayを返します。zend_execute_ex: ユーザー関数のオペコードを実行する際に呼び出されます。デバッガ(Xdebug)やプロファイラはこれを差し替えて関数の入退出を計測します。zend_execute_internal: 内部(C言語)関数の実行時に呼び出されます。APMツールはこれをフックして組み込み関数の呼び出しを監視します。
拡張機能は元のポインタを保存し、必要に応じてオリジナルを呼び出すよう置き換えます。これによってmiddlewareのような連鎖が生まれます。OPcacheのコンパイルフックがキャッシュを確認し、ミスの場合は元のコンパイラを呼び出して結果を保存する、という流れです。
ヒント: 実行をインターセプトするPHP拡張を書く場合、
zend_execute_exの置き換えよりも次のセクションで説明するObserver APIを優先してください。Observer APIは他の拡張との安全な共存を想定して設計されており、グローバル関数ポインタの置き換えは競合が起きやすくなっています。
Observer API
Zend/zend_observer.hで定義され、Zend/zend_observer.cで実装されているObserver APIは、グローバル関数ポインタを置き換えることなく関数呼び出しを計測する、構造化された方法を提供します。
拡張機能は、関数の開始と終了時に呼び出されるオブザーバハンドラを登録します。
zend_observer_fcall_register: すべての関数呼び出しに対して呼び出されるコールバックを登録します。このコールバックはbeginハンドラとendハンドラを提供できます。beginハンドラは関数エントリ時にexecute_dataを受け取ります。endハンドラは関数終了時にexecute_dataと戻り値を受け取ります。
複数のオブザーバを共存させることができます。エンジンは登録されたハンドラの配列を保持し、すべてを順に呼び出します。ハンドラは関数ごとのランタイムキャッシュに格納されるため、ルックアップのコストはリクエストごとに一度だけ発生します。
Observer APIはファイバーの切り替え通知(zend_observer_fiber_switch_register)やエラー通知もサポートしており、APMツール、プロファイラ、コードカバレッジツールにとって推奨のフックポイントとなっています。
SSAベースのオプティマイザ
OPcacheが有効な場合、コンパイルされたop_arrayは実行前に多段階の最適化パイプラインを通過します。オプティマイザはZend/Optimizer/ディレクトリにあり、Zend/Optimizer/zend_optimizer.cによってオーケストレーションされます。
flowchart TD
INPUT["zend_op_array<br/>(unoptimized)"] --> P1["Pass 1: Constant Folding<br/>Evaluate constant expressions"]
P1 --> CFG["CFG Construction<br/>(zend_cfg.c)<br/>Build control flow graph"]
CFG --> SSA["SSA Construction<br/>(zend_ssa.c)<br/>Insert phi nodes, rename vars"]
SSA --> TI["Type Inference<br/>(zend_inference.c)<br/>Propagate types through SSA"]
TI --> SCCP["SCCP Pass<br/>(sccp.c)<br/>Sparse Conditional Constant Propagation"]
SCCP --> DCE["DCE Pass<br/>(dce.c)<br/>Dead Code Elimination"]
DCE --> DFA["DFA Pass<br/>(dfa_pass.c)<br/>Data-flow optimizations"]
DFA --> BLOCK["Block Pass<br/>(block_pass.c)<br/>Peephole, jump threading"]
BLOCK --> OUTPUT["Optimized zend_op_array"]
SSAデータ構造はZend/Optimizer/zend_ssa.hで定義されています。各SSA変数は定義ポイント、使用チェーン、推論された型情報を持ち、制御フローのマージポイントにファイノードが挿入されます。
型推論(zend_inference.c)は特に重要で、その結果はJITコンパイラにフィードされます。ある時点での変数が常にIS_LONGであることがわかれば、JITは型チェックなしで整数専用のマシンコードを生成できます。
SCCP(Sparse Conditional Constant Propagation)はZend/Optimizer/sccp.cに実装されており、定数伝播と到達不能コードの検出を組み合わせています。分岐条件が定数であることがわかれば、偽のブランチが除去されます。
DCE(Dead Code Elimination)はZend/Optimizer/dce.cに実装されており、結果が一度も使われない命令を除去します。SCCPで定数が伝播して式が単純化された後に実行すると、驚くほど効果的です。
オプティマイザのパスレベルはopcache.optimization_level INI設定で制御します。各ビットが特定のパスを有効にするビットマスクで、デフォルトではすべてのパスが有効になっています。
次回予告
これでVMのディスパッチから最適化まで、実行パイプラインの全体像をカバーしました。第5回(最終回)では、PHPを実用的なものにする拡張エコシステムを探ります。具体的には、ライフサイクルフックを持つ拡張API、OPcacheの共有メモリアーキテクチャ、ホットなオペコードをネイティブマシンコードに変換するJITコンパイラ、協調的な並行処理のためのFiber、ストリームI/Oの抽象化、そしてスレッドセーフのためのTSRMを取り上げます。これらのシステムが組み合わさることで、ZendエンジンはウェブのPHPランタイムとして完成します。