こんにちは、エンジニアの 工藤 です。
PHP 8.5 がリリースされてもう半年近く経とうとしていますが、ひっそりと PHP 8.5 に入った Tail call VM について詳しく知っている人は少ないのではないかと思います。
この Tail call VM は PHP を利用する人にとっては直近で大きな意味や影響があるものではないのですが、今後の PHP の未来に向けて、非常に大きな一歩を踏み出したとも言えるような機能になっています。
今回はそんな Tail call VM とそれが生まれた背景、今後どうこれが活きてくるのかについてご紹介させていただきたいと思います。
PHP の動作原理と Zend Engine について
私たちが普段何気なく書いている PHP ですが、それを影で支えている Zend Engine については使っていても知らない、意識していない人も多いのではないかと思います。
Zend Engine は PHP という言語で書かれたコードを実際に実行する枠組み (一般的なプログラミング言語で言う 処理系) です。 PHP で書かれたコードを Zend VM という仮想マシンで実行可能な opcode にコンパイルしたり、 Zend Memory Manager で動的なメモリの確保と解放を担っています。
Zend VM の動作モード
Zend VM はコンパイル時に動作モードが決まります。 PHP 8.5 未満では以下の動作モードが存在していました。
ZEND_VM_KIND_CALL
最も原始的な実行方式で、 opcode を関数として実行します。その原始的な動作方法から実行速度はとても遅く、コンパイル時に他の VM モードが利用できないと判断された場合のフォールバックになっています。
ZEND_VM_KIND_SWITCH
opcode ハンドラを ID として持ち、ループしながらそれを switch 文で回して実行する方式です。通常利用されることはなく、コンパイル時に明示的に指定しない限り利用されることはありません。主にデバッグ目的以外では使われていません。
ZEND_VM_KIND_GOTO
opcode ハンドラをラベルとし、 goto で直接実行する方式です。後述する HYBRID が生まれるまでは最も高速な実行手段でした。
ZEND_VM_KIND_HYBRID
PHP 7.2 から採用された方式で、 ZEND_VM_KIND_GOTO の手法と関数ハンドラを併用する方式です。最も高速ですが、この VM は GCC (C / C++ コンパイラ) の Global Register Variables 拡張 に依存しており、少なくとも clang では利用できないという制約がありました。
Global Register Variables (以下: GRV) は、プログラム側から特定の CPU レジスタをグローバル変数として割り当てる機能です。PHP の仮想マシンはレジスタマシンベースの仮想マシン1であるため、この種の最適化と相性が良く、PHP 7.2 では Zend VM 周辺の改善によって大きな性能向上が見られました。
以下のベンチマークは MacBook Air (M4, 4P6E, 32GB RAM) 上の OrbStack 上の Docker イメージ上で行っています。バージョン差分による他の最適化も含まれるため、ZEND_VM_KIND_HYBRID 単独の効果を切り分けた比較ではありませんが、7.2 系で性能が大きく改善していることが確認できます。
PHP 7.1
$ docker run --rm -it php:7.1-cli /bin/bash $ docker-php-source extract $ php /usr/src/php/Zend/bench.php
PHP 7.2
$ docker run --rm -it php:7.2-cli /bin/bash $ docker-php-source extract $ php /usr/src/php/Zend/bench.php
実行結果
| Benchmark | PHP 7.1 (GOTO) | PHP 7.2 (HYBRID) |
|---|---|---|
| simple | 0.041 | 0.035 |
| simplecall | 0.009 | 0.007 |
| simpleucall | 0.016 | 0.015 |
| simpleudcall | 0.022 | 0.015 |
| mandel | 0.091 | 0.057 |
| mandel2 | 0.099 | 0.062 |
| ackermann(7) | 0.020 | 0.014 |
| ary(50000) | 0.003 | 0.002 |
| ary2(50000) | 0.001 | 0.001 |
| ary3(2000) | 0.037 | 0.026 |
| fibo(30) | 0.066 | 0.045 |
| hash1(50000) | 0.005 | 0.003 |
| hash2(500) | 0.004 | 0.004 |
| heapsort(20000) | 0.017 | 0.016 |
| matrix(20) | 0.019 | 0.014 |
| nestedloop(12) | 0.044 | 0.035 |
| sieve(30) | 0.012 | 0.010 |
| strcat(200000) | 0.003 | 0.002 |
| Total | 0.509 | 0.363 |
一方で、 GRV は GCC の拡張機能で、 clang では HYBRID VM を前提にした最適化をそのまま使えず ZEND_VM_KIND_GOTO にフォールバックしてしまい、 PHP 7.2 以降も GCC ビルドのほうが有利な状況が続いていました。
新たな選択肢 ZEND_VM_KIND_TAILCALL
PHP 8.5 では、新たな VM として Tail call VM が提案されました。
Tail call VM は LLVM clang (C / C++ コンパイラ) 19 以降において、 opcode ハンドラを関数呼び出しではなく末尾呼び出し2とすることで逐次実行ループに戻っていたのをやめ、直接次のハンドラを呼び出すようにしたものです。構成時に musttail, preserve_none が使え、プラットフォームが amd64 または arm64 であるかを判定し、利用可能であれば有効にする形になっています。
この発想は Josh Haberman 氏による tail-call interpreter の案 をベースとしており、 Python でも 3.14 で取り入れられています。 PHP では OPcache JIT への考慮もされている という点がポイントです。これにより PHP は GCC 固有機能に依存したパフォーマンスから脱却し、 LTO (Link Time Optimization)3 などコンパイラ全体の最適化も試しやすくなりました。 実際の効果はビルド条件やワークロードに左右されますが、最適化の選択肢が広がりました。
では、実際に PHP 8.4 と PHP 8.5 でそれぞれ GCC / clang でビルドした PHP でどの程度速度が改善されているのか確かめてみましょう。ビルドオプションは DockerHub の PHP 標準のものを利用する形としています。 (複雑ですが、結果だけ見てもらえれば大丈夫です)
PHP 8.4
$ docker run --rm -it php:8.4-cli /bin/bash $ docker-php-source extract $ apt-get update && apt-get install -y --no-install-recommends "make" "pkg-config" "re2c" "bison" "gcc" "g++" "clang-19" "clang++-19" $ cp -R "/usr/src/php" "/usr/src/php_gcc" $ cd "/usr/src/php_gcc" $ CC="$(command -v "gcc")" CXX="$(command -v "g++")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" ./configure --disable-all --disable-phpdbg --disable-cgi --disable-fpm --enable-cli $ CC="$(command -v "gcc")" CXX="$(command -v "g++")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" make -j"$(nproc)" $ cp -R "/usr/src/php" "/usr/src/php_clang" $ cd "/usr/src/php_clang" $ CC="$(command -v "clang-19")" CXX="$(command -v "clang++-19")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" ./configure --disable-all --disable-phpdbg --disable-cgi --disable-fpm --enable-cli $ CC="$(command -v "clang-19")" CXX="$(command -v "clang++-19")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" make -j"$(nproc)" $ /usr/src/php_gcc/sapi/cli/php /usr/src/php/Zend/bench.php $ /usr/src/php_clang/sapi/cli/php /usr/src/php/Zend/bench.php
PHP 8.5
$ docker run --rm -it php:8.5-cli /bin/bash $ docker-php-source extract $ apt-get update && apt-get install -y --no-install-recommends "make" "pkg-config" "re2c" "bison" "gcc" "g++" "clang-19" "clang++-19" $ cp -R "/usr/src/php" "/usr/src/php_gcc" $ cd "/usr/src/php_gcc" $ CC="$(command -v "gcc")" CXX="$(command -v "g++")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" ./configure --disable-all --disable-phpdbg --disable-cgi --disable-fpm --enable-cli $ CC="$(command -v "gcc")" CXX="$(command -v "g++")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" make -j"$(nproc)" $ cp -R "/usr/src/php" "/usr/src/php_clang" $ cd "/usr/src/php_clang" $ CC="$(command -v "clang-19")" CXX="$(command -v "clang++-19")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" ./configure --disable-all --disable-phpdbg --disable-cgi --disable-fpm --enable-cli $ CC="$(command -v "clang-19")" CXX="$(command -v "clang++-19")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" make -j"$(nproc)" $ /usr/src/php_gcc/sapi/cli/php /usr/src/php/Zend/bench.php $ /usr/src/php_clang/sapi/cli/php /usr/src/php/Zend/bench.php
実行結果
| Benchmark | PHP 8.4 (gcc, HYBRID) | PHP 8.4 (clang-19, GOTO) | PHP 8.5 (gcc, HYBRID) | PHP 8.5 (clang-19, TAILCALL) |
|---|---|---|---|---|
| simple | 0.010 | 0.028 | 0.009 | 0.011 |
| simplecall | 0.003 | 0.009 | 0.005 | 0.004 |
| simpleucall | 0.013 | 0.016 | 0.010 | 0.017 |
| simpleudcall | 0.011 | 0.015 | 0.010 | 0.014 |
| mandel | 0.038 | 0.054 | 0.036 | 0.032 |
| mandel2 | 0.028 | 0.067 | 0.029 | 0.026 |
| ackermann(7) | 0.008 | 0.013 | 0.007 | 0.009 |
| ary(50000) | 0.001 | 0.002 | 0.001 | 0.001 |
| ary2(50000) | 0.001 | 0.001 | 0.001 | 0.001 |
| ary3(2000) | 0.012 | 0.020 | 0.012 | 0.013 |
| fibo(30) | 0.022 | 0.041 | 0.023 | 0.028 |
| hash1(50000) | 0.002 | 0.002 | 0.002 | 0.002 |
| hash2(500) | 0.003 | 0.003 | 0.003 | 0.003 |
| heapsort(20000) | 0.008 | 0.013 | 0.008 | 0.008 |
| matrix(20) | 0.007 | 0.011 | 0.007 | 0.008 |
| nestedloop(12) | 0.008 | 0.021 | 0.008 | 0.008 |
| sieve(30) | 0.004 | 0.007 | 0.004 | 0.004 |
| strcat(200000) | 0.001 | 0.002 | 0.002 | 0.001 |
| Total | 0.180 | 0.327 | 0.179 | 0.190 |
まだ ZEND_VM_KIND_HYBRID には劣る状況ですが、 0.327 から 0.190 と大きく改善されていることがわかります。 clang-19 は既に古いバージョンのため、より新しい安定版の clang 22 を用いてビルドしてみます。
PHP 8.5 (clang-22)
~~ 省略 ~~ $ cp -R "/usr/src/php" "/usr/src/php_clang22" $ cd "/usr/src/php_clang22" $ apt-get install -y --no-install-recommends "gnupg" "curl" $ mkdir -p "/usr/share/keyrings" $ curl -fsSL "https://apt.llvm.org/llvm-snapshot.gpg.key" | gpg --dearmor --yes -o "/usr/share/keyrings/llvm-snapshot.gpg" $ echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/trixie/ llvm-toolchain-trixie-22 main" > "/etc/apt/sources.list.d/llvm.list" $ apt-get update $ apt-get install -y "clang-22" "clang++-22" $ CC="$(command -v "clang-22")" CXX="$(command -v "clang++-22")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" ./configure --disable-all --disable-phpdbg --disable-cgi --disable-fpm --enable-cli $ CC="$(command -v "clang-22")" CXX="$(command -v "clang++-22")" CFLAGS="${PHP_CFLAGS}" CPPFLAGS="${PHP_CPPFLAGS}" make -j"$(nproc)" $ /usr/src/php_clang22/sapi/cli/php /usr/src/php/Zend/bench.php
実行結果
| Benchmark | PHP 8.5 (clang-22) |
|---|---|
| simple | 0.012 |
| simplecall | 0.004 |
| simpleucall | 0.008 |
| simpleudcall | 0.008 |
| mandel | 0.026 |
| mandel2 | 0.027 |
| ackermann(7) | 0.010 |
| ary(50000) | 0.002 |
| ary2(50000) | 0.001 |
| ary3(2000) | 0.013 |
| fibo(30) | 0.029 |
| hash1(50000) | 0.002 |
| hash2(500) | 0.003 |
| heapsort(20000) | 0.008 |
| matrix(20) | 0.008 |
| nestedloop(12) | 0.008 |
| sieve(30) | 0.004 |
| strcat(200000) | 0.001 |
| Total | 0.175 |
なんと、 clang でも GCC の ZEND_VM_KIND_HYBRID と同程度の速度を実現できました! GRV に依存していないため LTO による最適化やアーキテクチャ指定による最適化などもしやすく、より柔軟性が高まったと言えます。
ついでに、それぞれで OPcache を有効にしたベンチ結果を見てみます。
gcc
$ /usr/src/php_gcc/sapi/cli/php -dopcache.enable_cli=1 -dopcache.jit="tracing" /usr/src/php/Zend/bench.php
clang (19)
$ /usr/src/php_clang/sapi/cli/php -dopcache.enable_cli=1 -dopcache.jit="tracing" /usr/src/php/Zend/bench.php
clang (22)
$ /usr/src/php_clang22/sapi/cli/php -dopcache.enable_cli=1 -dopcache.jit="tracing" /usr/src/php/Zend/bench.php
実行結果
| Benchmark | gcc | clang (19) | clang (22) |
|---|---|---|---|
| simple | 0.002 | 0.003 | 0.002 |
| simplecall | 0.001 | 0.001 | 0.001 |
| simpleucall | 0.001 | 0.001 | 0.001 |
| simpleudcall | 0.001 | 0.001 | 0.001 |
| mandel | 0.008 | 0.007 | 0.008 |
| mandel2 | 0.011 | 0.013 | 0.008 |
| ackermann(7) | 0.007 | 0.007 | 0.004 |
| ary(50000) | 0.002 | 0.001 | 0.001 |
| ary2(50000) | 0.001 | 0.001 | 0.001 |
| ary3(2000) | 0.004 | 0.004 | 0.005 |
| fibo(30) | 0.019 | 0.018 | 0.017 |
| hash1(50000) | 0.002 | 0.002 | 0.002 |
| hash2(500) | 0.002 | 0.002 | 0.003 |
| heapsort(20000) | 0.004 | 0.004 | 0.005 |
| matrix(20) | 0.002 | 0.002 | 0.002 |
| nestedloop(12) | 0.002 | 0.002 | 0.002 |
| sieve(30) | 0.001 | 0.001 | 0.001 |
| strcat(200000) | 0.001 | 0.001 | 0.001 |
| Total | 0.072 | 0.071 | 0.065 |
結果はバラつきがありますが、 clang が gcc を上回るパターンが出てきました。 今回の PHP ビルドは最小限の範囲でビルドしており、ベンチマークもマイクロベンチマークのため参考値程度にしかなりませんが、それでも今まで clang が抱えていたビハインドは解消されたことがわかると思います。
PHP 8.5 は "将来に向けた重要なマイルストーン"
PHP 8.5 では 言語本体にも各種機能が追加 されましたが、 OPcache を常に静的リンクする形に変更 するなど、利用実態に即して内部エンジンも着実に改善を重ね、将来に向けた地道なリファクタリングを進めていることがわかります。
特に clang が選択肢に入るようになったのは大きく、これにより Google Cloud の Axion や AWS の Graviton などの Arm 系インスタンスでもワークロード次第では有力な選択肢になりそうです。
また、 JIT の開発においても重要なマイルストーンであり、 clang 及び LLVM toolchain の充実したデバッグ機能により、より開発が迅速化され、 JIT の安定化がより一層加速する可能性もあります。
PHP にはまだまだ進化の余地があり、その下準備も着々と進んでいます。たまに issue や PR を覗いてみると、おもしろい発見があるかもしれませんよ!
ColoplTechについて
コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpass および X で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。
また、コロプラではインフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。
- 仮想マシンの実装方式には主にレジスタマシンとスタックマシンがあります。私たちが利用しているコンピュータはレジスタマシンであるため、レジスタマシンのほうが相性が良いです(とはいえJITやコンパイラ等の効率にもよるので、一概にどちらが優れているかと言えるものではありません)↩
- 関数の最後に別の関数をそのまま呼び出して処理を移す呼び出し方。ここでは opcode ハンドラ間の遷移に利用しています↩
- コンパイルしたオブジェクトファイルをリンクする時に更に最適化をかける手法。例えば全く同一の処理をしている部分があれば共通化することでコードのフットプリントを下げキャッシュラインに乗りやすいよう最適化したりなどを行います↩