Two tools printed different opcodes for one function
phpdbg showed thirteen instructions and the optimizer showed nine. Both were right. How to read the notation, and which listing is the one that runs.
Alden PikeBackend engineer and technical writer

Two tools dumped the same function and printed different programs. Both listings are correct.
<?php
declare(strict_types=1);
function totalOf(array $prices, int $shipping): int
{
$total = 0;
foreach ($prices as $price) {
$total += $price;
}
return $total + $shipping;
}
phpdbg -ptotalOf sample.php
L0005 0000 CV0($prices) = RECV 1
L0005 0001 CV1($shipping) = RECV 2
L0007 0002 ASSIGN CV2($total) int(0)
L0008 0003 V5 = FE_RESET_R CV0($prices) 0007
L0008 0004 FE_FETCH_R V5 CV3($price) 0007
L0009 0005 ASSIGN_OP (ADD) CV2($total) CV3($price)
L0008 0006 JMP 0004
L0008 0007 FE_FREE V5
L0012 0008 T7 = ADD CV2($total) CV1($shipping)
L0012 0009 VERIFY_RETURN_TYPE T7
L0012 0010 RETURN T7
L0013 0011 VERIFY_RETURN_TYPE
L0013 0012 RETURN null
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 sample.php
0000 CV0($prices) = RECV 1
0001 CV1($shipping) = RECV 2
0002 CV2($total) = QM_ASSIGN int(0)
0003 V4 = FE_RESET_R CV0($prices) 0007
0004 FE_FETCH_R V4 CV3($price) 0007
0005 ASSIGN_OP (ADD) CV2($total) CV3($price)
0006 JMP 0004
0007 FE_FREE V4
0008 T4 = ADD CV2($total) CV1($shipping)
0009 VERIFY_RETURN_TYPE T4
0010 RETURN T4
Thirteen instructions and eleven. Different slot numbers on the same values. One listing has source line numbers and the other does not. Everything the rest of this archive quotes from a dump depends on knowing which is which, so this post is the notation and nothing else.
How these listings were produced
PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop running macOS.
This build has OPcache compiled in, so opcache.enable_cli=1 is enough to reach
the optimizer without loading anything.
There is nothing timed here. Dumps are version-specific — the optimizer changes between releases and so do the opcodes it can produce — so treat every listing below as 8.5.10’s answer rather than PHP’s.
Three routes, two of them available
opcache.opt_debug_level is a bit field, and two values matter:
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 sample.php # before optimizer
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 sample.php # after optimizer
Both print every function in the file to standard error, ahead of the script’s
own output, and both run the script afterwards. 0x40000 prints the listing
again with basic-block boundaries, which is
the optimizer’s own subject rather
than this post’s.
phpdbg ships with PHP and takes a
function name:
phpdbg -ptotalOf sample.php
It prints one function, with L numbers giving the source line each instruction
came from — the only route here that shows them. It does not run the script.
The third route in circulation is the VLD extension, which adds a vld.active
flag and prints a listing with branch analysis. It is not installed on this
build, so I have not measured what it shows or where it differs. The two routes
above need nothing installed, which is why the rest of the archive uses them.
The header line, and finding your function
Above every listing sits a header the dumps share:
totalOf:
; (lines=11, args=2, vars=4, tmps=1)
; (after optimizer)
; /path/to/sample.php:5-13
lines is the instruction count, which is the number to compare between two
versions of the same function. args is the declared parameter count. vars is
how many compiled variables the frame holds — every named local, including the
parameters, because a parameter is a CV that RECV fills. tmps is the size of
the temporary area, and it is the figure the optimizer moves most: six to one in
the second example below.
Finding the function you want means knowing what the dump calls it. The name is printed exactly as the engine holds it:
$_main:
{closure:/path/to/names.php:14}:
{closure:/path/to/names.php:15}:
Cart::add:
Cart::make:
Cart::$count::get:
$_main is the file’s top-level code. A closure and an arrow function are both
named by file and line, so two closures declared on adjacent lines are
distinguishable and a closure moved by an edit is not. Methods are
Class::method. A property hook is Class::$property::get, which is
how a hooked property turns out to be a function with its own opcodes.
phpdbg -p takes the plain function name and matches the same set, so
phpdbg -pCart::add names.php works and phpdbg -p'{closure:...}' is the reason
the other route is easier for closures.
Reading a line
Every instruction is the same shape:
0008 T4 = ADD CV2($total) CV1($shipping)
The number at the front is the instruction’s position in the op array, which is
also what jump targets refer to — JMP 0004 on line 0006 above sends execution
back to the FE_FETCH_R. Then the result operand, an equals sign, the opcode
name, and up to two operands.
The letters are the whole trick. Each operand has a storage class, and
zend_compile.h names five:
#define IS_UNUSED 0 /* Unused operand */
#define IS_CONST (1<<0)
#define IS_TMP_VAR (1<<1)
#define IS_VAR (1<<2)
#define IS_CV (1<<3) /* Compiled variable */
In a listing they appear as:
| Class | Printed as | What it is |
|---|---|---|
IS_CV |
CV2($total) |
A named userland variable, resolved to a numbered slot at compile time |
IS_TMP_VAR |
T4 |
An intermediate the engine can discard freely |
IS_VAR |
V4 |
An intermediate that may need releasing — a call’s return value, an iterator |
IS_CONST |
int(0), string(":") |
A literal from the compiled constant table |
IS_UNUSED |
nothing at all | The operand slot the opcode does not use |
VERIFY_RETURN_TYPE on line 0011 of the phpdbg listing has no operands printed
at all. That is IS_UNUSED twice, not a truncated line.
Now the trap. In the optimized listing above, V4 appears on line 0003 and T4
on line 0008. They are not the same slot read twice. The number is an index and
the letter says which class it indexes, so the engine reuses index 4 for an
unrelated temporary once the iterator is freed.
The result operand is where the optimizer works
A second function, chosen for having one of everything:
<?php
declare(strict_types=1);
function operandZoo(array $rows, string $prefix): string
{
$count = count($rows);
$label = $prefix . ':' . $count;
return strtoupper($label);
}
; before optimizer — (lines=14, args=2, vars=4, tmps=6)
0002 T4 = COUNT CV0($rows)
0003 ASSIGN CV2($count) T4
0004 T6 = CONCAT CV1($prefix) string(":")
0005 T7 = CONCAT T6 CV2($count)
0006 ASSIGN CV3($label) T7
0007 INIT_FCALL 1 96 string("strtoupper")
0008 SEND_VAR CV3($label) 1
0009 V9 = DO_ICALL
0010 VERIFY_RETURN_TYPE V9
0011 RETURN V9
0012 VERIFY_RETURN_TYPE
0013 RETURN null
; after optimizer — (lines=9, args=2, vars=4, tmps=1)
0002 CV2($count) = COUNT CV0($rows)
0003 T4 = FAST_CONCAT CV1($prefix) string(":")
0004 CV3($label) = FAST_CONCAT T4 CV2($count)
0005 INIT_FCALL 1 96 string("strtoupper")
0006 SEND_VAR CV3($label) 1
0007 V4 = DO_ICALL
0008 RETURN V4
Six temporaries become one. The mechanism is visible in the result operand:
COUNT wrote into T4 and a separate ASSIGN copied it into CV2; afterwards
COUNT writes into CV2 directly and the ASSIGN is gone. The same happens to
the second concatenation. Two instructions and two temporaries removed by
retargeting a result.
The trailing VERIFY_RETURN_TYPE and RETURN null also disappear, because they
are unreachable after line 0011, and the surviving VERIFY_RETURN_TYPE goes too
once the optimizer knows strtoupper() returns a string.
Two more pieces of notation are in there. INIT_FCALL 1 96 string("strtoupper")
carries the argument count and a cache slot offset — 96 is a byte offset into
the run-time cache, not an instruction number. And in the first listing,
ASSIGN_OP (ADD) puts the operator in parentheses because a compound assignment
is a single opcode with the operation as an extension value, which is why
$total += $price is one instruction where the spelled-out version would be two.
LIVE RANGES, and what makes a V a V
Both optimized listings end with a table the earlier one omitted here:
LIVE RANGES:
4: 0004 - 0007 (loop)
4: 0009 - 0010 (tmp/var)
Slot 4 twice, for two different lifetimes: the foreach iterator from
instruction 4 to 7, and the addition’s temporary from 9 to 10. The table exists
because the engine has to release those values if an exception unwinds the frame
mid-range, and it is the practical difference between the two temporary classes.
A T holding an integer needs nothing; a V holding an iterator or a returned
object does.
The opcode name is a family
The last thing to know before quoting a dump as evidence: the name in the listing
is not the code that runs. ADD is compiled into a specialized handler chosen
from the operand classes, and the generated VM file has one for each combination:
ZEND_ADD_SPEC_CONST_CONST_HANDLER
ZEND_ADD_SPEC_CONST_TMPVARCV_HANDLER
ZEND_ADD_SPEC_TMPVARCV_CONST_HANDLER
ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER
TMPVARCV is one handler shared by the three non-constant classes. On top of
that come type specializations the compiler selects when it can prove the
operands’ types:
ZEND_ADD_LONG_SPEC_TMPVARCV_TMPVARCV_HANDLER
ZEND_ADD_LONG_NO_OVERFLOW_SPEC_TMPVARCV_TMPVARCV_HANDLER
ZEND_ADD_DOUBLE_SPEC_TMPVARCV_TMPVARCV_HANDLER
Counting unique _SPEC_ handler names in Zend/zend_vm_execute.h on the 8.5
branch gives 1,995. That is handlers, not opcodes — the opcode list is a couple
of hundred long, and the rest is the cross product with operand classes and the
tail-call variants the hybrid VM uses.
So a dump tells you which opcode and which operand classes, and those two facts select the handler. What the handler then does with them, and why the same listing can run different code depending on what the optimizer proved, is the next post in this series.
Mapping a source line to instructions
The reason to keep phpdbg around is the L column, because it is the only route
here that says which line produced which instruction. Reading the first listing
in this post again:
L0009 0005 ASSIGN_OP (ADD) CV2($total) CV3($price)
Line 9 of the file is $total += $price;, and it is one instruction. The
foreach above it is three — FE_RESET_R, FE_FETCH_R, FE_FREE — plus the
JMP that closes the loop, all tagged L0008. So a profiler pointing at line 8
is pointing at four instructions, three of which are the iterator and one of
which is the jump.
The optimized listing has no L column, so the mapping has to be carried across
by hand: same opcode names, same order, different slot numbers. That is
tractable for a function of this size and it is the reason to dump the function
rather than the file. It is also why a claim of the form “this line compiles to
one opcode” should name which listing it came from. The return on line 12 is
three instructions in both listings of this function; in the second example
further down, the same construct is five before the optimizer and four after it,
because the return type check was proved unnecessary and removed.
Using this on your own code
Dump the function you are actually asking about, not a reduction of it. The optimizer’s decisions depend on what it can prove, and a three-line extract of a twenty-line function proves different things.
Read the optimized listing when the question is what production does, and the
compiler’s listing when the question is what you wrote. A claim about performance
belongs against 0x20000, because that is what the VM walks when OPcache is on.
A claim about what a language construct compiles to belongs against 0x10000 or
phpdbg, before anything has been folded away.
And check the operand classes before believing a comparison. Two listings with the same opcode names can select different handlers, which is how two property declarations that dump identically can cost nine times apart.
Frequently asked
How do I get opcodes out of PHP without installing an extension?
Two ways on a stock build with OPcache. php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 file.php prints every function after the optimizer, and phpdbg -pfunctionName file.php prints one function as the compiler produced it, with source line numbers.
Why do phpdbg and opt_debug_level show different opcodes?
Because they show different stages. phpdbg prints what the compiler emitted; 0x20000 prints what OPcache's optimizer left behind, which is what the VM executes when OPcache is enabled. One function here was thirteen instructions in the first and eleven in the second.
What is the difference between a T and a V operand?
Both are temporaries, but a V can hold something that needs releasing - a function's return value, a foreach iterator - so it appears in the LIVE RANGES table with the span it must survive. A T holds a simple intermediate the engine can drop without ceremony.
Does the dump show which handler actually runs?
No. It shows the opcode name, and the engine picks a specialized handler from the operand classes and the runtime types. One ADD maps to a family that includes ZEND_ADD_LONG_NO_OVERFLOW_SPEC_TMPVARCV_TMPVARCV_HANDLER, and the generated VM file defines 1,995 such handlers in total.
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

