Preloading Laravel on PHP7.4 in the real world

One of the greatest features of PHP7.4 is for sure the preload - part of the opcache extension.

We recently moved a big part of my company's core APIs from a Node-Frankenstein to a well-tested Laravel 8 project, but of course, the performance - although opcache is enabled, is not really close to a Node app; but we already knew this.

So... what's next? "Let's try the preloading", I said! It should be easy, right? Weeell...

Approach 1: vendor-centric

I had no idea where to start. The needed result was simple: loop over "desired" files and require_once - or opcache_compile_file - them.

I found time ago the great article "Preloading in PHP7.4" on stitcher by Brent. It also has a full code! That's a dream, omg. Like a child with the Hogwarts Lego I quickly copied and pasted it. Of course, I set the needed configurations: opcache.preload=/app/preload.php and opcache.preload_user=www-data - this last one is needed if your account is root.

Then I restarted my container. And it crashed. Let's see the errors...

Uncaught Error: Class 'Illuminate\Queue\Queue' not found in /app/vendor/laravel/framework/src/Illuminate/Queue/DatabaseQueue.php:13

Well, that's fine, I'm just gonna ignore that file (there's a method in the example class to ignore specific files). Time to restart. New error: Uncaught Error: Class 'Opis\Closure\SerializableClosure' not found in /app/vendor/laravel/framework/src/Illuminate/Queue/SerializableClosure.php:7. Ok...? Let's ignore that too. Then another one. And another one.

In the end, I ignored the whole Laravel framework and 0 classes were preloaded. What am I doing wrong? Why does it work in the article? I'll never know this, but let's try to replace require_once in the class with opcache_compile_file.

Now restart and... It started! But there are a few errors like Can't preload unlinked class Illuminate\Database\PDO\PostgresDriver: Unknown parent Doctrine\DBAL\Driver\AbstractPostgreSQLDriver in /app/vendor/laravel/framework/src/Illuminate/Database/PDO/PostgresDriver.php on line 8, I guess I can filter out those files later and we're good.

Time to benchmark it! First, let's test the performance without preload. Note that the project is running in a Docker container with opcache enabled, XDebug and other 20+ containers alive. And if you're asking yourself "What's ab?", the answer is: Apache Bench.

    ab -n 100 -c 5 -l http://mysite.localhost/page

    Requests per second:    21.78 [#/sec] (mean)
    Time per request:       229.516 [ms] (mean)
    Time per request:       45.903 [ms] (mean, across all concurrent requests)

That's our status quo. Now let's see with preload.

Requests per second:    25.03 [#/sec] (mean)
Time per request:       199.774 [ms] (mean)
Time per request:       39.955 [ms] (mean, across all concurrent requests)

That's an increase for sure! We gained ±14.9% req/s and ±12.9% time/req!

Can it be better? Giving a shot to the logs, it preloaded 1022 classes and, in the list of preloaded files, there are a lot of dev-dependencies (like Telescope, Tinker, Testing) and some unusued classes too (Session, Console...). Let's strip them out and see if something changes. I'm just gonna put the namespaces in the ignore() method like this:

(new Preloader())
    ->paths(__DIR__ . '/vendor/laravel')
    ->ignore(
        'Laravel\Telescope',
        'Laravel\Tinker',
        'Illuminate\Queue',
        'Illuminate\Contracts\Queue',
        'Illuminate\View',
        'Illuminate\Contracts\View',
        'Illuminate\Foundation\Console',
        'Illuminate\Notification',
        'Illuminate\Contracts\Notifications',
        'Illuminate\Bus',
        'Illuminate\Session',
        'Illuminate\Contracts\Session',
        'Illuminate\Console',
        'Illuminate\Testing',
        'Illuminate\Http\Testing',
        'Illuminate\Support\Testing',
        'Illuminate\Cookie',
        'Illuminate\Contracts\Cookie',
        'Illuminate\Broadcasting',
        'Illuminate\Contracts\Broadcasting',
        'Illuminate\Mail',
        'Illuminate\Contracts\Mail',
    )
    ->load();

Did something change? Restart again, now there are 625 preloaded classes, run ab and the results:

Requests per second:    25.22 [#/sec] (mean)
Time per request:       198.274 [ms] (mean)
Time per request:       39.655 [ms] (mean, across all concurrent requests)

That's pretty much the same, nevermind, but I want to make a final test: add more vendors to paths() method:

(new Preloader())
      ->paths(
        __DIR__ . '/vendor/psr',
        __DIR__ . '/vendor/monolog',
        __DIR__ . '/vendor/doctrine',
        __DIR__ . '/vendor/guzzlehttp',
        __DIR__ . '/vendor/ramsey/uuid',
        __DIR__ . '/vendor/ramsey/collection',
        __DIR__ . '/vendor/vlucas/phpdotenv',
        __DIR__ . '/vendor/symfony',
        __DIR__ . '/vendor/laravel',
        __DIR__ . '/vendor/nesbot/carbon',
        __DIR__ . '/vendor/sentry',
        __DIR__ . '/vendor/auth0',
      )

And running again the ab gives us:

Requests per second:    26.54 [#/sec] (mean)
Time per request:       188.400 [ms] (mean)
Time per request:       37.680 [ms] (mean, across all concurrent requests)

Ok, that's something, right? And that "something" is ±21.8% req/s and ±17.9% time/req from the no-preload checks.

Now let's group and compare all the data:

Req/sTime/req
No preload21.78229ms
Naive preload25.03199ms
Refined preload25.22198ms
Refined preload + vendors26.54188ms

Approach 2: "Hot" files

The second approach I tried was to identify the hot files using Laraload, a wrapper for Laravel of Preloader; it creates a global middleware for your app and for every 500 requests (or your custom logic) it generates a preload.php with all the detected files. Pretty straightforward.

I "forced" the requests using ApacheBench with 500 requests and in the preload.php now there are 1490 classes. Wow, the problem is that they include dev-dependencies too like Telescope or Tinker, so I started removing them - also because they would not be found in production and I went down to 1351 classes of both vendors and files of my app.

Like in the Approach 1 there were some errors, for example, I had to remove the local app/Http/Kernel.php from the preloaded files and finally, the PHP container started again.

Remember our no-preload status:

ab -n 100 -c 5 -l http://mysite.localhost/page

Requests per second:    21.78 [#/sec] (mean)
Time per request:       229.516 [ms] (mean)
Time per request:       45.903 [ms] (mean, across all concurrent requests)

As usual, I ran my Apache Bench command to check the performance aaaand my app crashed because of amphp - I don't even know which vendor uses it but nevermind -, so I removed all its references from the big files array, restarted again and ran the bench command.

Requests per second:    25.16 [#/sec] (mean)
Time per request:       198.694 [ms] (mean)
Time per request:       39.739 [ms] (mean, across all concurrent requests)

Doing the math we gained ±15.5% req/s and ±13.4% time/request, but I wanted to make another test: what happens if I keep only Laravel vendor in the array? This is the result:

Requests per second:    25.03 [#/sec] (mean)
Time per request:       199.787 [ms] (mean)
Time per request:       39.957 [ms] (mean, across all concurrent requests)

Well, it's pretty much the same.

Final thoughts

Ok kids, time to bring something to the table. All the data to me!

Req/sTime/req
No preload21.78229ms
Naive preload (#1)25.03199ms
Refined preload (#2)25.22198ms
Refined preload + vendors (#3)26.54188ms
Hot files preload (#4)25.16198ms
Hot files Laravel preload (#5)25.03199ms

As we can see all the preload methods produce a similar performance boost, but one thing is sure: you should definitely preload.

How to preload is up to you, I will probably stick to the Preloader class from Brent (Sticher.io) because it's easier to read and maintain.

Staging benchmarks

The real test: how does this impact on a staging environment? I released this for our company in our staging environment using the "Refined preload + vendors" method. Our APIs are running on K8S and I took some before-after benchmarks. Below you can see the results; doing our math, we gained ±10.5% req/s and ±9.5% time/request.

Before preload:

Requests per second:    12.22 [#/sec] (mean)
Time per request:       409.068 [ms] (mean)
Time per request:       81.814 [ms] (mean, across all concurrent requests)

After preload:

Requests per second:    13.51 [#/sec] (mean)
Time per request:       370.110 [ms] (mean)
Time per request:       74.022 [ms] (mean, across all concurrent requests)

Summary:

Req/sTime/req
No preload12.22409ms
Preload13.51370ms

Will be updated soon with Production benchmarks!