Fine-Tune Your OPcache Configuration to Avoid Caching Surprises

Learn more about one of the most important levers for optimal PHP performance. OPcache provides support for byte-code caching of PHP scripts. For a dynamic language such as PHP, a byte-code cache can increase the performance significantly because it guarantees a script is compiled only once. OPcache has been bundled with PHP as a loadable extension since PHP 5.5. Starting with PHP 8.5 OPcache is an integral part of PHP and always installed, but not necessarily always enabled.

Normally PHP executes scripts in two steps:

  1. PHP source code is compiled into bytecode
  2. The PHP virtual machine executes that bytecode

OPcache stores compiled bytecode in shared memory so that subsequent requests can skip the compilation step entirely.

The default settings of the OPcache extension already boost PHP performance by a large amount, but you can tune the configuration settings to get even more out of it.

The Three Most Important OPcache Configuration Options

In practice, most performance improvements from OPcache come from configuring three settings correctly:

  • opcache.memory_consumption
  • opcache.interned_strings_buffer
  • opcache.max_accelerated_files

If these are configured properly, you usually achieve around 95% of the possible OPcache performance gains.

A word of caution: This post repeatedly mentions monitoring stats with opcache_get_status(false). Because OPcache uses a unique pool of shared memory for every SAPI, you cannot access the webserver’s statistics from the console. The call has to be made through Apache or PHP-FPM.

Avoid Destructive Caching Behavior When Memory is Too Small

OPcache uses 128 MB of RAM to save the compiled PHP scripts by default and up to 16,229 PHP scripts. This sounds more than enough to store your PHP application scripts, but there are caveats:

  • If your application uses code generation or a PHP file-based cache such as Symfony, Magento or Shopware, then there might be a large amount of scripts that are not part of your source control.
  • If you do validate timestamps and your code changes in production, then the old cache entries are marked as “waste”. This waste adds to the memory consumption of OPcache.
  • If you use the “new checkout for every release” deployment strategy, then OPcache might cache the same script multiple times in different filesystem locations. This can crowd out the available memory fast.

When OPcache is “full”, under some circumstances it will wipe all cache entries and start over from an empty cache. When this happens and your server gets large amounts of traffic, this can cause a thundering herd problem or cache slam: lots of requests simultaneously generating the same cache entries.

You absolutely want to avoid OPcache to restart with an empty cache.

The algorithm that detects whether OPcache is “full” depends on an interaction between three different INI settings, which is not intuitive and currently not documented. It helped to dig through the C code to understand it.

The three relevant INI variables are defined as follows:

  • opcache.memory_consumption defaults to 128 MB for caching all compiled scripts.
  • opcache.max_accelerated_files defaults to 10,000 cacheable files, but there is a calculation in place that always picks the next specific higher number in a list of possible values. While this is documented, it is counterintuitive. The maximum is 1,000,000.
  • opcache.max_wasted_percentage is the percentage of wasted space in OPcache that is necessary to trigger a restart (default 5).

OPcache works on a first-come, first-serve basis for the cache and does not use an eviction strategy such as Least Recently Used (LRU).

Whenever maximum memory consumption or maximum accelerated files is reached, OPcache attempts to restart the cache. However, if the wasted memory inside OPcache does not exceed the max_wasted_percentage, OPcache will not restart, and every uncached script will be recompiled every request as if there was no OPcache extension available.

This means you absolutely want to avoid OPcache being full.

To find the right configuration, you should monitor the output of opcache_get_status(false) which you can use to check for the memory, waste and number of used cache keys:

  • If cache_full is true and a restart is neither pending nor in progress, that probably means that the waste is not high enough to exceed the max waste percentage. You can check by comparing current_wasted_percentage with the INI variable opcache.max_wasted_percentage. In this case also the cache hit rate opcache_hit_rate will drop below 99%, where ideally it gets as closely as possible to 100%. Solution: Increase the opcache.memory_consumption setting.
  • If cache_full is true and num_cached_keys equals max_cached_keys then you have too many files. When there is not enough waste, no restart will be triggered. As a result, there are scripts that don’t get cached, even though there might be memory available. Solution: Increase the opcache.max_accelerated_files setting.
  • If your cache is never full, but you are still seeing a lot of restarts, that can happen when you have too much waste or configured the max waste percentage too low. Solution: Increase the opcache.max_waste_percentage setting.

To look for inefficient restart behavior, you can evaluate the oom_restarts (related to the opcache.memory_consumption setting) and hash_restarts (related to opcache.max_accelerated_files). Make sure to check when the last restart happened with last_restart_time statistic.

To find a good value for opcache.max_accelerated_files you can use this Bash one-liner to get the number of PHP files in your project:

$ find project/ -type f -iname "*.php" | wc -l
    45311

Make sure to account for code-generated, Composer vendor and cache files or multiple applications running inside the same FPM worker pool.

Increase Interned Strings Buffer

When parsing PHP files that contain hardcoded strings, OPcache converts them to interned strings in memory. Meaning that a string "foo" in the code used at different locations and executed in different requests at the same time all point to a single place in memory. This reduces the memory overhead of PHP massively. However, the interned strings buffer is only 8 MB large by default. For applications using frameworks, it makes sense to increase this significantly to 32 MB or 64 MB:

opcache.interned_strings_buffer=64

Interned strings include many elements of PHP code, such as class names, function names, constants, or string literals. Reusing these across requests can significantly reduce memory allocations during execution.

Note that the interned strings buffer is allocated from the total OPcache memory pool. If you increase this value significantly, you also need to increaseopcache.memory_consumption. These two variables are linked.

You can find information on this in the Tideways app and get recommendations on if you should change variables or if you are fine as it is.

Avoid Unnecessary Filesystem Calls by Disabling Timestamp Validation

With the default settings, whenever a PHP file is executed (for example, through include or require) OPcache checks the last time it was modified on disk. Then it compares this time with the last time it cached the compilation of that script. When the file was modified after being cached, the compile cache for the script will be regenerated.

This validation is not necessary in production when you know the files never change. To disable timestamp validation, add the following line to your php.ini:

opcache.validate_timestamps=0

Disabling timestamp validation also avoids filesystem stat calls during script execution. In practice this can improve performance by roughly 1–3%, depending on the type of application.

With this configuration, you have to make sure that during a deployment, all the caches get invalidated. There are several ways to ensure this happens:

  • Restart PHP FPM or Apache – The sledgehammer method of invalidating files in OPcache has the downside that it might lead to aborted requests and a very small amount of time when requests get lost.
  • Calling opcache_reset(), which is tricky because it has to be called in a script executed by Apache or PHP-FPM to affect the webserver’s OPcache. You can add a special endpoint to your application and secure it using a secret hash.
  • Changing the document root and reloading the Nginx webserver configuration. Nginx completes all the currently running requests and starts new workers in parallel that serve requests with the new configuration.
  • For Apache, Rasmus has a blog post detailing their approach at Etsy.

Be careful with the last two approaches because using them results in making the available OPcache memory “full” very fast. See the previous section for more information on how memory configuration works.

For both cases you might need a maintenance script that calls opcache_invalidate($file, true) on all scripts in old deployments or opcache_reset() directly. Otherwise, old deployments crowd out the cache for new ones, and you might end up with a full cache and an unaccelerated application.

Advanced OPcache Optimizations

OPcache Preloading

We have a separate article on OPcache Preloading. There is also a video on the topic on our YouTube channel.

OPcache on the Commandline

By default, OPcache is disabled for CLI scripts, the since the INI setting is opcache.enable_cli=0 by default.

This is intentional because each CLI invocation runs in its own process and cannot share cached bytecode. In fact each of them need to allocate the memory for OPcache over and over again.

Enabling OPcache can be beneficial for long-running CLI workers, as enabling OPcache will also enable the bytecode Optimizer that can make the the script run faster.

However, this requires additional memory, and if you’re running a lot of workers or cron jobs in parallel, you get closer to the maximum memory of the server.

Therefore, often it does not make sense to enable OPcache for the CLI.

OPcache File Cache

OPcache can also store compiled bytecode on disk using the file cache feature.

This can help reduce cold start times in environments where PHP processes start frequently. This makes sense for use-cases like PHP on AWS Lambda using Bref.

You can enable the file cache with the following INI setting:

opcache.file_cache=/path/to/cache/

Now, PHP 8.5 introduced the setting:

opcache.file_cache_read_only=1

which allows using the OPcache file cache on read-only file systems, making it easier to use in container images.

Configuring OPcache in FPM Pool file does not work

A common configuration mistake occurs when OPcache memory settings are configured inside PHP-FPM pool configuration:

php_admin_value[opcache.memory_consumption]=256

This does not work as expected.

OPcache memory is allocated when PHP starts, before PHP-FPM pool configuration is applied.

As a result, phpinfo() may show the changed value while the actual allocated memory still uses the default.

Always configure OPcache memory settings in:

php.ini

Starting with PHP 8.5, PHP will emit a warning in such situations, making this issue easier to detect.

Joining The Threads Together

Everything together gives you the following php.ini settings file:

opcache.memory_consumption=256
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=100000
opcache.validate_timestamps=0

; for cases with cold start or containers
opcache.file_cache=/path/to/cache/

Conclusion

Even though OPcache already provides a significant boost in PHP performance, you can still improve on it. This blog post expands on the information available in the PHP documentation on opcache_get_status and the opcache configuration.

By tuning the most important configuration settings and monitoring OPcache statistics, you can ensure that scripts are consistently served from cache and avoid unexpected performance issues.

Time to get started with Profiling! With our free trial.