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

- Published: 2026-07-30
- Tags: opcache, jit, benchmarking
- Source: https://elephantphp.com/blog/opcache-jit-and-where-time-actually-goes/
- Language: en-US
- Author: Alden Pike

---
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
<?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,
);
```

```bash
# 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](/blog/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](/blog/what-happens-when-php-runs-your-code/) 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.
