# The opcode is not the handler

> The same loop ran 23% faster with int parameters, and only with OPcache on. Operand classes pick a handler when the file compiles; proven types pick a different one.

- Published: 2026-10-25
- Tags: zend-vm, jit, benchmarking
- Source: https://elephantphp.com/blog/how-the-zend-vm-dispatches-an-opcode/
- Language: en-US
- Author: Alden Pike

---
One loop, two versions that differ only in whether the parameters are declared
`int`. With OPcache off they take the same time. With OPcache on, the typed one
is 23% faster.

```text
                  OPcache off      OPcache on
addTyped(...)     2.557 ns         1.964 ns
addUntyped(...)   2.559 ns         2.562 ns
```

Nothing about the source changed between the columns, and the opcodes are the
same either way. What changed is which C function the engine put behind the
`ADD`.

## How these numbers were taken

PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop with 24 GB and
12 logical cores, running macOS and not quiesced. OPcache's state is stated with
every measurement because it is the variable under test; the JIT is off except in
the one section that names it.

Timings are the median of 15 interleaved runs with three warmup rounds
discarded, taken with `hrtime()` inside the process over 20,000,000 iterations,
divided by the iteration count, with the range given. Where a figure is described
as net, an empty loop measured the same way in the same configuration has been
subtracted. Read them relative to each other, for
[the reasons the method post sets out](/blog/benchmarking-php-without-lying-to-yourself/).

Symbol counts come from `nm` on this binary and are a fact about this build
rather than about PHP.

## From a listing to a function

[The listing gives you an opcode name and the operand classes](/blog/reading-an-opcode-dump/),
and those two facts pick a handler. The engine does not have one `ADD`; it has a
generated family, and this binary carries 2,005 symbols matching `_SPEC_` and
`_HANDLER`.

The definition in [`zend_vm_def.h`](https://github.com/php/php-src/blob/PHP-8.5/Zend/zend_vm_def.h) that generates them says
which classes it covers:

```c
ZEND_VM_HOT_NOCONSTCONST_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV)
```

Two operand positions, each either a constant or one of the three
non-constant classes, minus the constant-constant pair the compiler folds away
before the VM ever sees it. That is the compile-time half of specialization, and
it is settled the moment the file is compiled.

It is also, on this workload, not worth anything measurable:

| Addition | Net of the loop |
|---|---|
| `$a + 11` — one constant operand | 1.113 ns (1.071–1.166) |
| `$a + $b` — two variables | 1.076 ns (1.055–1.109) |

Different handler functions, indistinguishable timings. The classes save the
handler from having to ask where its operands live; they do not change what it
does with them.

## The type check is a branch inside the handler

What the handler does with them is the interesting half, and it is readable:

```c
op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
if (ZEND_VM_SPEC && OP1_TYPE == IS_CONST && OP2_TYPE == IS_CONST) {
    /* pass */
} else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) {
    if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
        result = EX_VAR(opline->result.var);
        fast_long_add_function(result, op1, op2);
        ZEND_VM_NEXT_OPCODE();
    } else if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_DOUBLE)) {
```

Long and long, long and double, double and double, double and long — four fast
paths, checked in that order, each returning to the dispatch loop immediately.
Anything else falls past them to a general routine that has to work out what the
operands are.

Which is exactly the shape the measurements have:

| Operands | Net of the loop |
|---|---|
| `int + int` | 1.076 ns (1.055–1.109) |
| `float + float` | 1.052 ns (1.015–1.959) |
| `int + float` | 1.104 ns (1.046–2.050) |
| numeric `string + int` | 10.514 ns (10.141–12.142) |

The three combinations with a fast path are within noise of each other. The one
without is 9.8 times more expensive, for an addition whose operands look
identical in the dump. A string that holds `'7'` is not a number until something
makes it one, and the something is on the slow path.

## The second specialization, and what installs it

That accounts for the third row of the opening table. It does not account for the
fourth, because both versions of that loop add two integers.

The engine has a second family of handlers, declared with a condition rather than
with operand classes:

```c
ZEND_VM_HOT_TYPE_SPEC_HANDLER(ZEND_ADD, (op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG), ZEND_ADD_LONG, CONST|TMPVARCV, CONST|TMPVARCV, SPEC(NO_CONST_CONST,COMMUTATIVE))
ZEND_VM_HOT_TYPE_SPEC_HANDLER(ZEND_ADD, (res_info == MAY_BE_LONG && op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG), ZEND_ADD_LONG_NO_OVERFLOW, ...)
```

`op1_info` and `op2_info` are not run-time values. They are what type inference
concluded about the operands, and type inference is something the optimizer does
— which is why the handler is only installed when the optimizer ran.

That is the whole explanation of the opening table. With OPcache off there is no
inference, every `ADD` gets the general handler, and the `int` declarations
change nothing. With OPcache on the declarations let inference prove both
operands are long, `ZEND_ADD_LONG_NO_OVERFLOW` is installed, and the branch chain
disappears because it has already been decided.

The untyped version stays at 2.562 ns in both columns because nothing was
provable about `$a` and `$b`.

One caution on that measurement: the typed row's range is 1.743 to 2.592, wide
enough to overlap the untyped one at its worst. The median is clearly separated
and the untyped range is not, but this is a 23% effect on a microbenchmark, not
a result to carry into a capacity plan.

## What the dispatch itself costs

The loop scaffolding gives a rough figure for the machinery around all of this.
An empty `for` body is three instructions:

```text
0003 PRE_INC CV1($i)
0004 T2 = IS_SMALLER CV1($i) CV0($n)
0005 JMPNZ T2 0003
```

Twenty million iterations of that took 2.015 ns each, so roughly two-thirds of a
nanosecond per opcode, dispatch included. Adding one `ADD` to the body took the
iteration to 3.091 ns, so that opcode cost 1.076 — more than the average, which
is what an average over specialized handlers is worth.

## There is no dispatch loop

"The VM loops over opcodes" is the shape [the lifecycle post
described](/blog/what-happens-when-php-runs-your-code/), and on this build it is
not literally true. Every instruction carries the address of its own handler, and
the handler ends by jumping to the next one's — the macros are in the generated
[`zend_vm_execute.h`](https://github.com/php/php-src/blob/PHP-8.5/Zend/zend_vm_execute.h):

```c
#define HYBRID_NEXT()     HYBRID_JIT_GUARD(); goto *(void**)(OPLINE->handler)
#define HYBRID_SWITCH()   HYBRID_NEXT();
```

`OPLINE->handler` is a function pointer stored in the instruction when the op
array was built, and `goto *` is a computed goto into it. There is no switch to
evaluate, no call to make, and no loop edge to come back to — the sequence of
handlers is a chain of jumps that ends when one of them returns.

That is where the compile-time half of specialization is spent. Choosing the
handler by operand class costs nothing at run time because the choice was written
into the instruction; what the pointer points at is already the version that
knows where its operands live. It is also why the timings for the constant and
variable operand forms were indistinguishable: the work the classes save happens
before the program starts.

`HYBRID_JIT_GUARD()` is the other thing in that macro, and it is how the JIT gets
a foot in the door — a counter checked on the way to the next handler, which is
what decides that a loop has become hot.

## The VM kind is a build decision

How the loop reaches a handler at all depends on how PHP was compiled. There are
four kinds: `CALL` makes every handler a function and calls it; `SWITCH` uses a
switch statement; `GOTO` uses computed gotos; `HYBRID` combines the last two so
the common path is a computed goto and handlers remain reachable as functions for
the JIT.

No userland function reports which one you have. On a binary you can inspect,
`nm` answers it:

```text
_zend_jit_hybrid_func_hot_counter_stub
_zend_jit_hybrid_loop_hot_counter_stub
_zend_jit_hybrid_profile_jit_stub
```

Those stubs exist only in the hybrid build, and of the 2,005 handler symbols in
this binary, 1,023 are `_TAILCALL_` variants — the second entry point the hybrid
VM needs. This is one build's answer; another distribution may have chosen
differently, and the way to find out is the same command.

## What the JIT changes, and a measurement caution

The dispatch loop is the layer the JIT removes, so it is worth one row — with a
warning attached. The same three measurements, all with OPcache on so that the
only variable is the JIT:

| | JIT off | Tracing JIT |
|---|---|---|
| Empty loop | 2.037 ns (2.000–2.100) | 0.780 ns (0.702–0.901) |
| `int + int` | 3.149 ns (3.120–3.178) | 2.182 ns (2.153–2.411) |
| numeric `string + int` | 12.259 ns (11.903–12.871) | 11.425 ns (10.940–12.468) |

The absolute numbers all improve, and unevenly: the empty loop is 2.6 times
faster and the string addition is 7% faster, because the part the JIT removes is
the scaffolding rather than the work. What stops working is the arithmetic used
everywhere above: subtracting the empty loop assumes the loop costs the same in
both configurations, and here it does not, because the scaffolding is exactly
what the JIT is best at removing. Net-of-baseline figures are only comparable
within one configuration, which is why every table above states which one it is.

## What to take from this

Benchmark type declarations with OPcache on, or do not benchmark them. The
difference between the two columns of the opening table is the difference between
measuring your production build and measuring something else, and it is the whole
result.

Look at operand types before operand counts. A loop whose arithmetic touches a
string, even a string that holds digits, is on a path an order of magnitude away
from the one that looks identical in the listing. Casting once outside the loop
moves it back.

And read a dump as naming a family rather than a function.
[The listing tells you which opcode and which operand classes](/blog/reading-an-opcode-dump/);
what it cannot tell you is whether the optimizer proved anything about the
values, which is the difference between the two columns this post opened with —
and [the subject of the next post in this
series](/blog/what-the-optimizer-does-before-your-code-runs/).
