hnwの日記

PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話

PHP 7.0のリリースから約5年が経過し、そろそろPHP 8.0のリリースも見えてきました。人によっては使い始めて5年目になるはずのPHP 7.xですが、いまだに新しい発見があったりして面白いですね。

本稿ではPHP 7.0から入った定数配列に関する性能改善について紹介します。

PHP 5時代は配列の組み立てコストが大きかった

プログラミング上のテクニックとして、辞書データを連想配列としてプログラム中に記述し、これを必要に応じて使うというものがあります。たとえば次のコード例を見てみましょう。このような連想配列を持っておけば、プログラム中で国名コードをを扱う際に実在するかをチェックしたり、国名の日本語表記に変換したりといった処理ができるわけです。

<?php
$country_name = [
    'jp' => '日本',
    'us' => 'アメリカ合衆国',
    'ru' => 'ロシア連邦',
    /* 以下略 */
];

ところで、こうした辞書的なデータはPHPでどのように処理されるのでしょうか。PHPでは上記のコードが配列に1要素ずつ追加するopcodeにコンパイルされ、実行時にopcode列を実行することで配列が組み立てられます。つまり、プログラム中に1万要素の連想配列が登場すると1万個のopcode列にコンパイルされて実行されるわけです。この実行コストが高いため、PHPで巨大配列(数万要素オーバー)を作るのは重いといわれてきました。

どれくらい重くなるのか、下記プログラムで実際に試してみましょう1

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

39万要素の二次元配列を返す foo() を呼び出す処理です。これを私の手元の環境(Mac Mini + nginx + PHP 5.6.40)で実行して実行時間を測定しました2

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
267 ms 436 ms 87 ms

「OPcacheなし」と「OPcacheあり2回目以降」を比較すると、処理時間267msのうちの67%がコンパイル処理だということになります。コンパイル結果をキャッシュすることで速度が3倍になると考えればOPcacheの効果は大きいですね。一方で、連想配列の組み立てだけで87msかかっているわけで、OPcacheがあってもまだ遅いという見方もできます。実際にはここまで大きい連想配列を扱うことはないと思いますが、数万要素くらいなら実戦投入している現場があるはずですから、改善できるなら改善したいですね。

PHP 7でも連想配列の構築はそれなりに重い

PHP 7からは連想配列のデータ構造が改善され、高速・省スペースになったことはよく知られています。PHP 7.xでも連想配列の組み立て処理は重いままなのか確認してみましょう。

さきほどのプログラムをPHP 7.4.9で実行してみました。結果は以下の通りです。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
469 ms 9106 ms 25 ms

「OPcacheあり 2回目以降」同士で比較すると実行時間の改善はすごいですね。PHP 7の配列はさすがに速いということでしょうが、それでも25msは無視できない重さです。PHP 7をもってしても巨大配列の構築は高コストというわけです。

ところで、OPcacheの初回の処理がとんでもない重さになっているのも気になりますね。OPcacheの初回処理では最適化処理が走るので一般的にOPcacheなしのときより時間がかかるのですが、それにしても不安になる遅さです3。実戦でここまで遅くなることは少ないでしょうが、大きめの連想配列を扱う場合はPHP 7.4で採用されたコードの事前ロード機能を使ったり、別途ウォームアップ処理を実行した方が無難かもしれません。

定数配列ならコンパイル時に構築されキャッシュされるので高速(PHP 7.0以降)

ようやく本題です。PHP 7.0から定数配列4の扱いが変わっており、他の配列より性能面・メモリ消費面で有利になっています。

先ほどから実験に使っているプログラムでは連想配列の一部に変数を含んでいるため、コンパイル時に配列全体を確定することはできません。もし配列のキーと値の全てが定数であれば、コンパイル時に配列全体を確定できます。このような配列を本稿では定数配列と呼ぶことにします。

定数配列ではコンパイル時に配列全体が構築され、プログラム中でこれを利用するようになります。また、OPcacheが有効であれば初回コンパイル時に作られた配列がキャッシュされ、以降のリクエストで使い回されます。

定数配列の挙動を確認するため、先ほどのプログラムと要素数は同じまま値を全て定数にして実験してみましょう。

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

上記プログラムを PHP 5.6.40 と PHP 7.4.9 で試した結果が下記になります。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
PHP 5.6.40 313 ms 450 ms 85 ms
PHP 7.4.9 331 ms 458 ms 1 ms

PHP 5.6.40では変更前のプログラムと大差ない結果になりました。PHP 5では定数配列だからといって有利というわけではないようです。

一方PHP 7.4.9では全てのケースで改善が見られました。特にOPcacheあり2回目以降の1msというのは衝撃的な結果です。先ほどの実験によれば真面目に組み立てたら25msかかるサイズの配列を1msで返しているわけですから、定数配列の使い回し効果は絶大といえるでしょう。

もちろん初回アクセス時はそれなりに時間がかかるわけですが、これが気になる場合はコードの事前ロード機能を使うなどすれば実質使い放題というわけです。

まとめると、PHP 7ならどんなに巨大な定数配列でも実質タダで使えるわけですよ。やりましたね、PHPユーザーの皆さん!

まとめ

  • PHPの配列はopcode列としてコンパイルされるため、OPcacheありでも巨大配列の生成コストは高い
    • PHP 5ではこの問題が顕著だった
  • PHP 7から定数だけで構成された配列はコンパイル時に生成されるようになった(定数配列)
  • 定数配列はOPcacheのキャッシュ対象になっているため、キャッシュヒットすれば生成コストなしで使える

本稿の内容はイマイチ知られていない気がしますが、これを活用できるプロジェクトは多そうです。性能に影響を与える規模の配列・連想配列(数万要素程度?)を使っている場合、PHP 7の定数配列を利用できないか検討してみてはいかがでしょうか。


  1. 追試したい方のためにプログラム全体をgistにアップロードしました(巨大ソースコードなので、ブラクラ的な意味で閲覧注意です)

  2. ここで示した実行時間はnginxのログ $upstream_response_time で取得し、何回分か平均を取ったものです。

  3. OPcacheのイケてない実装を突いてしまった可能性がありそうです…

  4. PHPの内部ではimmutable arrayと呼ばれているもの。あまり直感的な名前ではないと感じたので、本稿ではこのように呼びます。