Raw PHP, opcache, JIT, and AOT: what actually happens to your code
DEV Community

Raw PHP, opcache, JIT, and AOT: what actually happens to your code

A few weeks ago, I posted a carousel on LinkedIn about TypePHP, the ahead-of-time compiler the Swoole team open sourced. It turns PHP source into a native executable that starts on its own, with no PHP CLI and no separate interpreter process involved. The response surprised me. Almost nobody wanted to argue about the benchmarks. What people kept asking, in one form or another, was: isn't this just JIT? Doesn't opcache already do this? Where does this fit with what I'm already running? Thnking about it, I realized my own mental model had gone fuzzy. I knew opcache was important and I knew JIT existed, but I couldn't have clearly explained the difference to someone who asked. So I went back and worked it out. This is that refresher, written up for anyone else who wants it. The short version If you've got two minutes, here's the whole thing. PHP has to do two separate jobs before your code produces a result. 1) First it has to understand your code, which means reading the text and turning it into instructions. 2) Then it has to execute those instructions. The four approaches each attack a different part of that. Raw PHP does both jobs on every single request. It reads your files, compiles them, runs them, and throws all the work away. Then the next request comes in and it does the whole thing again. Opcache fixes the first job. It saves the compiled instructions in memory so PHP stops re-reading your source on every request. This is the single biggest performance win available to a PHP app and it costs you nothing but memory. If you take one thing from this article, take this one. JIT attacks the second job, with limited success. While your code is running, it watches for hot spots and converts them to native machine code on the fly. It produces impressive numbers on math-heavy code and almost nothing on a normal web request. AOT attacks the second job by changing the rules. It compiles your PHP all the way to a native binary before you ever deploy it. It goes far faster than JIT can, but only because it gives up part of the PHP language to get there. The first three are about making your existing app faster. AOT is more about redefining what you can build with PHP at all... and that's the part I find really interesting. First, what PHP is doing with your code When a request hits your app, PHP reads your .php files as plain text. It parses them, checks the syntax, and compiles them into something called opcodes. Opcodes are small, simple instructions, roughly the PHP equivalent of assembly. Something like $a + $b becomes an ADD opcode with two operands. Then the Zend VM takes over. The VM is a loop. It grabs the next opcode, figures out what it means, does it, and moves to the next one. Over and over until your script finishes. There are two specific things you need to keep in mind. The first is that PHP variables aren't raw values. They're zvals, which are little containers that hold both the value and a tag saying what type it is. When the VM adds two variables, it has to check the tags first, because $a + $b means something different for two integers than for two strings. That type check happens at runtime, every time, because PHP doesn't know the types ahead of time. The second is that all of this work gets discarded when the request ends. PHP's process model throws everything away and starts clean. That's actually a feature. It's why PHP is so hard to leak memory in and so forgiving of sloppy code, but it also means any work you do during a request has to be redone on the next one. And the next one. And the next one. These strategies offer different answers to the question: "which part of that can we stop repeating?" 1. Raw PHP - goldfish memory This is PHP with nothing turned on. Every request: read the files, parse them, compile them, run the opcodes, throw it all away. Nobody should be running this on purpose in 2026, but it's the baseline and it's worth understanding why it's so bad. The problem isn't your code. The problem is your framework. A modern Drupal or Laravel install loads hundreds or thousands of PHP files to serve one page. Every one of those files has to be read off disk, tokenized, parsed, and compiled before a single line of your actual application logic runs. On a typical framework request, that setup work can eat more time than the work you actually care about. You'll still run into this sometimes. A misconfigured container image, a dev environment nobody set up properly, a hosting provider cutting corners. If an app feels inexplicably slow and the database looks fine, checking whether opcache is actually enabled is a good first move. 2. Opcache - caching is your friend Opcache fixes the obvious waste. The first time a file is requested, PHP compiles it as usual, but then opcache stores the resulting opcodes in shared memory. Every request after that skips straight to execution. Your source files stop being read. The parser stops running. All that setup work happens once instead of thousands of times a day. On a framework-heavy app this commonly gets you several times the throughput, which is an enormous return for a config flag. It's built into PHP, it's on by default in most modern builds, and the only real cost is a chunk of memory. But notice what opcache does not do. The Zend VM is still there, still walking through opcodes one at a time, still checking zval types on every operation. Opcache removed the cost of understanding your code. It did nothing about the cost of running it. however, a tight loop that does math a million times gets essentially nothing out of opcache. Opcache removed the cost of compiling that loop, which happens once. The time is going into the VM executing it a million times, and opcache never touches that part. That gap is what the next two approaches go after. 3. JIT - good on paper, but... JIT stands for just-in-time compilation, and it arrived in PHP 8.0. It builds directly on top of opcache - it's not a separate thing you run instead, it's a layer that sits on top. The idea is that while your code is running, PHP watches which parts run most often. When something crosses a threshold, PHP compiles that section into real native machine code and runs the machine code instead of interpreting opcodes. The compilation happens during execution, which is where "just in time" comes from. On paper this should be huge. In practice, for most web apps, it's roughly nothing. There are a few key reasons why. PHP's dynamic types get in the way. The JIT wants to compile $a + $b into a single machine instruction. To do that it needs to know that $a and $b are both integers. But PHP can't promise that because anything could have been assigned to those variables. So the JIT emits the fast machine code plus a guard check that verifies the types are what it assumed. If the guard fails, it bails back to the interpreter. Those guards cost time, and they're everywhere. It has to preserve all of PHP's behavior. References, magic methods, error handlers, the ability to redefine things at runtime. The JIT can't optimize away anything that might be observable, and in PHP an awful lot is observable. The work doesn't survive. The compiled machine code lives in one worker process. When that process recycles, it's gone, and the next one has to warm up from scratch. Most web requests aren't CPU-bound anyway. A typical page load spends its time waiting on MySQL, waiting on Redis, waiting on an API. Making the PHP execution faster doesn't help when the PHP was already sitting around waiting. Where JIT genuinely does earn its keep is code that's actually doing arithmetic in a loop: image manipulation, numeric simulation, machine learning inference, statistical work, anything that grinds on numbers without touching the network. If that's your workload, turn it on and measure. If it isn't, JIT is a config option you can safely leave alone. 4. AOT - now this is interesting AOT stands for ahead-of-time. Instead of compiling while your program runs, it compiles before you deploy at all. That makes sense. TypePHP takes your PHP source, translates it into C++17, and hands that to gcc or clang. What comes out the other end is a native binary. No interpreter, no opcodes, no VM. Just machine code, the same as if you'd written the thing in C. And here's the part that matters: the reason it goes so much faster than JIT isn't that compiling beats interpreting. It's that AOT gets to know the types. What you gain By requiring you to write in a typed subset of PHP, the compiler can turn a PHP int into an actual machine integer instead of a zval. No box, no tag, no runtime type check, no guard, no bailout path. Once the types are real, it can hand the whole program to a C++ optimizer that's had thirty years of work poured into it... inlining, loop unrolling, constant folding, vectorization, all of it, with full visibility into your code. That's where the impressive numbers come from. The project reports around 69x on a hundred-million-iteration pi calculation and roughly 135x on a recursive Fibonacci. Broader language benchmarks are more modest, at about 8x on bench.php and 6.5x on micro_bench.php . Not bad, that's for sure. What you lose The catch is exactly symmetrical to JIT's. JIT keeps the entire PHP language and accepts a performance ceiling. AOT breaks through the ceiling by giving up part of the language. TypePHP compiles a defined subset and publishes the incompatibility list openly, which I respect. Global scope is declaration-only. Binary mode wants a main() with a specific signature. Some dynamic reference and reflection patterns simply don't compile. So no, you can't compile Drupal core. But that was never really the point. Check the fine print Binary mode produces an executable that starts directly, with no PHP CLI and no separate interpreter process. That's real and it's useful. But the executable still links libphp and PHPX, and those have to ship in your deployment package. So, this isn't a

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.