# 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.

- Published: 2026-10-18
- Tags: zend-vm, opcache, jit
- Source: https://elephantphp.com/blog/reading-an-opcode-dump/
- Language: en-US
- Author: Alden Pike

---
Two tools dumped the same function and printed different programs. Both listings
are correct.

```php
<?php

declare(strict_types=1);

function totalOf(array $prices, int $shipping): int
{
    $total = 0;
    foreach ($prices as $price) {
        $total += $price;
    }

    return $total + $shipping;
}
```

```text
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
```

```text
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:

```bash
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](/blog/what-happens-when-php-runs-your-code/) rather
than this post's.

[`phpdbg`](https://www.php.net/manual/en/book.phpdbg.php) ships with PHP and takes a
function name:

```bash
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:

```text
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:

```text
$_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](/blog/property-hooks-and-what-the-compiler-emits/).

`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:

```text
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`](https://github.com/php/php-src/blob/PHP-8.5/Zend/zend_compile.h) names five:

```c
#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
<?php

declare(strict_types=1);

function operandZoo(array $rows, string $prefix): string
{
    $count = count($rows);
    $label = $prefix . ':' . $count;

    return strtoupper($label);
}
```

```text
; 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
```

```text
; 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:

```text
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:

```text
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:

```text
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](/blog/how-the-zend-vm-dispatches-an-opcode/),
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:

```text
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](/blog/property-hooks-and-what-the-compiler-emits/).
