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.
Alden PikeBackend engineer and technical writer

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.
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.
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,
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 that generates them says
which classes it covers:
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:
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:
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:
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, 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:
#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:
_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; 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.
Frequently asked
Do scalar type declarations make PHP faster?
With OPcache on, they can. The same loop with int parameters ran at 1.964 ns an iteration against 2.562 for the untyped version. With OPcache off both ran at 2.56, because the handler that exploits the types is installed by the optimizer and there is no optimizer.
Why is adding a numeric string so much slower than adding an integer?
Because it misses every fast path in the handler. The general ADD handler checks for long and double operands in that order and falls through to a general routine that has to interpret the string. Measured net of the loop, integers cost 1.076 ns and a numeric string 10.514.
What does an opcode dispatch actually cost?
On this build, about two-thirds of a nanosecond on average. An empty for loop is three opcodes per iteration - PRE_INC, IS_SMALLER, JMPNZ - and ran at 2.015 ns. That average hides real variation, because the whole point of specialization is that opcodes do not cost the same.
Which VM does my PHP use?
Whichever kind it was built with - CALL, SWITCH, GOTO or HYBRID - and there is no userland function that reports it. On a binary you can inspect, the presence of zend_jit_hybrid stubs and tail-call handler symbols is the evidence; this build has both.
Alden Pike
Alden Pike writes about PHP internals, performance, architecture and production behavior — the layer beneath the frameworks. Measurements over assumptions, trade-offs over universal rules.
More about the author

