What happens when PHP runs your code

A request walks from the SAPI to the Zend VM — lexing, compilation to opcodes, the OPcache lookup that skips both, and execution.

Alden Pike9 min read

Deploy a change, load a page, load it again. The second response comes back faster, on identical code and identical input. Nothing in the application decided that — the gap sits underneath it, in work the engine did once and did not have to repeat.

How much faster is a measurement question, and I have not measured a cold and warm FPM pair on hardware I can describe here; producing that number without fooling yourself is the subject of benchmarking PHP without lying to yourself. The question here is structural: what did the second request skip?

The SAPI boundary

PHP does not run your file. A SAPI does — the command line binary, or under a web server, php-fpm. An FPM worker sits in a loop: take a request over FastCGI, hand the engine the script path and the request environment, run it, flush the response, wait for the next one.

Around that call sit request startup and shutdown. Startup builds the request-scoped world: a fresh memory arena, a per-request hook in each loaded extension, and the superglobals — which under the default auto_globals_jit materialize on first access, not at startup. Shutdown tears the arena down.

“Shared nothing” is the consequence. Your variables, your static properties and the class definitions compiled during a request do not survive into the next one, whatever the worker kept underneath. The process does keep things — it stays alive, its extensions stay loaded, and it maps a region of shared memory that matters more here than the rest.

Source to opcodes

Asked for a file it has not seen, the engine reads the bytes and runs them through three stages. The lexer turns characters into tokens, the parser turns tokens into an abstract syntax tree, and the compiler walks that tree emitting opcodes.

Three stages is a simplification, worth marking as one: the real compiler does more than translate, and some of that work shows in its output. Arithmetic on literal operands is folded during compilation, so 2 ** 16 leaves behind the number and no arithmetic instruction.

The unit of all of it is the file. Compiling one file produces an op array for its top-level code plus one per function and method it defines. Nothing is per-class or per-call: reference one function from a two-thousand-line file and the whole file goes through the compiler.

What OPcache stores, and what it does not

OPcache sits between “the engine is asked for a file” and “the lexer starts”, keyed by the file path as the engine resolved it. On a miss the file is compiled and the result written into a shared memory segment every worker in the pool can read. On a hit the stored op arrays are attached to the request and the lexer, the parser and the compiler are skipped.

Those are the only things skipped. A cache hit does not skip execution. Every opcode in every op array a request touches runs on that request, cached or not, and OPcache holds no opinion about the values those opcodes produce. What the second request avoided was the cost of turning text into instructions, once. Everything those instructions themselves cost is still charged on every request, and the JIT is the feature aimed at that remaining half — what the JIT reaches and what it does not turns out to be a narrower story than the name suggests.

Whether staleness is checked depends on opcache.validate_timestamps. With it on, the engine compares the mtime on disk against the one in the cache entry, at most once per opcache.revalidate_freq seconds, and treats a newer file as a miss. With it off, the entry is trusted until something invalidates it explicitly.

The VM is a loop over opcodes

An op array is a flat sequence of instructions, not a tree. Each instruction carries a handler, up to two operands and a slot for its result, and the virtual machine loops over the sequence: take the next instruction, call its handler, advance. A call pushes an execution frame holding the callee’s variables, and returning pops it.

Operands come mostly in two flavors. A CV is a compiled variable — a numbered slot standing in for a named userland variable, resolved while compiling so no runtime lookup by name is needed. A T is a temporary holding an intermediate result. Both are slots rather than values: sixteen bytes on a 64-bit build carrying a type tag and either a small payload or a pointer to something larger, so an opcode that reads an array element costs whatever the structure behind that pointer costs rather than what the instruction count suggests. Two statements show the shape:

<?php

// total.php

declare(strict_types=1);

$total = (int) $argv[1] + 5;
echo $total, PHP_EOL;
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 total.php 7

The dump goes to standard error, ahead of the script’s own output. Both listings below drop the one header comment naming the file’s absolute path. On PHP 8.5.9 the first reads:

$_main:
     ; (lines=7, args=0, vars=2, tmps=4)
     ; (before optimizer)
     ; return  [] RANGE[0..0]
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T4 = ADD T3 int(5)
0003 ASSIGN CV0($total) T4
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1)

The single line of arithmetic is four instructions: fetch element 1 of $argv into a temporary, cast it, add the literal, store the sum in the slot standing for $total.

That is the compiler’s output, not the cache’s, as the flag name and header comment say. OPcache runs an optimizer over an op array before storing it, so the form the VM walks is what 0x20000 prints:

$_main:
     ; (lines=7, args=0, vars=2, tmps=2)
     ; (after optimizer)
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T2 = ADD T3 int(5)
0003 ASSIGN CV0($total) T2
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1)

Same seven instructions, two temporary slots instead of four. The first listing references three temporaries but declares four, so one was never used at all: the optimizer drops that one and folds the addition’s result back into T2, whose value the cast had already consumed.

Tidying is the least of it. Run the same two flags over a function holding a $base = 5 local, a dead if (false) branch and a declared return type, and ten instructions become four: the constant is propagated into the addition, the unreachable branch and the trailing implicit return are dropped, and the sum is written straight into a compiled variable with no temporary at all.

Autoloading decides how much reaches the compiler

To put a figure on the compile side I generated 300 small class files — twelve methods each, about 414 KiB of source — and timed a loop requiring them all with OPcache disabled, so every run compiled every file. Apple M4 Pro, macOS, PHP 8.5.9 CLI, NTS, JIT disabled, timing taken with hrtime() around the require loop, so filesystem reads are included. The median of 15 runs was 7.69 ms, spanning 7.61 to 7.89 ms, a range of 3.6% of the median: roughly 26 µs per file. Read those as relative, not absolute — one laptop, not quiesced. The shape is the portable part: cost proportional to file count, at tens of microseconds each.

Autoloading decides which files reach the compiler. Under Composer’s autoloader a class no code path references is never required, never compiled and never cached, while a referenced class pulls its file through the sequence above. Cold-start cost is a function of how many distinct files a request touches, and the dependency graph moves that number far more than anything inside a function body. Trimming instructions from a hot loop does not recover 26 µs apiece for files that never needed to load.

An optimized classmap is a narrower lever: it replaces the autoloader’s per-class filesystem probing with one array lookup, removing stat calls, but it does not reduce how many files are compiled. I have not measured that difference here.

A bigger lever than either is moving the work out of the request entirely. A framework that compiles its service container does the resolution once at build time and writes the result out as generated code, so a request loads one file and calls the constructors directly instead of loading the definition machinery and resolving through it on every call. That file is compiled once and read from the cache afterwards, which makes the whole advantage contingent on OPcache being on — what compiled DI containers actually buy you measures both halves of the saving, and the container size past which compiling the generated file costs more than the definitions it replaced.

What follows for a deployment

Turn opcache.validate_timestamps off in production and pay for it deliberately. The engine stops stat-ing your files altogether, and in exchange an edit on disk has no effect until the cache entry goes away — which means deploying into a new release directory so the cache keys are new, or reloading the pool. Fixing something by editing a file on a production box stops working. Worth taking when deploys are automated, worth refusing when they are not.

Size opcache.memory_consumption and opcache.max_accelerated_files against the number of PHP files the application actually loads. OPcache does not evict. When the key slots run out, cache_full goes true and new scripts stop being cached, while everything already stored keeps serving — a cliff rather than a slow decline.

Running out of memory refuses new scripts the same way, but cache_full does not reliably report it. Loading 300 files of roughly 38 KB each into a 16 MB segment, 3,907 key slots free throughout, cached 88 of them and refused 212 — with 117,912 bytes still free and the flag false. A true reading means the cache is full; a false one does not mean it is not. That asymmetry is the reason to watch free_memory on this path rather than the flag.

Filling the cache is also what arms its restart. Once cache_full is true, pushing wasted memory past opcache.max_wasted_percentage schedules one; below that threshold, or before the cache has filled, nothing is scheduled — with 3,907 slots free in a 16 MB segment I reached 10.4% wasted against a 5% threshold and nothing was queued. Neither condition has to come first. In that same 16 MB segment cut to 223 key slots, invalidating 222 cached files to 5.76% wasted scheduled nothing; a later load of all 400 exhausted the slots and scheduled the restart then, having invalidated nothing at all. opcache_reset() schedules one regardless of both.

The two findings interlock badly. The restart gate consults that same unreliable flag, so the same 16 MB segment with all 300 files invalidated sat at 85.68% wasted, cache_full false, no restart scheduled, and oom_restarts, hash_restarts and manual_restarts all zero.

So read three things rather than one. Filling a 223-slot cache with 400 files on PHP 8.5.9 CLI, default 128 MB segment, turned cache_full true and pinned num_cached_keys at 223 of 223 with 119 MB still free — the slots ran out, not the memory — and the first file stayed cached while the last was never cached. On the memory path those two look healthy instead and only free_memory moves. Read all three, and treat the restart counters as the answer to a different question.

Then treat cold start as a property of the deployment rather than of the code. The first request after a release pays to compile everything it touches, and the size of that bill was set by the dependency graph long before the release. The second request is faster because the first one did the compiling; which of the two your users get is a deployment question.

Frequently asked

Does OPcache cache the results of my code?
No. It caches compiled opcodes, not the values your code produces. Two requests hitting the same cached script still execute every opcode.
Why does a deploy make the first requests slow?
New file paths and new mtimes invalidate the cached entries, so the next request to each file pays for lexing and compilation again.
Where does the JIT fit into this?
After the VM, and only for code it can profitably compile to machine code. What it reaches and what it leaves alone is the subject of "OPcache, the JIT, and where time actually goes".
Share

Related posts

Arrow keys to move, Enter to open.