OPcache・JIT が 効くコード/効かないコードを、 作って測って確かめる 1
このセッションの目的 OPcache、JIT がそれぞれどこに効くのかを 計測して、数字で見て、理解する パフォーマンス改善のヒントを 持ち帰ってもらえれば、幸いです 2
プロフィール 発表者 ma@me 所属 最近の業務 品質改善・不具合対応 3
1. OPcache の 効果計測 4
PHP の処理の流れ概要図 PHP ソース コンパイル OPCODE 生成 Zend VM で実行 5
OPcache の効くところ PHP ソース 2回目以降はスキップ コンパイル OPCODE 生成 ① 保存 OPcache メモリにキャッシュ Zend VM で実行 6
OPcache の計測 まずは OPcache がどこに効いて どこまで速くなるのか 作って・測って・確かめてみる 7
計測環境 実行基盤 ネイティブ x86_64 (GitHub Actions ubuntu-latest) PHP 8.5 フレームワーク Laravel 13.14 計測ライブラリ PHPBench 1.7 8
OPcache の効くところはシンプル 効果はコード量(コンパイル対象の規模) にほぼ比例 ↑ 効果あり:大量のクラス/ファイル ↓ 効果なし or 薄い:薄いスクリプト群 9
Laravel で効果を計測する 対象 Laravel の初期 Welcome ページを表示 条件 OPcache OFF / ON で、それぞれ 30 回ずつリクエスト 計測値 1 リクエストのブート時間 10
Laravel 計測結果 500 ファイル / リクエストを読み込み OPcache 平均 / 1リクエスト OFF との比較 OFF 68.71 ms ー ON 42.43 ms 約 26 ms 短縮 5000ファイル OPcache 平均 / 1リクエスト OFF との比較 OFF 83.142 ms ー ON 10.112 ms 約 70 ms 短縮 11
読み込みファイルが多いほど 効果:大 ⤴︎ ファイル数が多くなる、フレームワークと相性抜群 12
注意点:キャッシュ領域を ちゃんと取ってあげる キャッシュ領域が足りないと性能がダウンする 設定 計測値 差分 OFF 83.142 ms ー ON(8MB。不足) 61.680 ms 基準より約 20ms UP 24MB より約 50 msダウン ON(32MB) 10.112 ms 基準より約 70ms UP 24MB より約 50 msアップ 13
逆効果パターン ファイル数が多くても、関数 1 個だけの極小ファイルを 大量に読み込むケースでは逆効果 require "1.php"; require "2.php"; ・・・ 14
薄いスクリプト計測結果 全条件で OPcache ON の方が遅い。 キャッシュ作成コストが上回ったパターン。 関数 1 個だけの PHP ファイルを50個読み込み require数 OPcache OFF OPcache ON 差(ON − OFF) 1 0.039 ms 0.070 ms +0.031 ms 50 0.719 ms 1.000 ms +0.281 ms 500 7.033 ms 12.453 ms +5.420 ms 15
設定値で気をつけること OWeb アプリケーション:Opcache は初期設定で ON CLI実行:Opcache は初期設定で OFF このような実行形式には無効になっている php artisan queue:work 16
Laravelなど フレームワークを使った開発であれば 恩恵に預かれる。 デフォルト ON なので、よっぽどなことでない限り OFF に しない 17
2. JIT の効果計測 18
JIT(Just-in-Time) の概要 PHP には Opchache と PCRE にそれぞれ備え付けられている 必要なものを、必要な時に、必要なだけ作る =必要でなければ作られない キャッシュした OPCODE を ネイティブ機械語に変換して実行 Zend VM を介さず CPU が直接走るため 計算ヘビーな処理ほど効きやすい 19
JIT が導入された目的 PHP コードを機械語化し、実行コストを下げる。 特に、計算負荷の高い処理を高速化する。 Web 以外の CPU 集約用途 へ、PHP を広げる。 参考:JIT のプロポーザル https://wiki.php.net/rfc/jit 20
CPU アーキテクチャごとに 分かれる実装 x86(32-bit / i386) x86_64(今回使用)(64-bit / AMD64) ARM64(AArch64 / Apple Silicon 等) 同じコードでも結果に差異が出るので JIT の計測をするときは要注意 21
function と tracing function 関数全体をひとまとまりとして、機械語へコンパイル tracing 実行時に頻繁に通る経路を見つけ、 トレース単位でコンパイル 前述した CPU アーキテクチャでは特に差が出た部分 ARM64 では function が tracing より遅くなる傾向がありました (スペースの都合上、結果は載せていません) 22
JIT は Web 開発でも効果的なのか? 標準関数にどこまで作用するのか 計測していきます 23
JIT の計測環境 CPU / アーキ ネイティブ x86_64 (GitHub Actions ubuntu-latest ) PHP 8.5(OPcache 前提) JIT モード OFF / function / tracing ( jit_buffer_size = 0 / 64M) 計測ライブラリ PHPBench 24
OPchace.JIT が効く箇所 PHP ソース 2回⽬以降はスキップ コンパイル OPCODE ⽣成 Zend VM で実⾏ ① 保存 OPcache メモリにキャッシュ JIT 経路 JIT OPCODE → 機械語 CPU が直接 機械語を実⾏ 25
JIT で isset と in_array() 速度差は縮 まるのか // in_array:値の中から探す(得意:値検索) $values = range(1, 10000); in_array(10000, $values, true); // 末尾までフルスキャン // isset:キーの有無を引く(得意:キー参照) $keys = array_fill_keys(range(1, 10000), true); isset($keys[10000]); // 1 回のハッシュ参照 26
計測結果:ホットループ 1計測関数 = 対象処理を1万回実行 処理 JIT OFF function tracing 1回あたり in_array 78,788.0μs 78,139.1μs 78,271.6μs 7,878.8 / 7,813.9 ns isset 27.6μs 27.4μs 27.3μs 3.8 / 0.2 ns array_key_exists 53.7μs 62.6μs 61.2μs 6.3 / 5.4 ns 1回あたり:最大値 / 最小値 ほぼ変わらない...? array_key_exists に関しては悪化傾向に 27
標準関数の呼び出しフロー コンパイル PHP コード in_array(...) 実行 C 関数を呼ぶ Zend VM / C 実装の標準関数 JIT 生成コード 検索・比較などを実行 JIT ON で最適化される範囲 JIT の対象外 関数呼び出し OPCODE C 実装の本体処理は、JIT ON / OFF で変わらない 計算量が絡むのは C 実装側で JIT の対象外 JIT では標準関数の速度差を縮められない。 効果がないのか? 28
JIT の恩恵を受ける標準関数群 一部の関数は JIT の恩恵を受ける 1. コールバックを受け取る関数群 2. 専用 OPECODE を持つ関数 3. 正規表現を使う検索、置換関数 29
1. コールバックを受け取る 関数群 array_map, usort() などの コールバックを受け付ける関数は コールバック部分が JIT の効果を受ける function array_map( ?callable $callback, array $array, array ...$arrays ): array 30
計測結果:コールバック関数 1計測関数 = array_map / usort を10,000回実行 処理 JIT OFF function tracing 1関数あたり array_map 3,727.9μs 2,927.4μs 2,939.9μs 372.8 / 292.7 ns usort 18,097.4μs 15,293.7μs 15,297.0μs 1,809.7 / 1,529.4 ns 1関数あたり:最大値 / 最小値 約 1.18〜1.27 倍高速 31
2. 専用 OPCODE を持つ関数 一部、専用の OPCODE を持つ関数がある 実行コスト> 関数呼び出し の場合 専用 OPCODE が用意されている ZEND_STRLEN(strlen) ZEND_COUNT(count) ZEND_TYPE_CHECK(is_int / is_string 等) 32
計測結果:strlen / count 1計測関数 = それぞれの関数をを10,000回実行 呼び方 JIT OFF function tracing 1関数あたり strlen(裸) 148.4μs 86.9μs 87.5μs 14.8 / 8.7 ns \strlen 40.3μs 30.0μs 30.3μs 4.0 / 3.0 ns count(裸) 136.0μs 76.5μs 77.1μs 13.6 / 7.7 ns \count 34.9μs 23.0μs 23.6μs 3.5 / 2.3 ns 33
なぜ \ を付けると速いのか \ の有無で自作関数かグローバル関数かが決まる グローバル関数と分かれば、その分実行が早い strlen($s) \strlen($s) ユーザー作の strlen かも? グローバル関数と確定 専用 OPCODE 化できない ZEND_STRLEN へ置換 34
3. 正規表現を使う 検索、置換関数 opcache.jit 以外のもう1つの JIT pcre.jit 正規表現パターンをネイティブ化する https://www.php.net/manual/ja/pcre.configuration.php preg_match(), preg_match_all() などの関数が 恩恵を受ける 35
計測結果:PCRE JIT 1計測関数 = preg_* を10,000回実行 処理 PCRE JIT OFF PCRE JIT ON 1回あたり preg_match 190.228ms 23.524ms 19.0 / 2.4 μs preg_match_all 307.039ms 37.144ms 30.7 / 3.7 μs preg_replace_callback 302.597ms 39.270ms 30.3 / 3.9 μs 1回あたり:最大値 / 最小値 PCRE JIT で約 7.7〜8.3 倍高速 36
標準関数でも JIT の 効果はある。。。?! 37
ここで時間の単位の確認 秒(s)を基準にした大きさ 単位 記号 1秒の何分の1 換算 秒 s 1 1s = 1,000ms ミリ秒 ms 1 / 1,000 1ms = 1,000μs マイクロ秒 μs 1 / 1,000,000 1μs = 1,000ns ナノ秒 ns 1 / 1,000,000,000 — 38
時間の単位と比べてみる、strlenの改善効果 呼び方 JIT OFF function tracing 1関数あたり strlen(裸) 148.4μs 86.9μs 87.5μs 14.8 / 8.7 ns \strlen 40.3μs 30.0μs 30.3μs 4.0 / 3.0 ns 単位 記号 1秒の何分の1 換算 秒 s 1 1s = 1,000ms ミリ秒 ms 1 / 1,000 1ms = 1,000μs マイクロ秒 μs 1 / 1,000,000 1μs = 1,000ns ナノ秒 ns 1 / 1,000,000,000 — 39
正直、体感するのは難しいくらいの効果。。。 実行回数が少なければ、より効果は少ない 40
ユーザー定義コードの計測 とはいえ、普段書くコードではどうなのか? どこまで JIT の効果がでるのかを計測していきます ・四則演算 ・ループ ・条件判定 41
四則演算の計測 $value = 0.0; for ($i = 0; $i < 10000; $i++) { $value += 1.0001; } 42
計測結果 1関数 = 1万回の演算 処理 JIT OFF function tracing 1演算あたり 加算(+) 42.756μs 11.311μs 11.240μs 4.3 / 1.1 ns 減算(−) 42.619μs 11.364μs 11.301μs 4.3 / 1.1 ns 乗算(×) 42.461μs 11.321μs 11.333μs 4.2 / 1.1 ns 浮動小数点除算 85.217μs 88.022μs 88.333μs 8.8 / 8.5 ns 整数除算 145.210μs 89.204μs 89.139μs 14.5 / 8.9 ns 1演算あたり:最大値 / 最小値(ループ込み) 43
加減乗は JIT で約 3.7〜3.8 倍高速 一方、除算は JIT を有効にしても、CPU の演算コストが上回り、 遅くなる結果に いろんなパターンを試すも、除算は安定せず... 44
ループ制御の計測
純粋なループを計測するために本体処理は空
for ($i = 0; $i < self::N; $i++) {}
foreach ($this->loopValues as $_) {}
45
計測結果 for は約 6 倍、foreach は約 1.9 倍高速 1計測関数 = 10,000ループ 処理 JIT OFF JIT function JIT tracing 1ループあたり for 29.426μs 4.841μs 4.856μs 2.9 / 0.5 ns foreach 39.305μs 20.746μs 20.931μs 3.9 / 2.1 ns 1ループあたり:最大値 / 最小値 46
単純比較にも JIT は効く 1万回分(基準処理差引後の推定値) 判定 JIT OFF function tracing 1回あたり 単純 if == 61.3μs 32.7μs 34.2μs 6.1 / 3.3 ns 単純 if === 15.6μs 3.4μs 4.8μs 1.6 / 0.3 ns 単純 switch 81.9μs 41.2μs 42.3μs 8.2 / 4.1 ns 単純 match 23.3μs 2.8μs 3.5μs 2.3 / 0.3 ns 計算結果 if 44.1μs 9.6μs 7.6μs 4.4 / 0.8 ns 計算結果 switch 37.5μs 7.6μs 7.5μs 3.8 / 0.8 ns 計算結果 match 107.2μs 85.9μs 86.0μs 10.7 / 8.6 ns 1回あたり:最大値 / 最小値 47
効果があるとはいえ、μs(マイクロ秒)の改善で まだ効果が厳しい...! そもそもの処理が早いのもある。これは PHP7 以降の改善が 素晴らしい 👏 48
クラスを定義して計測 より実践的に、クラスを定義してコードの計測 final class Money { public function __construct( private readonly int $amount) {} public function add(Money $o): self { return new self($this->amount + $o->amount); } public function multiply(int $f): self { return new self($this->amount * $f); } } より実践的なコードでその効きの差を検証 お題は、クーポン割引・送料計算 49
題材:クーポン割引・送料計算 1 小計 カート明細(単価 × 数量)を合計する 2 割引 適用クーポンで割引額を算出する(定額 / 率 / 条件付き) 3 送料 配送地域 × 重量から送料を算出する(無料ライン判定あり) 4 確定 小計 − 割引 + 送料 で請求額を確定する 50
ベースの実装概要 ミュータブル版は new せず、 代入したオブジェクトを返す // クーポンは interface + 3 実装(定額 / 率 / 条件付き) interface Coupon { function discountFor(Money $s): Money; } // 小計 → 割引 → 送料 → 確定(Money は演算のたびに new) $discount = $cart->coupon->discountFor($subtotal); // ← new $discounted = $subtotal->subtract($discount); // ← new $total = $discounted->add($shipping); // ← new 51
計測結果 1計測関数 = カート確定を10,000件実行 実装 JIT OFF function tracing 1件あたり イミュータブル 18.61ms 12.27ms 12.34ms 1,861.5 / 1,226.7 ns ミュータブル 5.77ms 2.35ms 2.37ms 576.8 / 235.4 ns 1件あたり:最大値 / 最小値 JIT で イミュータブルは約 6ms イミュータブルは約 3ms の削減 52
ここまでの計測結果を 踏まえての感想 OPchache.jit は無理に適用しなくてもいいかなー。。。 色々な設定値を変えても、 ns レベルの変化しかなかった or 遅くなってしまった... 53
OPcache, JIT の計測を 終えての感想 このような設定値になっていれば理想 効果の高かった Opcache:ON 一番効果の高かった正規表現の PCRE.JIT:ON 使い所の難しい Opcache.JIT:OFF 54
PHP の OPcache / JIT の既定値 Opcache:ON 正規表現 PCRE.JIT:ON Opcache.JIT:OFF 55
すでになっていました。 デフォルトが最適! PHP 公式ありがとう...! 56
ご清聴 ありがとうございました 57