PHP7.4のpreloadでLaravel7を高速化!?

これまでシステム開発終盤の負荷試験で思うような性能が出ずに、「(;;´ )ε( `;;)・・・う~ん」と悩まされることが何度かありました。

しかしそういった場合でも、比較的ローコストで改善する方法がないかと考えていたところ、PHP7.4のpreloadと呼ばれる新機能でパフォーマンスが向上したという記事を目にしました。

「プリロード」、、、なんて素敵な響き、きっと速くなるに違いない!と思い、自分でも検証してみることにしました。

ちなみにpreloadとは、サーバ起動時に指定したスクリプトを読み込み、それをコンパイルしたものをメモリに載せて、全てのリクエストにおいてそのキャッシュを利用するという機能のことです。

やること

「preloadなし」と「preloadあり」でLaravelのベンチマークをとる。

環境

  • CentOS 7.7
  • Nginx 1.17.9
  • php-fpm 7.4.4
  • Laravel 7

VirtualBox+Dockerで環境を構築しました。

事前準備

abコマンド

abコマンドで性能測定するので次のようにhttpd-toolsが必要になります。

# yum install -y httpd-tools

検証用スクリプト

固定のjson文字列を返却するだけのシンプルなAPIで検証することにしました。

app/Http/Controllers/TestController.php
<?php
namespace App\Http\Controllers;

class TestController extends Controller
{
    public function hello()
    {
        return response()->json([
            'message' => 'Hello World!',
        ]);
    }
}
routes/web.php
<?php
・・・
Route::get('test/hello', 'TestController@hello');

preloadするためのスクリプト

PHP RFCにあるヘルパー関数をほぼそのまま流用します。 https://wiki.php.net/rfc/preload#proposal

<?php
function _preload($preload, string $pattern = "/\.php$/", array $ignore = []) {
  if (is_array($preload)) {
    foreach ($preload as $path) {
      _preload($path, $pattern, $ignore);
    }
  } else if (is_string($preload)) {
    $path = $preload;
    if (!in_array($path, $ignore)) {
      if (is_dir($path)) {
        if ($dh = opendir($path)) {
          while (($file = readdir($dh)) !== false) {
            if ($file !== "." && $file !== "..") {
              _preload($path . "/" . $file, $pattern, $ignore);
            }
          }
          closedir($dh);
        }
      } else if (is_file($path) && preg_match($pattern, $path)) {
        if (!opcache_compile_file($path)) {
          trigger_error("Preloading Failed", E_USER_ERROR);
        }
      }
    }
  }
}

_preload(["/var/www/html/preload/vendor/laravel"]);

ヘルパー関数が行っていることは、キャッシュ対象のディレクトリを指定して、そのディレクトリ以下にあるphpスクリプトを再帰的に調べた上でopcache_compile_file関数を呼んでいるだけです。

このopcache_compile_file関数がpreloadで重要な役割を果たします。 https://www.php.net/manual/ja/function.opcache-compile-file.php

この関数は、PHP スクリプトをコンパイルして opcode キャッシュに追加しますが、スクリプトは実行しません。 これを使うと、ウェブサーバーの再起動後にファイルをキャッシュしておき、 後でリクエストがあったときにそのキャッシュを使えるようになります。

サーバを再起動しない限り永久にキャッシュされ続けるので、キャッシュ対象は変更が少ないvendor/laravel以下のファイルにしています。

実は当初、キャッシュ対象ディレクトリを1階層上のvendorにしていたのですが、毎回必ず5,191個目のファイル(vendor/sebastian/resource-operations/build/generate.php)を読み込んだ時点でopcache_compile_file関数がphpエラーを吐かずに終了してしまうようで、そのせいでphp-fpmコンテナが起動できなくなりました。

問題のgenerate.phpを読み込まないように除外しても、別のファイルの読み込みで失敗するようだったので、キャッシュ対象を限定してvendor/laravelのみをキャッシュすることにしました。

ログレベル(opcache.log_verbosity_level)を上げると稼働ログは出力されるようになりましたがエラーは出力されないままで、OPcacheのメモリサイズ(opcache.memory_consumption)や読み込みファイル数(opcache.max_accelerated_files)の上限を上げても無理だったので、opcache_compile_file関数のバグのような気がするのですが、どうなのでしょうか。

この辺についてわかっている人がいたら教えて頂きたい。。。

とりあえずvendor/laravel(908ファイル)だけであれば、全てキャッシュすることができました。

OPcacheの設定

ヘルパー関数はphp.iniのopcache.preloadに指定します。こうすることによってサーバが起動した際にヘルパー関数が実行されて、phpスクリプトがキャッシュされます。

# cat /etc/php.ini
・・・
[opcache]
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data
・・・

最初ハマってしまったのですが、opcache.preloadの設定しかしていなかったため、サーバ起動時に次のようなエラーが発生しました。

Fatal Error "opcache.preload_user" has not been defined

PHPマニュアルによると、次のようにセキュリティ上の理由からopcache.preload_userにはroot以外のユーザを指定しないといけないようです。

opcache.preload_user string

root ユーザでコードをあらかじめロードすることは、セキュリティ上の理由から禁止されています。 このディレクティブは、コードを別のユーザでロードする手助けをします。

今回はNginx+php-fpmで動かしているので、www-dataユーザでヘルパー関数を実行することにしました。

実行結果

preloadなし

同時に100ユーザが、1ユーザあたり10リクエスト(合計1,000リクエスト)を発行した場合を想定して検証用スクリプトを実行しました。

preloadは行っていませんが、OPcacheは有効にしています。

$ ab -n 1000 -c 100 http://192.168.56.101/test/hello
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 192.168.56.101 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests

Server Software:        nginx/1.17.9
Server Hostname:        192.168.56.101
Server Port:            80

Document Path:          /test/hello
Document Length:        26 bytes

Concurrency Level:      100
Time taken for tests:   9.755 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      963000 bytes
HTML transferred:       26000 bytes
Requests per second:    102.51 [#/sec] (mean)
Time per request:       975.520 [ms] (mean)
Time per request:       9.755 [ms] (mean, across all concurrent requests)
Transfer rate:          96.40 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.6      0       3
Processing:    28  905 191.9    930    1478
Waiting:       28  905 191.9    929    1478
Total:         31  905 191.4    930    1478

Percentage of the requests served within a certain time (ms)
  50%    930
  66%    961
  75%    989
  80%   1021
  90%   1094
  95%   1141
  98%   1195
  99%   1299
 100%   1478 (longest request)

1秒あたり102.51リクエストを処理しています。

別途、1回のリクエストでLaravelがインクルードしているファイル数を調べたところ、410ファイルを読み込んでいました。

preloadあり

$ ab -n 1000 -c 100 http://192.168.56.101/test/hello
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 192.168.56.101 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests

Server Software:        nginx/1.17.9
Server Hostname:        192.168.56.101
Server Port:            80

Document Path:          /test/hello
Document Length:        26 bytes

Concurrency Level:      100
Time taken for tests:   8.429 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      963000 bytes
HTML transferred:       26000 bytes
Requests per second:    118.63 [#/sec] (mean)
Time per request:       842.933 [ms] (mean)
Time per request:       8.429 [ms] (mean, across all concurrent requests)
Transfer rate:          111.57 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.7      0       3
Processing:    25  805 211.2    784    1388
Waiting:       22  805 211.2    784    1388
Total:         25  805 210.9    784    1390

Percentage of the requests served within a certain time (ms)
  50%    784
  66%    803
  75%    818
  80%    829
  90%    978
  95%   1355
  98%   1370
  99%   1377
 100%   1390 (longest request)

1秒あたり118.63リクエストを処理しています。

何度か実行しましたが、「preloadあり」のほうが「preloadなし」より10%~15%ほど性能が良かったです。

インクルードしているファイル数も236に減っていました。

まとめ

preloadによりLaravelのパフォーマンスは向上しました。

しかし、OPcacheを無効→有効にすると10倍ほどパフォーマンスが向上していたので、それと比較すると思ったほどのインパクトはないというのが正直なところです。

Laravelの機能をもっと多く利用したアプリだと効果が大きいのかもしれませんが、opcache_compile_file関数で落ちてしまう問題や、preloadで読み込んだはずの一部のスクリプトがなぜか実行時にも読み込まれていたりと、枯れた技術ではないという印象でした。

現時点だと、よほどパフォーマンスを重視したいという理由以外で、本番環境で使用することはなさそうです。

う~ん、、、まぁ近々正式リリースされるPHP8のJITは爆速らしいので、そちらも検証してみたいです。

Kitaru

Programmer

PHP7.4のpreloadでLaravel7を高速化!?

お気軽に
お問い合わせください。

お問い合わせ