The JIT of PHP 8 - Ivan Centamori
The JIT of PHP 8
PHP has never run your code. It has always run a translation of it.
When you write $a + $b, the CPU never sees an addition. It sees a long loop that, on every turn, reads an intermediate instruction, looks up who knows how to handle it, executes it on data structures wrapped in metadata, and starts again. This layer of mediation is what makes PHP comfortable to write and slow to run. It is the interpreter tax.
The JIT (Just-In-Time compiler), introduced in PHP 8.0 in November 2020, is the first mechanism in the language capable of removing that tax: it translates PHP byte-code directly into native machine code while the program is running. The CPU stops interpreting and starts executing.
In this article we look at what really happens under the hood — from the compilation pipeline to the Zend VM loop, from the two JIT strategies to the CRTO configuration — with a benchmark that was run and verified, not imagined.
The journey of a PHP script
Before understanding what the JIT accelerates, you need to understand what slows things down. A PHP script goes through four stages before producing a result.
Stage<br>Input<br>Output<br>Component
Lexing<br>Source code<br>Tokens<br>Lexer
Parsing<br>Tokens<br>AST<br>Parser
Compilation<br>AST<br>Opcodes<br>Compiler
Execution<br>Opcodes<br>Result<br>Zend VM
The first three stages turn the text you write into opcodes — the low-level instructions of the Zend Engine, PHP's equivalent of byte-code. A line like $c = $a + $b is not a single operation: it compiles into something like ADD $a, $b -> ~tmp followed by ASSIGN $c, ~tmp. Each opcode has its own handler, a C function that knows how to execute it.
The last stage is the one that matters. The Zend Virtual Machine is, in essence, a loop that does one single thing, millions of times:
while (opcode = next_opcode()) {<br>handler = handlers[opcode->type];<br>handler(opcode);<br>This is the heart of the interpreter. On each iteration the VM must decide which handler to call based on the opcode type — a dispatch that, repeated billions of times, becomes a measurable cost.
Why it is slow
The problem is not just the dispatch. It is what the handlers do.
PHP has dynamic types. The same variable can hold an integer, then a string, then an array. To manage this flexibility, every value is wrapped in a structure called a zval , which contains the value, its type, and a reference count.
When the ADD handler executes $a + $b, it does not sum two numbers. It has to: read the zval of $a, read the zval of $b, check their types at runtime, decide whether it is an integer addition, a float addition, a disguised concatenation, or an overload on an object, perform the right operation, and finally build a new zval for the result.
All of this machinery fires even when $a and $b are always two integers, and they are so on every iteration of a loop that runs a million times.
The interpreter does not know this. It cannot know it: its job is to be ready for anything. And paying to be ready for anything, on every single opcode, is exactly what the JIT eliminates.
OPcache, the prerequisite
The JIT is not a separate entity. It lives inside OPcache , the extension PHP has used for years to avoid recompiling the same script on every request.
Without OPcache, every HTTP request redoes everything from scratch: lexing, parsing, compilation. For a script that does not change, it is pure waste. OPcache solves this by storing the compiled opcodes in shared memory. From the second request onwards, PHP jumps straight to execution.
Without OPcache: source -> tokens -> AST -> opcodes -> EXECUTION (every request)<br>With OPcache: [opcodes cached] -> opcodes -> EXECUTION (from 2nd request)<br>With JIT: [opcodes cached] -> [native code cached] -> CPU<br>OPcache eliminates recompilation. But execution stays interpreted: the opcodes still pass through the Zend VM loop. The JIT adds one more level to the cache — no longer just shared opcodes, but shared native code . This is why it is enabled through OPcache, and why it does not work if OPcache is switched off.
What the JIT actually does
The JIT takes the opcodes and, instead of feeding them to the VM loop, translates them into machine instructions the CPU runs directly. Two things disappear: the dispatch (there is no longer a loop deciding which handler to call) and, where possible, the zval machinery.
The second point is where the magic happens. It is called type inference .
If the JIT compiler manages to prove that, at a certain point in the code, a variable is always a float, then it does not need to emit the generic code that checks the type, handles zvals, and contemplates every possible case. It can emit a single machine instruction — an addsd on an SSE register for a float addition — and nothing else.
It is the difference between:
fetch zval $a; check type; fetch zval $b; check type;<br>branch on int/float/string/object; add; alloc result zval; write<br>and:
addsd xmm0, xmm1<br>One operation against a...