Enabling the JIT is supposed to make an application faster. I turned it on for a request that loads 200 class files and calls 400 methods, and the request got slower — the first one by about 6 ms, and every request after it by an amount I could not separate from run-to-run noise.
The JIT was not misbehaving. It was working on a request that had almost nothing for it to work on, which is the ordinary case for web code and the reason the setting disappoints most people who reach for it.
Method
Everything below ran on PHP 8.5.9 CLI, NTS, arm64, Homebrew build, on an Apple
M4 Pro laptop running macOS, not quiesced. Per-request figures come from PHP’s
built-in server serving one fixed script, timed with hrtime() inside the
process around each phase separately, so HTTP and client costs are excluded. Ten
server lifecycles of eight requests each, so the first request is sampled ten
times and the warm ones seventy. Loop figures are 15 interleaved runs per
variant, reported as medians with ranges.
<?php
declare(strict_types=1);
// bench.php - the loop behind the Mandelbrot rows below.
function mandelbrot(int $iterations): float
{
$sum = 0.0;
for ($i = 0; $i < $iterations; $i++) {
$cr = ($i % 200) / 100.0 - 1.5;
$ci = ($i % 100) / 50.0 - 1.0;
$zr = 0.0;
$zi = 0.0;
$k = 0;
while ($k < 20 && $zr * $zr + $zi * $zi < 4.0) {
$t = $zr * $zr - $zi * $zi + $cr;
$zi = 2.0 * $zr * $zi + $ci;
$zr = $t;
$k++;
}
$sum += $k;
}
return $sum;
}
$iterations = (int) ($argv[1] ?? 2_000_000);
$warm = ($argv[2] ?? 'warm') === 'warm';
$jit = opcache_get_status(false)['jit'] ?? null;
if ($warm) {
mandelbrot(50_000);
}
$start = hrtime(true);
$result = mandelbrot($iterations);
$ms = (hrtime(true) - $start) / 1_000_000;
printf(
"jit=%s on=%s n=%d %.4f ms result=%.1f\n",
ini_get('opcache.jit'),
$jit === null ? 'no-status' : var_export($jit['on'], true),
$iterations,
$ms,
$result,
);
# loop figures: the plain CLI SAPI needs both flags before OPcache is active
php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.file_update_protection=0 \
-d opcache.jit=disable bench.php 2000000
# per-request figures: the built-in server reads opcache.enable, not opcache.enable_cli.
# public/ holds the script under test; it is not bench.php.
php -d opcache.enable=1 -d opcache.file_update_protection=0 \
-d opcache.jit=tracing -d opcache.memory_consumption=256 \
-S 127.0.0.1:8080 -t public/
Those two comments cost me a run each. Under the built-in server
opcache.enable_cli=0 leaves the cache on, and under the plain CLI binary
opcache.enable_cli=1 alone leaves it off — either mistake produces a clean,
confident table of the configuration you did not set.
opcache.file_update_protection was set to 0 in every cached run: a freshly
written file is not cached inside that window, and leaving the default would
have measured an empty cache while reporting a full one. Every run printed its
own effective configuration along with the OPcache hit and miss counters, which
is how I know the variants actually differed rather than all landing on the
default. Treat the absolute numbers as relative to each other and to this
machine; benchmarking PHP without lying to
yourself covers why.
What OPcache removes
Asked for a file it has not seen, the engine lexes it, parses it and compiles it to opcodes. OPcache stores that output in shared memory so the next request skips all three stages, and the path a request takes from source to opcodes describes them.
Those three stages are the entire scope of the feature. To put a size on it I generated 200 class files of deliberately mixed sizes — 830 bytes to 41 KB, median 1,926 bytes, 1.24 MiB in total — and wrote a script that requires all of them and then instantiates 200 objects and calls 400 methods. The two phases are timed separately, and only the OPcache setting varies:
| Configuration | Compile phase | Execute phase |
|---|---|---|
| OPcache off, every request | 13.83 ms (13.47–14.83) | 0.051 ms (0.045–0.101) |
| OPcache on, first request | 26.53 ms (26.18–26.78) | 0.060 ms (0.048–0.071) |
| OPcache on, later requests | 0.336 ms (0.316–0.477) | 0.048 ms (0.044–0.065) |
The compile phase fell by a factor of 41. The execute phase did not move at all: 0.051 ms uncached against 0.048 ms cached, with ranges that overlap, so this method cannot tell the two apart. The VM walks the same op array either way, and every opcode in it still runs.
The first row through a cold cache is the other half of the trade. Compiling and
storing cost 26.53 ms against 13.83 ms for compiling alone — the first request
after a deploy pays nearly twice what it would have paid with the cache off.
Preloading moves that work to server startup: with opcache.preload compiling
the same 200 files, the first request’s compile phase was 0.457 ms and only two
files registered as misses.
What the JIT compiles
The tracing JIT watches the program run, and when a loop or a function crosses a
hotness threshold it compiles the recorded trace to machine code.
opcache.jit_hot_loop defaults to 61 and opcache.jit_hot_func to 127 here.
Five workloads, two million iterations each, opcache.jit the only setting that
differed:
| Workload | jit=disable | jit=tracing | Ratio |
|---|---|---|---|
| Float arithmetic, Mandelbrot inner loop | 271.6 ms | 40.6 ms | 6.69x |
| Integer arithmetic | 7.05 ms | 1.18 ms | 5.98x |
| Method calls on one object | 26.0 ms | 9.26 ms | 2.81x |
| String building through internal functions | 52.4 ms | 38.1 ms | 1.37x |
| Hashtable reads and writes | 70.7 ms | 58.6 ms | 1.21x |
Arithmetic gains most of an order of magnitude. String work and hashtable traffic — the two things a request handler spends its time on — gain 37% and 21%. Run-to-run spread was under 8% of the median on every row except the compiled integer-arithmetic runs, at 20%, and the uncompiled method-call runs, which spanned 23.9 to 45.1 ms for a spread of 81% against 3.7% for their compiled counterparts. Read that 2.81x as the least trustworthy number in the table.
The hotness threshold decides whether you see any of it
Those numbers came from loops warmed inside the process before timing started. Run the same Mandelbrot loop cold and vary only how many iterations it gets:
| Iterations | jit=disable | jit=tracing | Ratio |
|---|---|---|---|
| 100 | 0.0130 ms | 0.2050 ms | 0.06x |
| 1,000 | 0.136 ms | 0.225 ms | 0.60x |
| 2,000 | 0.261 ms | 0.246 ms | 1.06x |
| 5,000 | 0.679 ms | 0.299 ms | 2.27x |
| 100,000 | 13.58 ms | 2.22 ms | 6.11x |
| 2,000,000 | 270.9 ms | 40.7 ms | 6.66x |
At 100 iterations the JIT-enabled run was sixteen times slower, and the ranges — 0.012 to 0.016 against 0.199 to 0.250 — come nowhere near each other, so that is a real loss rather than noise. The roughly 0.19 ms of it is the fixed cost of compiling the trace, which at that size buys nothing back. Break-even sits near 2,000 iterations, where the ranges do overlap and the honest reading is that this method cannot separate the two.
That figure belongs to this loop body, not to the JIT. Each iteration above runs up to twenty rounds of complex-number arithmetic, so it recovers the fixed compile cost quickly. A lighter body earns less back per iteration and needs proportionally more of them: the same fixed cost against a loop doing a tenth of the work per pass breaks even roughly an order of magnitude later. Measure the crossover for your own loop rather than carrying 2,000 across.
That threshold is why the request I opened with got slower. Its driving loop ran
200 times, over opcache.jit_hot_loop, so the trace compiler charged for the
attempt — 9,176 bytes of machine code on the first request — while each of the
400 methods it called ran exactly once and left the compiled code almost nothing
to run on. In a long-lived process the counters
and the compiled traces carry across requests — warm requests in the same server
process compiled a further 992 bytes of trace — so the cost amortizes over a
worker’s lifetime rather than being charged per request. How that behaves across
a pool of php-fpm workers I have not measured.
Where the time actually goes
Check waits first. I ran a request/response service on loopback, with no query to execute and no network hop, and fetched 1,500 bytes from it two ways: in one round trip, and in one hundred. Same bytes, same process, same connection.
| Shape | Median | Range |
|---|---|---|
| One round trip | 0.0935 ms | 0.0661–0.1080 |
| One hundred round trips | 8.56 ms | 7.03–8.62 |
Ninety-one times the cost for identical data. Enabling the JIT on that measurement changed nothing either range could distinguish, which is what a feature that compiles arithmetic does to a program that is waiting on a socket.
Check the number of operations second. The two rows above differ only in how many times the program crossed the boundary, and a single loopback round trip cost about 86 µs — while the JIT’s best result saved roughly 115 ns per iteration of the tightest numeric loop I could write. One avoidable round trip is worth about 750 of those iterations, and a real database on another host costs more than loopback does.
Per-operation cost comes third, and it is the only one of the three either feature touches.
What to do with the setting
Measure before enabling, and measure the real path rather than a loop. If a profiler shows the request waiting on a database, a cache or an HTTP call, the JIT has nothing to compile and turning it on trades a first-request penalty for no return.
Set opcache.jit_buffer_size=0 when that is what the measurement says. With
opcache.jit=tracing and the buffer at zero, opcache_get_status() reported the
JIT as disabled and the same Mandelbrot loop — at 500,000 iterations here rather
than the two million the tables above use — ran at 65.99 ms against 67.01 ms for
opcache.jit=disable, the same speed, which is to say off. On this build the
buffer defaults to 64 MB of shared memory, so zero also returns memory you were
not using.
Keep OPcache on regardless. It removes a cost that scales with how many files a request touches, it removes it from every request after the first, and it does not care what your code does. The JIT is the narrower tool, and the condition under which it earns its keep — arithmetic hot enough to cross the threshold inside one process — is a condition worth confirming rather than assuming.
Frequently asked
- Why did enabling the JIT not speed up my application?
- Because the time was not in the code the JIT compiles. A request dominated by database and network waits has little arithmetic for it to reach.
- Is opcache.jit_buffer_size worth tuning?
- Only after measurement shows JIT-eligible code is where the time goes. Set it to zero and the JIT is off, which is the correct setting for many applications.
- Does preloading replace OPcache?
- No. Preloading links a set of classes into memory once at startup; OPcache still caches the compiled form of everything else.