80 WordPress sites, one OPcache

Hosting our clients' websites means taking on technical challenges every day. Today I'd like to share one of them, which led us to build a patch for PHP that is currently being tested on our infrastructure.

Case study · PHP 8.4 · OPcache

On a shared hosting server, 80 PHP 8.4 sites were using about 3 GB of opcode cache, even though 80% of their files are identical. We wrote an OPcache patch that stores identical bytecode only once, whatever the file path, then measured it under load, over two runs of 500,000 requests.

Vitalyn · October 2026 · PHP 8.4.26, opcache-dedup patch

site001/wp-includes/formatting.php:4751
esc_html: ; (lines=16, args=1, vars=2, tmps=1)
0000 CV0($text) = RECV 1
0001 INIT_FCALL 1 160 string("wp_check_invalid_utf8")
0002 SEND_VAR CV0($text) 1
0003 V2 = DO_UCALL
0004 CV1($safe_text) = QM_ASSIGN V2
0005 INIT_FCALL 2 192 string("_wp_specialchars")
...

The same file on a second site:

site002/wp-includes/formatting.php:4751
esc_html: ; (lines=16, args=1, vars=2, tmps=1)
0000 ... 0015 : same opcodes, same literals
→ a single block in shared memory

Specific to each site: filename, scope,
__FILE__, error traces
...
Metric Change Detail
OPcache memory -46.5% 6,406 MB → 3,428 MB
PHP-FPM RAM (peak PSS) -18.6% 6.55 GB → 5.34 GB
CPU per request +0.8% 170.3 ms → 171.7 ms, within noise
Error responses 0 out of 1,000,000 requests, 0 worker crashes

The problem: OPcache keys by path

OPcache compiles each PHP file to bytecode and keeps it in shared memory. That memory is created by the PHP-FPM master and inherited by all its workers, including those of pools running as different Linux users. This is the usual setup on shared hosting: one master per PHP version, one pool and one user per site.

The cache key is the file path. /home/site001/wp-includes/formatting.php and /home/site002/wp-includes/formatting.php are two separate entries, even when the files are byte-for-byte identical. With 80 copies of WordPress and the same plugins, the same bytecode is stored 80 times.

Hashing the source file is not enough: a file's bytecode contains its own path. __FILE__ and __DIR__ are resolved at compile time, and every function carries the name of its file for errors and traces. Two copies of the same file therefore never produce fully identical bytecode. Their functions, on the other hand, almost all do.

The patch: one hash per compiled function

The patch adds a directive, opcache.enable_dedup. When OPcache copies a compiled function into shared memory, it computes a fingerprint of its opcodes and literals. If an identical block already exists, the function points to it instead of storing a copy.

site001 · esc_html()  (filename, scope, variables, arg_info, try/catch)
site002 · esc_html()  (filename, scope, variables, arg_info, try/catch)
site080 · esc_html()  (filename, scope, variables, arg_info, try/catch)
        │
        └──► Shared block: opcodes + literals, stored once, referenced by all three functions
  • Never shared on the hash alone. A matching fingerprint is always confirmed by a full comparison of the block. A collision, whether accidental or crafted by a customer, cannot make one site run another site's code.
  • Nothing changes for isolation. Scripts are still looked up by path, with the native checks (opcache.validate_permission, validate_root). The shared block is never addressable from outside.
  • Each site keeps its identity. __FILE__, exception traces and Reflection still belong to the site. A block that depends on the path naturally differs and is not shared.
  • No new lock. Everything happens during persistence, which is already protected by OPcache's write lock. At runtime, workers only read.
  • Instant kill switch. With opcache.enable_dedup=0, no structure is allocated and OPcache behaves exactly as stock.

The protocol

A Docker container, itself inside a Debian LXC container on a Proxmox host, capped at 10 vCPUs and 18 GB. Two identical runs, apart from one configuration line, on the same patched PHP 8.4.26, built in release mode.

Item Configuration
Sites 80 WordPress installs: WooCommerce, Yoast SEO, Contact Form 7 and Elementor active, 12 products and 10 posts each.
Versions 64 sites (80%) on the up-to-date stack, 16 on an older stack (WordPress 6.5.5, WooCommerce 9.0).
Isolation One Linux user, one PHP-FPM pool (ondemand) and one MariaDB database per site, a single FPM master. The PHP files are real per-site copies, with opcache.validate_permission=1.
OPcache 8 GB of shared memory, 256 MB of interned strings, validate_timestamps=1, JIT disabled. Empty cache at the start of each run.
Load h2load through nginx, 32 connections, 500,000 requests per run, the same sequence replayed in both runs. Mix: 35% home page, 25% posts, 15% product pages, 10% shop, 10% REST API, 5% search, spread randomly across the 80 sites.
Measurements Every second: PHP-FPM and MariaDB CPU (/proc), worker PSS, OPcache memory (opcache_get_status()). Latency of every request, client side.

Memory: -46.5% cache, -18.6% RAM

Without dedup, OPcache uses 6.3 GB at the end of the run for 252,812 cached scripts. With dedup, it uses 3.3 GB for almost the same number of scripts (252,673): 2.9 GB of opcode copies were avoided, i.e. 2.4 million functions pointing to an already stored block.

The RAM actually used by PHP-FPM, measured as PSS (shared memory is counted once), drops less in proportion, because each worker keeps its private per-request memory: -18.6% at peak, from 6.6 to 5.3 GB. The 1.2 GB saved is memory handed back to the system.

Our test stack is heavier than the original production one: WooCommerce, Elementor and Yoast active on every site, hence 6.3 GB instead of about 3 GB. With the same proportion of shared code, those 3 GB should drop to around 1.6 GB.

OPcache memory used: stock PHP 8.4 vs with dedup
OPcache memory used. opcache_get_status(): memory_usage.used_memory, interned strings included.
Total RAM of PHP-FPM processes: stock PHP 8.4 vs with dedup
Total RAM of PHP-FPM processes. Sum of the PSS of all workers: shared memory is counted once.

CPU and response times: unchanged

The server runs at saturation in both runs (about 9 cores out of 10): this is deliberate, so that any overhead shows up in throughput. At runtime, nothing changes for the workers: they read the same opcodes, just at a different address.

Over the whole run, CPU per request goes from 170.3 to 171.7 ms (+0.8%) and throughput from 52.2 to 51.7 req/s. Sliced into windows, the steady-state difference stays within each run's own variance: 167 to 173 ms per request in both cases, with the same slow drift caused by the databases growing. Median latency is identical (523 vs 522 ms). The p99 varies from 1,775 to 1,920 ms from one window to the next in the dedup run, which covers the overall gap (1,853 vs 1,933 ms).

The only real cost shows up at cold start, when everything is being compiled: during the first 5 minutes, 41 req/s with dedup vs 46 without. This cost comes from the fingerprints computed at compile time, and the optimization described below has since reduced it.

CPU used by PHP-FPM: stock PHP 8.4 vs with dedup
CPU used by PHP-FPM. Average cores used over 30 s, workers and master.
Throughput (req/s): stock PHP 8.4 vs with dedup
Throughput (req/s). Completed requests per second, averaged over 30 s.
Median latency (p50): stock PHP 8.4 vs with dedup
Median latency (p50). Per 30 s window.
p99 latency: stock PHP 8.4 vs with dedup
p99 latency. Per 30 s window.
Latency by percentile, over the whole run: stock PHP 8.4 vs with dedup
Latency by percentile over the whole run, measured by h2load (ms).

All the numbers

Measurement Stock PHP 8.4 With dedup Change
Requests 500,000 500,000
Run duration 159.6 min 161.3 min +1.0%
Average throughput 52.2 req/s 51.7 req/s -1.0%
OPcache memory at end of run 6,406 MB 3,428 MB -46.5%
of which interned strings 155 MB 155 MB 0.0%
Cached scripts 252,812 252,673
Memory saved by dedup - 3,018 MB
PHP-FPM RAM, peak PSS 6,711 MB 5,466 MB -18.6%
Total PHP-FPM CPU 1,418.8 CPU min 1,430.6 CPU min +0.8%
PHP-FPM CPU per request 170.25 ms 171.68 ms +0.8%
Average latency 612.9 ms 619.2 ms +1.0%
p50 latency 523.3 ms 521.9 ms -0.3%
p95 latency 1,285.5 ms 1,328.9 ms +3.4%
p99 latency 1,853.0 ms 1,933.0 ms +4.3%
200 responses 500,000 500,000
Error responses (non-200) 0 0
Workers killed by SIGSEGV 0 0

Cold compilation: -45% right from loading

A second bench isolates compilation: each site compiles all its PHP files (about 9,000) through its own pool, 8 sites in parallel, without serving any pages. It measures the maximum footprint, that of a cache where all installed code is loaded. CPU time varies too much from run to run on a shared host to be compared here. It is measured further down in an isolated process.

Sites Stock PHP 8.4 With dedup Change Shared blocks
10 1,818 MB 1,167 MB -35.8% 78.7%
30 4,857 MB 2,667 MB -45.1% 84.4%

Each additional site costs about 75 MB instead of 152 MB. The gain therefore grows with the number of identical sites and tends toward 50%. What remains is mostly class structures (method, property and constant tables), which contain the file path and cannot be shared without modifying the Zend engine itself (see Going further).

Optimization: computing each fingerprint only once

OPcache copies a script into shared memory in two passes: the first computes the size to reserve, the second does the copy. Both must make exactly the same sharing decisions, otherwise the reserved size is wrong. The first version recomputed each function's fingerprint in each pass, i.e. three to four serializations per function.

The final version remembers the decision from the first pass and applies it as is in the second. Each function is now serialized only once, the second pass never re-reads the compiled code, and table entries shrink from 40 to 32 bytes. Measurement: one process compiling three complete sites, seven alternating repetitions.

Version Compilation CPU (average) Change vs stock Shared functions
Stock PHP 8.4 7,742 ms - -
Dedup, load bench version 8,439 ms +9.0% 110,665
Dedup, final version 7,912 ms +2.2% 110,665

This cost only applies to compilation, so to the first few minutes after a restart or a file update. Once the cache is warm, it is zero.

Confirmation run with the final version, under the same load: 60,000 requests across the 80 sites, 100% 200 responses, no dead workers. OPcache uses 3,379 MB for 248,780 scripts, including 2,978 MB of avoided copies: sharing is practically the same as with the load bench version.

How we checked that nothing breaks

  • 15 dedicated tests (ext/opcache/tests/dedup/). They pass on PHP 8.2, 8.3, 8.4, 8.5, 8.6 (RC3) and the 8.7 development branch: per-site __FILE__ and traces, literals that differ by one byte or whose hash keys collide, two docroots with the same structure where 20 functions differ by one detail (operator, branch, literal, type...), classes and traits, invalidation, protected memory.
  • PHP's test suite (Zend, tests/, OPcache: 6,437 tests) rerun with dedup forced on and opcache.protect_memory=1: no regressions.
  • Valgrind and AddressSanitizer on the tests, on PHP's OPcache suite and on compiling two complete WordPress sites in the same process: no memory errors, no leaks attributable to the patch. A known limitation of both tools: they do not instrument the inside of shared memory, which OPcache carves up itself.
  • Differential fuzzing. Thousands of random but deterministic PHP programs are compiled as two identical copies (to force sharing) and then run, with and without dedup. The output must be identical in both modes, and the dedup run must actually have shared code. 3,000 iterations under AddressSanitizer and thousands more without: no divergence, no crash. This is the fuzzing requested during the patch review, at the level that matters here (behavior and memory outside SHM).
  • Both load bench runs: almost the same number of cached scripts, no dead workers, same response codes.
  • Isolation between tenants, compared with stock. Two sites, two Linux users, one master, four runs (dedup off and on, with and without opcache.restrict_api). Identical code is shared, then each site modifies its copy: the other never sees the change, because a shared block is never modified or freed. Reading another site's file, through a symlink or by path, is denied both before and after it is cached. The OPcache API called from a site (status, invalidation, reset) is denied with restrict_api and allowed without it, exactly as in stock PHP. The outputs of the dedup runs are identical to those without. On top of that, the table key is randomly salted and any key match is confirmed by a full comparison: one test forces collisions and checks that none of them is shared.
  • Error logs, site by site. The same library, dropped onto all 80 sites, triggers a warning, an undefined variable, an exception, a call to a nonexistent function and a division by zero, then prints __FILE__, __DIR__, __LINE__, __CLASS__, __METHOD__, the traces and Reflection. Result: 480 requests per mode and 1,680 paths checked. Every log line names the file and line of the calling site, every worker runs as its site's user, and the output is byte-for-byte identical to stock PHP's. Functions containing __FILE__ are not shared. Those that are, such as the one raising the errors, still report the right site, because the path comes from the calling function and not from the shared block.

The first bench found a real bug, invisible in the unit tests. Under load, in an optimized build, workers were dying with SIGSEGV. A constant array shared between two functions of the same file was freed by OPcache after the first one was copied, then re-read by dedup for the second one. In the final version, the copy pass never re-reads the compiled code: it applies the decisions made during the sizing pass, which frees nothing. A test reproduces the case, and Valgrind confirms there are no invalid accesses.

The real question: can code be injected into a neighbor?

Since the esc_html() function of 80 sites now points to a single block in memory, the question that matters is: can a compromised site use it to run its code on the 79 others? We walked through the complete attack chain (enumerate, target, inject) against the OPcache code. It breaks at the second step for an attacker who only has PHP.

The distinction that governs everything else: a PHP attacker runs code as their own user, within the engine's rules. A native attacker has already achieved machine code execution in a worker (a PHP memory bug, a vulnerable extension, FFI). The latter can already read and write everything in shared memory, with or without this patch: the question only makes sense at the PHP level.

1. Listing other sites' cached files: yes, but that is stock OPcache

opcache_get_status(true) walks the master's whole table and returns the path of every script of every site. This behavior exists without the patch, and opcache.restrict_api shuts it off. Dedup only adds global counters (shared blocks, memory saved): no paths, no per-site data. Enumeration is therefore not made any worse.

2. Linking a file to the opcodes of a specific function: no

This is the step that blocks. No PHP function returns opcodes: neither the OPcache API (seven functions, all reviewed, none of which returns bytecode) nor Reflection. The deduplication table is not exposed to PHP: it cannot be enumerated or queried, and there is no way to know which sites share a block. And just to bring a neighbor's compiled code into your own process, you would have to include their file, and therefore have the right to read it, which Unix permissions and validate_permission forbid. From PHP, there is no way to locate "the neighbor's esc_html() block".

3. Patching the opcode to inject code: not at the PHP level

No PHP function writes opcodes. And even when two sites point to the same block, that memory is immutable: opcodes are read-only on the engine side, and literals (including constant arrays) are flagged immutable when cached. A site that modifies a constant value it received modifies a private copy, never the shared one. Static variables and runtime caches are not shared at all. A test proves it: two sites share a function that returns a configuration array, one hammers that array in every possible way (overwrites a password, adds keys, writes by reference), and the other always gets the original value intact.

At the native level, injection becomes possible again, but it already was without the patch, since shared memory is writable by any worker. What dedup changes in that case is amplification: patching a shared block hits every site sharing it in a single write, instead of one site at a time. That is an argument for capping the number of sites per master, not for increasing it.

Conclusion. For a PHP attacker, the chain breaks at step 2: they can list paths (stock behavior, to be shut off with restrict_api), but cannot read opcodes, link them to a function, or write them. Steps 2 and 3 require native execution, which already gives full access to shared memory, dedup or not. The patch's only contribution to the risk is concentrating the target once that native access is obtained. Hence the two safeguards: mandatory restrict_api, and masters of 15 to 20 sites with equivalent trust levels rather than one master for an entire fleet.

Setup

The patch fits in one file, zend_accelerator_dedup.c, plus about forty lines of hooks in OPcache. It is available for every PHP 8 branch, ready for git am.

# 1. Apply the patch to the sources of the deployed version
git clone --depth 1 --branch php-8.4.26 https://github.com/php/php-src.git
cd php-src && git am /chemin/vers/patches/php-8.4.patch

# 2. Build with the distribution's usual options
./buildconf --force && ./configure ... && make -j"$(nproc)" && make install

# 3. Enable, then restart PHP-FPM (PHP_INI_SYSTEM directives)
opcache.enable_dedup = 1
opcache.jit = disable      ; "off" leaves the JIT available and blocks dedup

# 4. Monitor the effect
php -r 'print_r(opcache_get_status(false)["dedup"]);'
#    => 1   => ...   => ...   => ...
  • A single FPM master. Dedup shares the memory of a single master. If each site has its own master, there is nothing to pool.
  • Automatically disabled if opcache.file_cache is set or if the JIT is available, with a warning in the OPcache log. Watch out: opcache.jit=off, the default in the Sury packages, leaves the JIT available at runtime and is enough to disable dedup. You need opcache.jit=disable.
  • Instant rollback: opcache.enable_dedup=0 then a PHP-FPM restart, no recompiling needed.
  • What stays specific to each site. A function is shared if its bytecode does not contain the file path. __FILE__ and __DIR__ are frozen at compile time, like the other magic constants (__LINE__, __CLASS__, etc., which, however, are identical from one copy to the next). User constants are not: with const ROOT = __DIR__ . '/www'; or define(), only the declaring file contains the path, and every function that uses ROOT reads the constant by name at runtime, so it stays shareable. Two pitfalls: __DIR__ as a parameter's default value, and a self::CONSTANTE built with __DIR__, which the optimizer copies into the method. That is why WordPress, which goes through ABSPATH almost everywhere, is 92% shared: code blocked by the path accounts for only 6.5% of its bytecode.
  • Statistics. The opcache_get_status() counters can reveal that another site runs the same code. As with the existing OPcache statistics, opcache.restrict_api restricts them to the administrator.

Going further: what the patch does not share, and why

The patch shares the opcodes and literals of every function, class methods included. That is about 58% of a site's memory, and most of the measured gain. The rest is duplicated on purpose: the goal was a lightweight, reliable patch, confined to the step where OPcache copies compiled code into shared memory. It does not touch the Zend engine, execution or the JIT, and with opcache.enable_dedup=0 the behavior is strictly the original one.

Each avenue below crosses that boundary: it requires changes to PHP's core, so a discussion with its maintainers and sometimes an RFC. The idea can be taken further, but that was not the purpose of this patch.

What remains duplicated Why the patch leaves it alone What it would take
Class structures: zend_class_entry, method, property and constant tables, default values. About 31% of a site's memory: the biggest lever left. A class carries the identity of its script (file, lines, comments) and links specific to how it was loaded: parent class, interfaces and traits, resolved at link time. Part of it is also modified at runtime: constants and default values evaluated on first use, static properties, inheritance cache. The opcodes of the methods, however, are already shared. Separate the immutable part of a class from its script-bound part, and make the engine go through an indirection for the latter. This touches inheritance and class persistence in Zend: it is the natural next step, but it belongs to the engine, not to OPcache.
Function headers (zend_op_array), arg_info, variable names, live ranges, try/catch blocks. Most of the remaining ~11%. Small per-function structures that point to their script's file, scope and runtime cache. arg_info and variable names are nonetheless identical from one copy to the next. Sharing arg_info and variable names, which are already immutable, is the simplest first step. Modest gain: left out of the first patch to keep its surface minimal.
Code containing the file path: __FILE__, __DIR__, internal class declaration keys. 3.8% of a site's memory (6.5% of WordPress's bytecode). The compiler freezes these values into the bytecode: two copies of the same file therefore differ by construction, and the patch only shares what is identical. Two avenues: path-free declaration keys, doable in OPcache alone (~1.8% of opcodes), and __FILE__/__DIR__ resolved at runtime, which requires a compile option and a new opcode, hence an RFC. Together, the gain would go from about -46.5% to -49%.
The JIT: incompatible, dedup disables itself if it is available. The JIT rewrites in place, in shared memory, the instruction handlers with machine code specific to one function: its scope, its constants, its cache. If those instructions were shared, one site would run code compiled for another. Have the JIT work on a private copy of the instructions of hot functions only. That is a change to the JIT. On WordPress-style sites, its gain is small anyway.
The file cache (opcache.file_cache): incompatible. Its format serializes each script in isolation and cannot represent a pointer to another script's block. A file cache format that supports cross-script references.
Sharing across FPM masters. Each master has its own shared memory, and that separation is also an isolation boundary. A segment shared by several masters. Not desirable: it is the opposite of the safeguards argued for above (masters of bounded size).
32-bit builds. Operands there are absolute addresses, specific to each copy. Nothing planned: a marginal platform for this use case.

Why stop here. Opcodes and literals deliver most of the gain: -46.5% measured, and a gain that levels off from a few dozen sites. Each further step would add rules to the engine that the rest of PHP would have to follow (immutability, indirect addressing), and a review surface out of all proportion with this patch. A first patch confined to OPcache, off by default and verifiable end to end, is the prerequisite for discussing what comes next. For maintainers, the measurements above give the order of magnitude of each step. The most cost-effective one is the immutable part of classes.

What's next

The patch is written as a contribution to PHP: php-src coding conventions, tests in phpt format, UPGRADING and php.ini entries. It targets the development branch, the only one where the PHP project accepts new features. Versions 8.2 to 8.6 are backports you apply yourself (8.0 and 8.1, which are end of life, are no longer provided).

For now, the patch remains private. We want to keep auditing it internally for a while, run it in dev and potentially in prod, and then, if the patch meets our expectations, we will decide on a contribution to PHP, to work with the maintainers on integrating it into a future version. If it is accepted, we will make the patch available so that current versions can benefit from it too.


  • Measurements taken on October 19 and 20, 2026 in a 10 vCPU Docker container.

Have a web project in mind?

Website design, e-commerce or SEO: our web agency in Paris, Besançon and Normandy can help.

Free quote

Related articles