OPcache, the JIT, and where time actually goes

OPcache removes compilation, not execution. The JIT reaches arithmetic-heavy code and little else. Where the remaining time lives.

Alden Pike8 min read

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.
Share

Related posts

Arrow keys to move, Enter to open.