COLOPL Tech Blog

コロプラのエンジニアブログです

Deep Dive PHP: PHP 8.5 の新機能「Tail call VM」とは?

こんにちは、エンジニアの 工藤 です。

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 が提案されました。

github.com

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 で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。

また、コロプラではインフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。


  1. 仮想マシンの実装方式には主にレジスタマシンとスタックマシンがあります。私たちが利用しているコンピュータはレジスタマシンであるため、レジスタマシンのほうが相性が良いです(とはいえJITやコンパイラ等の効率にもよるので、一概にどちらが優れているかと言えるものではありません)
  2. 関数の最後に別の関数をそのまま呼び出して処理を移す呼び出し方。ここでは opcode ハンドラ間の遷移に利用しています
  3. コンパイルしたオブジェクトファイルをリンクする時に更に最適化をかける手法。例えば全く同一の処理をしている部分があれば共通化することでコードのフットプリントを下げキャッシュラインに乗りやすいよう最適化したりなどを行います