PHP hosting: simple setup for opcache and adjacents

Published:  11/04/2023 16:00

Introduction

In our experience, PHP tends to bottleneck at CPU level because of site engines pretty much rebuilding themselves entirely at every request.

PHP extensions are usually compiled to machine code (i.e. instructions that the specific targetted microprocessor understands) but PHP scripts aren't.

The advantage of that state of affair is that a developer can easily replace a single PHP script live on a production server and it gets immediately used on the server without having to recompile or restart anything.

With time passing people seem to care less about that advantage. Some even see it as a risky behavior and would prefer relying on a CI/CD step that looks like compilation anyway.

The platform solution: opcache

Some big PHP users like Facebook were already taking huge steps to compile most of their PHP codebase, either by putting as much as possible in extensions (which are compiled, as mentioned earlier) or using full on compilation engines.

Something less hacky and more portable was implemented at some point to help with high processor usage of big PHP apps: Opcache.

It became part of PHP starting with PHP 5.5 and upwards, and doesn't compile to machine code (like extensions) but to bytecode, a more portable instruction set, very close to machine code but at a slightly higher level to provide easy portability and flexibility as PHP is a multi-platform interpreter.

Opcache is disabled by default, because it's adding complexity and is unavoidably contrarian to the "drop in a new PHP file and it works immediately", compile-free philosophy as we'll highlight later on.

Enabling opcache

You need to edit the php.ini file related to how you serve PHP.

On most Linux distributions, php.ini files are somewhere in /etc.

For instance, on Arch, they're in:

/etc/php<VERSION>/php.ini

Or, more commonly:

/etc/php/<VERSION>/<RUN_MODE>/php.ini

For the whole Debian family, where RUN_MODE is how you're serving or using PHP, with, for instance:

  • cli — The config within is applied for command line invocations of the interpreter. It's actually interesting to have opcache enabled there too for projects such as Symfony making heavy use of console script invocations;
  • fpm — The way for most web servers to run PHP, passing script execution to a managed pool of FastCGI processes; Nginx has to use this mode;
  • apache — Specific to the Apache PHP module, though Apache can also use FPM (and we'd recommend going that route).

The location of php.ini on Windows will depend on where and how you installed it. Which is somewhere alongside XAMPP when using that project, or something like C:\Program Files\php for a standard PHP installation.

The file itself is usually quite large and well annotated. You'll have to look for a section starting with [opcache], all of the related options should be commented out below (lines starting with ";").

If you just uncomment the "enable" line (the CLI one can be enabled too) and restart whatever PHP is running on — Apache or FPM most of the time — You'll have a basic opcache config running that should already boost your performances as it is:

[opcache]
; Determines if Zend OPCache is enabled
opcache.enable=1

; Determines if Zend OPCache is enabled for the CLI version of PHP
opcache.enable_cli=1

To apply the changes when using FPM:

systemctl reload php<VERSION>-fpm

Or, for the Apache module:

systemctl restart apache2

These actions also clear the opcache entirely so that everything has to be compiled anew.

Incidentally it also happens when FPM processes are reloaded due to log rotation, so you probably have a cache clear happening daily unless you changed the log rotation behavior for FPM.

In our experience it's perfectly fine. The opcache rebuilds itself quite quickly and without much impact on the system.

Showing the opcache status

You can easily check if opcache is working by querying its status.

Since the most important area for opcache to cover is web-facing scripts, we need to query its status through the Apache module or FPM.

An easy way that works in both cases is to place a small PHP page somewhere with content as follows:

<?php

header("Content-Type: text/plain");

print_r(opcache_get_status());

echo "----------\n";

print_r(opcache_get_configuration());

?>

Where we output the status first, and the current config at the end (always useful to see whether changes were applied or not).

The beginning contains the most important metrics:

Array
(
  [opcache_enabled] => 1
  [cache_full] => 1
  [restart_pending] => 
  [restart_in_progress] => 
  [memory_usage] => Array
    (
        [used_memory] => 1018929688
        [free_memory] => 16
        [wasted_memory] => 54812120
        [current_wasted_percentage] => 5.1047764718533
    )

  [interned_strings_usage] => Array
    (
        [buffer_size] => 6291032
        [used_memory] => 6291032
        [free_memory] => 0
        [number_of_strings] => 95854
    )

  [opcache_statistics] => Array
    (
        [num_cached_scripts] => 33272
        [num_cached_keys] => 45256
        [max_cached_keys] => 65407
        [hits] => 382919949
        [start_time] => 1680685830
        [last_restart_time] => 0
        [oom_restarts] => 0
        [hash_restarts] => 0
        [manual_restarts] => 0
        [misses] => 37884713
        [blacklist_misses] => 0
        [blacklist_miss_ratio] => 0
        [opcache_hit_rate] => 90.997078592252
    )

    // Rest of the response was cut here

We'll discuss these stats later on but we can immediately see the cache is currently full, and the hit rate is 90%.

Ideally you'd want the hit rate to approach 99% which might require increasing the amount of memory reserved for opcache, or other parameters we'll be looking at shortly.

The well known phpinfo() function also shows opcache stats under the "Zend OPcache" header though it doesn't include some information like the hit rate.

Configuring opcache

Let's consider the following settings:

; Determines if Zend OPCache is enabled
opcache.enable=1

; Determines if Zend OPCache is enabled for the CLI version of PHP
opcache.enable_cli=1

; The OPcache shared memory storage size.
opcache.memory_consumption=128

; The amount of memory for interned strings in Mbytes.
opcache.interned_strings_buffer=8

; The maximum number of keys (scripts) in the OPcache hash table.
; Only numbers between 200 and 1000000 are allowed.
opcache.max_accelerated_files=15000

; The maximum percentage of "wasted" memory until a restart is scheduled.
opcache.max_wasted_percentage=10

; When disabled, you must reset the OPcache manually or restart the
; webserver for changes to the filesystem to take effect.
opcache.validate_timestamps=1

; How often (in seconds) to check file timestamps for changes to the shared
; memory storage allocation. ("1" means validate once per second, but only
; once per request. "0" means always validate)
opcache.revalidate_freq=60

Our commentary on these options follows.

memory_consumption

Maximum memory amount that opcache is allowed to use in megabytes.

It actually defaults to 128 if you leave the option commented out, so it's not really doing anything to explicitely set it to 128 but it's good to know where you're at.

When running PHP through Apache or FPM, there will always be only one opcache memory pool contrary to what some people believe.

Having multiple FPM pools doesn't make a difference: one opcache memory pool is attached to the FPM master process.

However, if you have multiple FPM master processes, for instance for multiple PHP versions, each one of these will have its own opcache pool.

We recommend looking at the opcache status and increase memory_consumption when needed, which is apparent when [cache_full]=>1 is present and num_cached_keys isn't close or equal to max_cached_keys — because in that case you need to increase another config option described below.

The only downside to increasing memory_consumption is that it's eating memory you may need for something and can cause swapping or even application crashes on some operating systems.

That reason alone is enough for me to not advise activating opcache on modest servers with memory <=2GB.

We touched on a few ways to check for free memory on linux systems in a previous article about Linux performance measurements.

interned_strings_buffer

How much memory is reserved for reusable strings, in megabytes. PHP will reuse the same place in the opcache memory pool for strings that are identical.

Of course it'll need to duplicate the string in memory if it gets modified, but in many cases we're using a lot of the same string values in read only.

Defaults to 8MB and has to be taken out of the configured memory_consumption option so it's a good idea to increase that setting when you're increasing interned_strings_buffer.

I tend to leave it on default for large shared servers.

It's hard to know how much increasing the strings pool would help your use case but if your hardware has a lot of available memory, you should increase it to 64 (alongside a memory_comsumption of at least 192).

max_accelerated_files

Indication on the minimum amount of possible keys in the hash table opcache uses to store bytecode for individual PHP scripts.

Defaults to 10000. The actual amount displayed in opcache stats might be higher due to the hash table algorithm only allowing some specific sizes, you might consider the setting as a "minimum value".

You can see how many keys are in use in the hash table in the opcache statistics:

[num_cached_keys] => 45256
[max_cached_keys] => 65407

In this instance, the max was set to 50000 but is rounded to a nearby specific prime number that is equal or larger than the configured value (65407 in this case).

On small, single-application servers, the default of 10000 is more than enough. It'll have to be increased to 15000 or 30000 for larger shared servers.

When opcache has reached its maximum of cachable scripts it'll stop caching new entries, even if it still is allowed to use more memory by the memory_consumption value. As a result, looking at the cache_full statistic to determine whether opcache can still help or not is deceiving.

Having a lot of scripts in the hash table makes looking up entries more complicated. However, realistically, I don't believe it has any impact on a modern system. You still may want to do some load testing before using values larger than 50000.

There is a hard maximum of 1 million.

max_wasted_percentage

Maximum amount wasted memory allowed in the opcache memory pool before triggering a full clean up and restart of opcache.

It defaults to 5. Having a low value tends to trigger restarts too often but it's more memory efficient.

It's common to set it to 10 or 15.

The current wasted memory percentage metric appears in the opcache statistics.

validate_timestamps

Takes a value of 0 or 1 for disabled or enabled, with 1 being the default.

Enabling timtestamp validation will have PHP check the file modified timestamp on requested cached PHP scripts at certain intervals (see next setting) and recompile the script when it's been modified.

Setting validate_timestamps to 0 will result in compiled PHP scripts staying in the opcache hash table forever (until an opcache restart), even if the source script has been modified in the meantime.

We said in the intro that opcache would have to interfere with PHP's advantage of easily dropping in an updated file and having it work immediately. This is it.

Enabling validate_timestamp makes it less of an issue, depending on how large the revalidate_freq is.

On a production server which codebase isn't updated often, you can safely disable validate_timestamp to get maximum performance, and just reload FPM or Apache manually after a successful deployment.

revalidate_freq

Determines how often to check script modified date to determine when a script needs to be recompiled.

The value is in seconds and defaults to 2.

With such a low value the classic behavior of PHP is mostly preserved and developers can drop in new versions of PHP files using FTP or another file service and have the new script almost immediately put to use.

However, checking for file modifications every 2 seconds on a server that sees heavy traffic will tank performance, considering modern PHP applications have a complex include tree spanning through a lot of different scripts that probably almost never change (e.g. the database or template engine).

Since we wanted opcache to improve performance, it's a good idea to increase revalidate_freq to at least 30 seconds. It's set to 60 in our example.

A value of 0 will cause opcache to check for modifications for every single cache hit. It looks drastic but could work fine on modern hardware with fast, directly attached storage and could be a performance compromise for a development platform/server on which files are getting updated all the time.

The revalidate_freq setting is ignored when timestamp validation is disabled.

As a side note, you might want to remember it's possible to configure a file system to never update the file modified metadata for performance reasons (some database servers use that config). In that case it's useless to even check that value and opcache might not behave as expected.

The case of PHP 8

PHP 8 introduced an experimental JIT to opcache.

Just-in-time compilers are an important organ of all the successful bytecode-based language interpreters (for instance Java or Python).

Their main effect is to compile parts of code that are used often to machine code and keep that in memory for reuse. Small functions make good candidates for just in time compilation, especially if the types of parameters and the return type can be inferred easily.

JIT compilers are quite complex and should obviously be tuned to not make things worse.

The PHP JIT works on top of opcache, requiring it to be enabled. It however has its own memory pool on top of the opcache memory.

Enabling the JIT is done by providing it some memory to use with the jit_buffer_size setting, which defaults to 0.

Do note that values for the setting are considered to be bytes unless you provide a shorthand notation byte value like K for kilobytes or M for megabytes.

The option is usually absent from commented php.ini files, just add it in the opcache section:

opcache.jit_buffer_size=256M

Lower the value to 128M if your server is short on memory.

Some have measured that the JIT doesn't actually always help for real-world applications, so do not feel too bad about disabling it on modest hosting platforms and keep more memory for the opcache pool itself.

This is mostly due to the performance impact of having a lot of database queries and a complex include tree outweighing what the JIT can do.

Some articles and doc pages recommend the following fire-and-forget opcache settings for PHP 8+:

opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=256M

Which will apply all the default opcache settings with timestamp validation and 128MB of memory for opcode storage.

Checking the JIT buffer status

JIT statistics are also present in the output of opcache_get_status(). When printing the returned associative array as text with print_r, you may have to look farther down after the scripts array to find it:

[jit] => Array
(
  [enabled] => 1
  [on] => 1
  [kind] => 5
  [opt_level] => 4
  [opt_flags] => 6
  [buffer_size] => 134217712
  [buffer_free] => 133086736
)

As we can see, the JIT is using very little of its configured 128MB of memory on this server.

We often observe low JIT usage but that may change with your hardware, use case and PHP version. In our case we can safely lower jit_buffer_size.

To be completely thorough you'd have to run load tests to make sure the JIT is actually helping, especially when your hosting environment has limited memory.

Downsides of using opcache

Memory requirements

The main downside is the increased memory usage required to store the opcode.

You'll have to size the opcache memory pool to reach a high hit rate (95%+) but avoid swapping or worse (e.g. having the kernel start killing programs).

Changes to PHP script may not apply immediately

Opcache can be configured to check the modification time of PHP script every single time they run, in which case changes do get applied immediately.

However, production servers benefit from a more conservative policy when it comes to checking file timestamps and one might run into a case where an older version of a script is still running even though it's been updated.

Worst case scenario the entire web server or FPM master process has to be restarted or reloaded to allow opcache to recompile newer versions of the script.

Not meant for fast moving development platforms

We don't recommend enabling opcache on a dev platform, it'll have to recompile all the time due to files changing very often.

At least, if you need opcache on a dev platform, enable validate_timestamp with a revalidate_freq of 0.

Going further

There is more to explore with opcache.

For instance it's possible to manually add entries into it, request a refresh or invalidate entries from PHP itself, use a file backup cache for faster startup of an already running opcache into memory, and there are more niche optimizations we didn't talk about.

You can also preload files manually into opcache.

How much you benefit from the opcache will depend on your use case and hardware. It tends to help significantly in CPU-bound scenarios but won't do much when IO-bound.

Then again, PHP tends to very often get bottlenecked by the CPU and we'd suggest always enabling opcache if you have enough memory to back it up.

Comments

Loading...