Internals8 min read

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.

Written by

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

Arrow keys to move, Enter to open.