Function JIT, Tracing JIT and the boundary between machine code and syscalls
Since PHP 8.0, the JIT compiler translates hot opcode paths into native machine code at runtime, but the effect depends entirely on the workload: numerical computation, image processing, and parsing large volumes of data show measurable runtime gains, while classic, database-heavy web applications barely benefit because wait time on syscalls dominates there. This article explains the architecture of Function JIT and Tracing JIT, the relevant opcache.jit configuration values, and a solid benchmark methodology to measure the actual effect in your own project instead of guessing.
Table of Contents
- 1. What the JIT Compiler in PHP 8 Actually Does
- 2. Tracing JIT vs. Function JIT: Architectural Differences
- 3. Configuring opcache.jit and opcache.jit_buffer_size
- 4. Hot Thresholds: jit_hot_func, jit_hot_loop, jit_hot_side_exit
- 5. When the JIT Compiler Actually Helps
- 6. When the JIT Compiler Brings Nothing
- 7. Benchmark Methodology: Measuring the JIT Impact Correctly
- 8. Known Issues: Debugging, Xdebug, Extensions
- 9. Production Recommendation: Enable, Disable, Differentiate
- 10. Summary
- 11. FAQ
1. What the JIT Compiler in PHP 8 Actually Does
The Zend Engine first translates PHP source code into opcodes, the compact bytecode representation interpreted by the Zend Virtual Machine. OPcache caches these compiled opcodes in shared memory so that parsing and compilation don't have to be repeated on every request. That is already a substantial performance gain over uncompiled PHP, but it has nothing to do with the JIT compiler itself yet: the opcodes are still interpreted line by line by the Zend VM, just without repeated parsing.
The JIT compiler takes a decisive step further. It takes the Zend VM opcodes for code identified as "hot" and translates them at runtime directly into native machine code for the given CPU architecture. This machine code is then executed directly by the CPU, bypassing the interpreter's dispatch loop entirely. The memory area reserved for this is its own shared memory block, separate from the classic OPcache bytecode cache, and is sized via opcache.jit_buffer_size.
A common misconception is that the JIT compiler replaces OPcache. In fact it's an extension that builds on an already-enabled OPcache: without opcache.enable=1 it doesn't work either, because it operates on the same internal structures of the already-compiled opcodes. The bytecode cache and machine code translation are two distinct, layered optimization stages within the same module.
2. Tracing JIT vs. Function JIT: Architectural Differences
PHP has two fundamentally different compilation strategies for the JIT compiler. Function JIT compiles a complete function to machine code as soon as its call counter reaches the configured threshold. The entire function body is translated, including rarely executed branches, without specifically exploiting the types actually observed at runtime. Compilation happens entirely based on statically available type information.
Tracing JIT, on the other hand, follows actually executed, hot paths: concrete loops that iterate frequently, or side paths created by deviations from an existing trace. It records the types actually observed during execution and generates highly specialized machine code for exactly that one path. If execution deviates from the recorded trace, execution jumps back into the interpreter, a so-called side exit. This approach allows more aggressive optimizations than Function JIT because type checks that would be necessary in the generic case can be omitted, but it introduces additional debugging complexity, since compiled code exists only for exactly the observed paths.
Since PHP 8.0, Tracing JIT has been the default mode, because it tends to deliver better results in realistic benchmarks than Function JIT. Function JIT remains relevant nonetheless, for instance when more predictable compilation behavior with less variance is desired, or when a codebase contains many small, evenly called functions rather than long-running loops.
3. Configuring opcache.jit and opcache.jit_buffer_size
The value of opcache.jit follows a four-digit scheme that encodes CPU-specific optimizations, register allocation, trigger behavior, and optimization level. In practice it's enough to remember the common presets: 1205 enables Function JIT, 1254 enables Tracing JIT and has been the default since PHP 8.0. Anyone who wants to disable the JIT compiler entirely sets opcache.jit=disable or alternatively opcache.jit_buffer_size=0, because a buffer of size zero disables it regardless of the chosen mode.
opcache.jit_buffer_size determines the size of the shared memory area where compiled machine code is stored. If this area is sized too small, the JIT compiler eventually can no longer compile additional code and silently falls back to the interpreter for further hot code, without any error or warning. Via opcache_get_status()['jit']['buffer_free'] you can check at runtime how much space is still free. For most applications, 64 to 128 megabytes are a sensible starting point; very large codebases with many hot functions may need more.
; php.ini - enable the JIT compiler (Tracing JIT, PHP default since 8.0)
opcache.enable=1
opcache.enable_cli=1
; JIT buffer: separate shared memory area for native machine code.
; 0 = JIT compiler fully disabled, regardless of the opcache.jit value
opcache.jit_buffer_size=100M
; Four-digit scheme (CPU register, allocation, trigger, optimization level)
; 1254 = Tracing JIT, optimization level 4 (PHP default since 8.0)
; 1205 = Function JIT, compiles whole functions without trace specialization
opcache.jit=1254
; Fully disable, e.g. for pure debug environments running Xdebug
; opcache.jit=disable
The buffer should be monitored regularly, especially after deployments with new, frequently executed code. A full JIT buffer is not an error in the classic sense but a silent fallback to pure interpretation, which quietly undoes the expected performance gains.
4. Hot Thresholds: jit_hot_func, jit_hot_loop, jit_hot_side_exit
The JIT compiler doesn't compile every piece of code immediately, because compilation itself costs CPU time. Three thresholds control when code counts as hot. opcache.jit_hot_func defines the number of calls before a function gets compiled by Function JIT, 127 by default. opcache.jit_hot_loop defines the number of loop iterations before Tracing JIT creates a trace for that loop, 61 by default. opcache.jit_hot_side_exit defines how many times a trace has to be left at the same point before a dedicated, specialized trace gets compiled for the deviating path, 8 by default.
These thresholds prevent the JIT compiler from spending time translating code that only ever runs once or twice anyway. The compilation effort only pays off once the code runs often enough for the later execution speed to offset the overhead of translation. Lower thresholds let it kick in earlier but increase the risk of compiling code that ultimately never stays hot enough. Higher thresholds delay the effect but reduce unnecessary compilation work for code that is genuinely cold.
; php.ini - hot thresholds for the JIT compiler
; From what point is code considered "hot" and compiled to machine code?
; Call counter per function before Function JIT compiles it (default: 127)
opcache.jit_hot_func=127
; Iteration counter per loop before Tracing JIT starts a trace (default: 61)
opcache.jit_hot_loop=61
; Number of side exits at the same location before a dedicated trace
; is compiled for the deviating path (default: 8)
opcache.jit_hot_side_exit=8
; Lower values: kicks in earlier, but higher risk of compilation
; overhead for code that doesn't stay hot for long.
5. When the JIT Compiler Actually Helps
CPU-bound workloads benefit most clearly from the JIT compiler: numerical computations such as matrix operations and physics simulations, image processing with pixel-wise operations, encryption and hashing routines not already implemented as a C extension, parsing large volumes of data character by character, and machine learning preprocessing with feature-engineering loops written in plain PHP.
The reason lies in the nature of these workloads: they spend most of their runtime in tight loops that repeatedly execute arithmetic and comparison opcodes. The overhead of the Zend VM dispatch loop, including repeated type checks per opcode, dominates execution time in this scenario. The JIT compiler eliminates this dispatch overhead by generating native machine instructions directly. With Tracing JIT, type specialization additionally removes a significant share of the type checks otherwise needed at runtime. In practice, benchmarks for tight numerical loops often show speedups of three to eight times.
Classic real-world examples are pure PHP implementations of Mandelbrot computations, sorting algorithms without native acceleration, or recursive numerical functions. Anywhere the code itself does the work instead of delegating it to a C extension or an external resource, it unfolds its full potential.
6. When the JIT Compiler Brings Nothing
I/O-bound web applications, by contrast, barely benefit from the JIT compiler. Database queries, network calls, and filesystem access are syscalls that happen entirely outside the Zend Engine. It can only speed up code actually executed by the Zend VM, not the time a process spends waiting inside the kernel while a MySQL query runs or an HTTP response arrives.
A typical web request makes the ratio clear: a few milliseconds of actual CPU-bound PHP execution often stand against several dozen to several hundred milliseconds of wait time for database responses, external API calls, or session storage. The JIT compiler only affects the CPU-bound portion, which for many web applications is well under five percent of total request time. Even a hypothetical fivefold speedup of that small portion brings barely noticeable improvement to end-to-end response time.
Real benchmarks confirm this: for typical, database-heavy content management and shop applications, independent measurements often show only single-digit percentage improvements, while the same measurements for pure computational benchmarks show multiples of the speed. Anyone who enables the JIT compiler solely based on generic marketing numbers, without knowing their own workload, will regularly be disappointed in practice.
7. Benchmark Methodology: Measuring the JIT Impact Correctly
For microbenchmarks, hrtime(true) is clearly better suited than microtime(), because it provides a monotonic timer in nanoseconds with no susceptibility to system clock adjustments. Equally crucial is a separate warm-up: since compilation by the JIT compiler itself takes time and only kicks in once the hot thresholds are reached, the code under measurement must run enough times before the actual measurement. If you measure starting from the very first call, interpretation time and compilation overhead flow into the result, and the actual peak performance is systematically underestimated.
Microbenchmarks run in isolation from the CLI with targeted -d flags differ significantly from real workloads in a web server context. Long-running PHP-FPM workers share the JIT buffer across multiple requests, while an opcache_reset() or a process restart discards already-compiled code again. Frequent measurement errors arise from runtimes that are too short, where timer resolution, garbage collector pauses, or OS scheduling jitter influence the result more than the effect actually being measured.
<?php
declare(strict_types=1);
/**
* Microbenchmark: CPU-bound function with and without the JIT compiler.
* Run twice from the CLI to compare:
* php -d opcache.jit=0 bench.php (interpreter only)
* php -d opcache.jit=1254 -d opcache.jit_buffer_size=64M bench.php (JIT compiler on)
*/
function isPrime(int $n): bool
{
if ($n < 2) {
return false;
}
for ($i = 2; $i * $i <= $n; $i++) {
if ($n % $i === 0) {
return false;
}
}
return true;
}
function countPrimes(int $limit): int
{
$count = 0;
for ($n = 2; $n <= $limit; $n++) {
if (isPrime($n)) {
$count++;
}
}
return $count;
}
// Warm-up: let the JIT compiler reach the hot-function/hot-loop thresholds
// and finish compiling before the actual measurement starts.
countPrimes(200_000);
$iterations = 5;
$samples = [];
for ($i = 0; $i < $iterations; $i++) {
$start = hrtime(true);
$result = countPrimes(2_000_000);
$elapsedMs = (hrtime(true) - $start) / 1_000_000;
$samples[] = $elapsedMs;
}
$avg = array_sum($samples) / count($samples);
printf("Primes found: %d\n", $result);
printf("Average runtime over %d runs: %.2f ms\n", $iterations, $avg);
printf("Samples (ms): %s\n", implode(', ', array_map(fn($v) => round($v, 2), $samples)));
Important for reliable results: measure multiple repetitions, look at average and spread instead of a single run, and perform the measurement both with the JIT compiler disabled and enabled on identical hardware under identical system load. Only a direct comparison under otherwise equal conditions delivers a reliable statement about the actual effect.
8. Known Issues: Debugging, Xdebug, Extensions
Debugging with the JIT compiler enabled is generally possible but occasionally less convenient. Since compiled machine code cannot always be mapped one-to-one back to source lines, certain low-level debugging scenarios with tools like gdb can be harder. For the everyday use case with var_dump(), logging, and exceptions, however, nothing changes, because these mechanisms work independently of whether the underlying opcode is currently interpreted or executed as machine code.
The interaction with Xdebug is far more relevant. Xdebug hooks deeply into Zend VM execution to enable step debugging, coverage, and profiling at the opcode level. This instrumentation is not compatible with natively executed machine code, which is why PHP automatically disables the JIT compiler as soon as Xdebug is loaded in an active mode, regardless of the configured value for opcache.jit. This is not a bug but deliberate behavior that prioritizes debugging correctness over performance.
# Check whether the JIT compiler is actually active at runtime
php -r 'var_dump(opcache_get_status()["jit"]["enabled"]);'
# bool(true) -- plain opcache.jit=1254 configuration, Xdebug not loaded
php -d zend_extension=xdebug -r 'var_dump(opcache_get_status()["jit"]["enabled"]);'
# bool(false) -- Xdebug forces the JIT compiler off, regardless of opcache.jit
# Confirm which extensions are actually loaded for this SAPI/process
php -v
php -m | grep -i xdebug
Occasional compatibility issues with active JIT compiler have also been documented for some C extensions that hook deeply into internal Zend VM structures or opcode handlers, particularly older, poorly maintained extensions. Before enabling it in production, a targeted compatibility test of the extension landscape in use is therefore recommended, rather than relying solely on general statements about core PHP compatibility.
9. Production Recommendation: Enable, Disable, Differentiate
There's no universally correct answer as to whether the JIT compiler should be enabled in production. The decision depends entirely on the actual workload profile determined through profiling, not on assumptions. For classic PHP-FPM applications with a request-per-process model that are predominantly dominated by database access and network I/O, enabling it typically brings only a few percent improvement, while additional debugging complexity and additional memory demand for the JIT buffer arise, an effort that often doesn't match the small benefit.
For dedicated CPU-bound batch and worker processes, such as report generation, image processing pipelines, data export and import jobs, or compute-heavy queue consumers, a targeted activation of the JIT compiler makes considerably more sense. Such processes can be equipped with their own PHP-FPM pools or targeted CLI invocations with their own opcache.jit value and a larger jit_buffer_size, independent of the configuration of the rest of the web traffic.
# Web pool (PHP-FPM): I/O-bound requests, JIT compiler brings little value
# pool.d/www.conf
; php_admin_value[opcache.jit] = off
; php_admin_value[opcache.jit_buffer_size] = 0
# Worker pool (PHP-FPM): CPU-bound batch, export and report jobs
# pool.d/worker.conf
; php_admin_value[opcache.jit] = 1254
; php_admin_value[opcache.jit_buffer_size] = 128M
# Standalone CLI batch job with the JIT compiler enabled explicitly
php -d opcache.jit=1254 -d opcache.jit_buffer_size=128M bin/export-report.php
It's important to set realistic expectations: the JIT compiler is not a blanket performance switch but a targeted tool for a specific type of workload. Anyone who profiles their own application before making the decision and actually quantifies the CPU-bound share makes an informed decision rather than a guessed one.
| Workload Type | JIT Benefit | Reasoning |
|---|---|---|
| Numerical Computation | significant | Tight loops, many opcode dispatches, barely any I/O wait |
| Image Processing | significant | Pixel-wise operations, repeated arithmetic per pixel |
| DB Query-Heavy Request | low | Wait time for database responses dominates total runtime |
| API Proxy / Network I/O | none | Syscalls and network latency are not sped up by the JIT compiler |
| Template Rendering | low | Mostly cache and storage I/O, little pure CPU workload |
The table makes clear that the decision for or against the JIT compiler is not a question of general PHP version or general best practice, but a question of the concrete share of CPU-bound compute time in the given workload. Anyone who knows this share can target the configuration specifically, instead of applying it blanket across the entire infrastructure.
10. Summary
The JIT compiler in PHP 8 translates hot opcode paths into native machine code at runtime and is thus clearly distinct from OPcache's classic bytecode cache function. Function JIT compiles whole functions based on call counters, Tracing JIT follows concrete, type-specialized execution paths and has been the default mode since PHP 8.0. Configuration via opcache.jit, opcache.jit_buffer_size, and the hot thresholds jit_hot_func, jit_hot_loop, and jit_hot_side_exit controls when and how aggressively compilation happens.
The decisive factor for the actual benefit is the workload: CPU-bound computations benefit significantly, I/O-bound web applications barely at all, because the JIT compiler cannot speed up syscalls. Xdebug automatically disables the JIT compiler, and a clean benchmark methodology with hrtime(), warm-up, and multiple measurement runs is a prerequisite for reliable statements. The production recommendation is therefore: profile, quantify the CPU-bound share, and apply the JIT compiler specifically where it really helps, instead of blanket-enabling or disabling it everywhere.
The JIT Compiler in PHP 8, the Essentials at a Glance
What It Does
Translates hot Zend VM opcodes into native machine code at runtime, separate from the classic OPcache bytecode cache.
Function JIT vs. Tracing JIT
Function JIT compiles whole functions. Tracing JIT follows type-specialized paths and has been the default since PHP 8.0.
Configuration
opcache.jit=1254, opcache.jit_buffer_size=100M, fine-tune hot thresholds depending on the workload.
When It's Worth It
CPU-bound workloads: significant. I/O-bound web applications: barely, because syscalls aren't sped up.
11. FAQ: The JIT Compiler in PHP 8
1What is the difference between OPcache and the JIT compiler?
2What does the value 1254 for opcache.jit mean?
3What happens if opcache.jit_buffer_size is too small?
4Function JIT vs. Tracing JIT?
5What do jit_hot_func, jit_hot_loop, jit_hot_side_exit stand for?
6Does the JIT compiler help DB-heavy web applications?
7Why does Xdebug disable the JIT compiler?
8How do I measure the JIT effect correctly?
9Are there compatibility issues with extensions?
10Should I generally enable the JIT compiler?
Mironsoft
PHP performance analysis, profiling, and infrastructure tuning
Is the JIT compiler even relevant for your workload?
We profile your PHP application, determine the actual share of CPU-bound compute time, and configure opcache.jit specifically where it makes a measurable difference, instead of adopting blanket recommendations unchecked.
Workload Profiling
Cleanly determine the CPU-bound vs. I/O-bound share of your application
JIT Configuration
Size opcache.jit, jit_buffer_size, and hot thresholds to match the workload
Benchmark Setup
Reliable before/after measurements instead of generic marketing numbers