Two hands guiding a hand plane along a light-coloured board. A shaving runs out of the plane, a second one curls up on the wood in front of the blade. A heap of shavings lies below the board. The board itself is smooth and shows its grain; nothing has been added to it.
The board has stayed the same. The difference is in what is lying next to it.

Every opcode dump in the last article carried a line I left uncommented: ; (before optimizer). There is an after as well, and between the two sits the OPcache optimiser. Everything that follows refers to PHP 8.6.

In conversations, I keep running into the assumption that OPcache is a cache and nothing else: compile once, keep the result, done. That is an obvious reading, the name says as much. It is only half of it, though. Between the compiler and the executor, OPcache rebuilds the bytecode, and thoroughly enough that the executor ends up running different instructions from the ones the compiler produced. In one case it even runs different handlers.

What the compiler leaves behind

The compiler translates your source code into opcodes, and without OPcache that is the whole story. The executor gets exactly what the compiler produced and works through it.

With OPcache loaded, one step comes in between. Before a compiled script goes into shared memory, OPcache calls zend_optimize_script(). That function and everything it calls lives in Zend/Optimizer/, not in ext/opcache/: the optimiser is part of the engine, but only OPcache ever sets it going. No OPcache, no optimiser.

The timing here is different from that of the just-in-time compiler (JIT). The optimiser runs while compilation happens, and its result is kept. It can therefore afford analyses that would be far too expensive per request, because per request they do not happen at all.

The path from source code to the executor, with and without OPcache
Without OPcache the bytecode goes straight from pass_two() into the executor. With OPcache there are fifteen passes in between, and the bytecode is sent through pass_two() a second time afterwards.

Two numbers from php.ini

You know opcache.opt_debug_level from the last article: 0x10000 prints the bytecode as the compiler produced it, 0x20000 prints it again after the optimiser has been over it. Together, 0x30000.

The second number is opcache.optimization_level, a bit mask defaulting to 0x7FFEBFFF in which every bit switches one pass on or off. Setting it to 0 switches the optimiser off without switching the cache off, and that is what we need for measuring later.

Take the program from the last article. CV0 and CV1 in the output are compiled variables, the variables written in the source:

<?php declare(strict_types=1);
$a = 2;
$b = 3;

print $a + $b;
$_main:
     ; (lines=5, args=0, vars=2, tmps=3)
     ; (before optimizer)
0000 ASSIGN CV0($a) int(2)
0001 ASSIGN CV1($b) int(3)
0002 T4 = ADD CV0($a) CV1($b)
0003 ECHO T4
0004 RETURN int(1)

$_main:
     ; (lines=5, args=0, vars=2, tmps=1)
     ; (after optimizer)
0000 ASSIGN CV0($a) int(2)
0001 ASSIGN CV1($b) int(3)
0002 T2 = ADD CV0($a) CV1($b)
0003 ECHO T2
0004 RETURN int(1)

Five instructions before, five instructions after. T4 became T2 and tmps=3 became tmps=1, and that is all. This was the moment I assumed I had done something wrong.

I had not. $a and $b are at file scope here, which makes them global variables. An include, an access through $GLOBALS, an extract(): there are too many ways for somebody else to reach these variables for the optimiser to be allowed to assume anything about them.

The same three lines inside a function:

<?php declare(strict_types=1);
function f()
{
    $a = 2;
    $b = 3;

    return $a + $b;
}
f:
     ; (lines=5, args=0, vars=2, tmps=3)
     ; (before optimizer)
0000 ASSIGN CV0($a) int(2)
0001 ASSIGN CV1($b) int(3)
0002 T4 = ADD CV0($a) CV1($b)
0003 RETURN T4
0004 RETURN null

f:
     ; (lines=1, args=0, vars=0, tmps=0)
     ; (after optimizer)
0000 RETURN int(5)

Five instructions become one, and two variables disappear entirely. The optimiser works on functions. At file scope it does almost nothing, so the first attempt at watching it is usually a disappointment.

Sixteen numbers, fifteen passes

The bits of that mask are declared in Zend/Optimizer/zend_optimizer.h, each with a comment after it:

#define ZEND_OPTIMIZER_PASS_1       (1<<0)   /* Simple local optimizations   */
#define ZEND_OPTIMIZER_PASS_2       (1<<1)   /*                              */
#define ZEND_OPTIMIZER_PASS_3       (1<<2)   /* Jump optimization            */
#define ZEND_OPTIMIZER_PASS_4       (1<<3)   /* INIT_FCALL_BY_NAME -> DO_FCALL */
#define ZEND_OPTIMIZER_PASS_5       (1<<4)   /* CFG based optimization       */
#define ZEND_OPTIMIZER_PASS_6       (1<<5)   /* DFA based optimization       */
#define ZEND_OPTIMIZER_PASS_7       (1<<6)   /* CALL GRAPH optimization      */
#define ZEND_OPTIMIZER_PASS_8       (1<<7)   /* SCCP (constant propagation)  */
#define ZEND_OPTIMIZER_PASS_9       (1<<8)   /* TMP VAR usage                */
#define ZEND_OPTIMIZER_PASS_10      (1<<9)   /* NOP removal                 */
#define ZEND_OPTIMIZER_PASS_11      (1<<10)  /* Merge equal constants       */
#define ZEND_OPTIMIZER_PASS_12      (1<<11)  /* Adjust used stack           */
#define ZEND_OPTIMIZER_PASS_13      (1<<12)  /* Remove unused variables     */
#define ZEND_OPTIMIZER_PASS_14      (1<<13)  /* DCE (dead code elimination) */
#define ZEND_OPTIMIZER_PASS_15      (1<<14)  /* (unsafe) Collect constants */
#define ZEND_OPTIMIZER_PASS_16      (1<<15)  /* Inline functions */

#define ZEND_OPTIMIZER_IGNORE_OVERLOADING (1<<16)  /* (unsafe) Ignore possibility of operator overloading */

#define ZEND_OPTIMIZER_NARROW_TO_DOUBLE   (1<<17)  /* try to narrow long constant assignments to double */

#define DEFAULT_OPTIMIZATION_LEVEL  "0x7FFEBFFF"

The comment after pass 2 is empty, and the name appears in no .c file of the optimiser. This numbering has grown rather than been designed: passes have arrived, merged and disappeared again over the years, and the numbers of the ones that went have stayed vacant. The numbers go up to sixteen, the passes are fifteen.

Compared with ZEND_OPTIMIZER_ALL_PASSES, which is 0x7FFFFFFF, the default is missing exactly two bits: pass 15 and ZEND_OPTIMIZER_IGNORE_OVERLOADING. Those are the two whose comment starts with (unsafe). Everything else is on, including the inlining from pass 16. The two bits after pass 16 are not passes anyway, but permissions granted to type inference. ZEND_OPTIMIZER_NARROW_TO_DOUBLE is on and may turn an $x = 0 into an $x = 0.0 when no use of $x gets a different result out of it: a long|double becomes a double, and what that is worth comes further down.

The passes are also interlocked, and in both directions. Pass 10 removes NOP instructions but only runs when pass 5 is off, because the control flow graph optimisation takes care of them anyway. Pass 6 only runs when pass 7 is off, because data flow analysis is driven differently with a call graph than without one. Turning individual bits therefore gets you into combinations nobody has tried.

The low bits of opcache.opt_debug_level print the bytecode after a particular pass: 1 after pass 1, 16 after pass 5, 128 after constant propagation, 8192 after dead code elimination. That lets you follow the whole path, which is what we are about to do.

Backwards and forwards again

One detail first, because it connects straight back to the last article.

After compilation, a step called pass_two() runs over every function. It turns the abstract instruction into an executable one: constants become positions in the literal table, jump targets become addresses, and every instruction gets its handler, the field the last article revolved around.

The optimiser cannot work on that form. It moves instructions, removes some and rewrites others; addresses pointing at fixed positions would be wrong afterwards. So it undoes pass_two() first:

static void zend_optimize_op_array(zend_op_array      *op_array,
                                   zend_optimizer_ctx *ctx)
{
    /* Revert pass_two() */
    zend_revert_pass_two(op_array);

    /* Do actual optimizations */
    zend_optimize(op_array, ctx);

    /* Redo pass_two() */
    zend_redo_pass_two(op_array);

    if (op_array->live_range) {
        zend_recalc_live_ranges(op_array, NULL);
    }
}

The last step is the one that matters here. zend_redo_pass_two() walks all the instructions again and assigns a handler to each of them. Which one that is, is the question I come back to below.

One function through the pipeline

Take a function nobody would write this way, but one that gives several passes something to do:

<?php declare(strict_types=1);
function f(float $x)
{
    $factor = 2;
    $unused = $x * 3;

    if ($factor > 1) {
        return $x * $factor;
    }

    return $x;
}

The compiler turns it into ten instructions:

f:
     ; (lines=10, args=1, vars=3, tmps=5)
     ; (before optimizer)
0000 CV0($x) = RECV 1
0001 ASSIGN CV1($factor) int(2)
0002 T4 = MUL CV0($x) int(3)
0003 ASSIGN CV2($unused) T4
0004 T6 = IS_SMALLER int(1) CV1($factor)
0005 JMPZ T6 0008
0006 T7 = MUL CV0($x) CV1($factor)
0007 RETURN T7
0008 RETURN CV0($x)
0009 RETURN null

Pass 5 builds the control flow graph and throws away whatever no path reaches. That catches the RETURN null the compiler puts after every function so that falling through without a return still returns something. Nine instructions.

For the next passes the optimiser converts the function into static single assignment form, SSA for short: every assignment to a variable gets its own number, so that each number is written exactly once. That sounds like bookkeeping, and it is the point at which an analysis can say anything at all, because every use now belongs to exactly one definition. The output shows that form along with the types that fall out of it; BB0, BB1 and BB2 are the basic blocks of the control flow graph:

f:
     ; (lines=9, args=1, vars=3, tmps=5, ssa_vars=10, no_loops)
     ; (after sccp pass)
     ; return  [double]
BB0:
     ; start lines=[0-3]
     ; to=(BB1)
0000 #3.CV0($x) [double] = RECV 1
0001 ASSIGN #1.CV1($factor) NOVAL [undef] -> #4.CV1($factor) [long] RANGE[2..2] int(2)
0002 #5.T4 [double] = MUL #3.CV0($x) [double] int(3)
0003 ASSIGN #2.CV2($unused) NOVAL [undef] -> #6.CV2($unused) NOVAL [double] #5.T4 [double]

BB1:
     ; follow exit lines=[6-7]
     ; from=(BB0)
0006 #9.T7 [double] = MUL #3.CV0($x) [double] int(2)
0007 RETURN #9.T7 [double]

BB2:
     ; target exit unreachable lines=[8-8]
0008 NOP

#3.CV0($x) [double] reads: the third SSA variable, it belongs to $x, and it is a float because that is what the signature says. #4.CV1($factor) [long] RANGE[2..2] is more precise: not just an integer, but an integer between 2 and 2. That decides 1 < $factor, the jump goes away, and the second return ends up in a block the output marks as unreachable. Line 0006 now reads MUL $x int(2), because $factor has been replaced by its value.

This analysis is called SCCP, sparse conditional constant propagation. It propagates constants and decides branches at the same time rather than one after the other, and that finds constants which a round of constant folding followed by a round of branch removal does not find.

Dead code elimination comes next:

f:
     ; (after dce pass)
BB0:
0000 #3.CV0($x) [double] = RECV 1

BB1:
0006 #9.T7 [double] = MUL #3.CV0($x) [double] int(2)
0007 RETURN #9.T7 [double]

$factor is no longer read anywhere and $unused never was, so both assignments go, along with the multiplication nobody needs. After the NOPs are cleaned up, the temporaries merged and the variables renumbered, this is what is left:

f:
     ; (lines=3, args=1, vars=1, tmps=1)
     ; (after optimizer)
0000 CV0($x) = RECV 1
0001 T1 = ADD CV0($x) CV0($x)
0002 RETURN T1

Ten instructions have become three, and MUL $x, 2 has become ADD $x, $x. That last rewrite sits in Zend/Optimizer/dfa_pass.c, with a comment describing it in one line:

} else if (opline->opcode == ZEND_MUL
 && (OP1_INFO() & ((MAY_BE_ANY|MAY_BE_UNDEF)-(MAY_BE_LONG|MAY_BE_DOUBLE))) == 0) {
    zv = CT_CONSTANT_EX(op_array, opline->op2.constant);

    if ((Z_TYPE_INFO_P(zv) == IS_LONG
      && Z_LVAL_P(zv) == 2)
     || (Z_TYPE_INFO_P(zv) == IS_DOUBLE
      && Z_DVAL_P(zv) == 2.0
      && !(OP1_INFO() & MAY_BE_LONG))) {

// op_1: #v.? = MUL #x.? [double,long], 2 => #v.? = ADD #x.?, #x.?

        opline->opcode = ZEND_ADD;
        opline->op2_type = opline->op1_type;
        opline->op2.var = opline->op1.var;

        /* ... */
    }
}

Note the condition above it. The rewrite only applies once type inference has ruled out that the operand is anything other than an integer or a float. For a string, $x * 2 is not the same as $x + $x, and for an object with overloaded operators the gap is wider still.

Type inference picks the handler

Which brings me back to the earlier question: which handler does zend_redo_pass_two() assign?

The last article was about the 987 handler functions the generator produces, specialised by the kind of the operands: constant, temporary, variable or compiled variable. The engine makes that choice without OPcache as well.

If the optimiser has run a data flow analysis, it uses a different function, one that takes the inferred types into account too. That function lives in zend_vm_execute.h, so it is generated rather than written, like the handlers themselves:

ZEND_API void ZEND_FASTCALL zend_vm_set_opcode_handler_ex(zend_op* op, uint32_t op1_info, uint32_t op2_info, uint32_t res_info)
{
    uint8_t opcode = zend_user_opcodes[op->opcode];
    uint32_t spec = zend_spec_handlers[opcode];
    switch (opcode) {
        case ZEND_ADD:
            if (res_info == MAY_BE_LONG && op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG) {
                /* ... */
                spec = 2586 | SPEC_RULE_OP1 | SPEC_RULE_OP2 | SPEC_RULE_COMMUTATIVE;
                /* ... */
            } else if (op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG) {
                /* ... */
            } else if (op1_info == MAY_BE_DOUBLE && op2_info == MAY_BE_DOUBLE) {
                /* ... */
            }
            break;

        /* ... */
    }
    op->handler = zend_opcode_handlers[zend_vm_get_opcode_handler_idx(spec, op)];
}

For an addition there are three such special cases: both operands integers and the result one as well, both operands integers with a possible overflow, both operands floats. In total there are 42 of these type-specialised templates in zend_vm_def.h, for addition, subtraction and multiplication, the comparisons, PRE_INC, SEND_VAL, FETCH_DIM_R and a few more. Division has none: 7 / 2 is a float and 1 / 0 throws, so there is no case in which two integers are guaranteed to produce an integer.

The first case looks like this:

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, CONST|TMPVARCV, CONST|TMPVARCV, SPEC(NO_CONST_CONST,COMMUTATIVE))
{
    USE_OPLINE
    zval *op1, *op2, *result;

    op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
    op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
    result = EX_VAR(opline->result.var);
    ZVAL_LONG(result, Z_LVAL_P(op1) + Z_LVAL_P(op2));
    ZEND_VM_NEXT_OPCODE();
}

Fetch two values, add them, carry on. No type check, no overflow check, no branch for strings, no branch for overloaded operators, no helper call for the general case. Compare that with the template for ADD from the last article, which starts with a cascade of type checks and ends by jumping into a helper.

How the handler for an addition is chosen, with and without OPcache
The same instruction, two different handlers. Which one the executor jumps to depends on whether a type inference ran while the script was compiled.

This is where it shows most clearly that OPcache does two things. The same addition in the same function runs a different handler depending on whether the extension is loaded, and that has nothing to do with caching.

Inlining that almost never happens

Pass 16 is called Inline functions, and what it does is a great deal smaller than that name suggests:

static void zend_try_inline_call(zend_op_array *op_array, const zend_op *fcall, zend_op *opline, const zend_function *func)
{
    const uint32_t no_discard = RETURN_VALUE_USED(opline) ? 0 : ZEND_ACC_NODISCARD;

    if (func->type == ZEND_USER_FUNCTION
     && !(func->op_array.fn_flags & (ZEND_ACC_ABSTRACT|ZEND_ACC_HAS_TYPE_HINTS|ZEND_ACC_DEPRECATED|no_discard))
        /* TODO: function copied from trait may be inconsistent ??? */
     && !(func->op_array.fn_flags & (ZEND_ACC_TRAIT_CLONE))
     && fcall->extended_value >= func->op_array.required_num_args
     && func->op_array.opcodes[func->op_array.num_args].opcode == ZEND_RETURN) {

        zend_op *ret_opline = func->op_array.opcodes + func->op_array.num_args;

        if (ret_opline->op1_type == IS_CONST) {

            /* ... */

            for (i = 0; i < num_args; i++) {
                /* Don't inline functions with by-reference arguments. This would require
                 * correct handling of INDIRECT arguments. */
                if (ZEND_ARG_SEND_MODE(&func->op_array.arg_info[i])) {
                    return;
                }
            }

            /* ... */
        }
    }
}

The decisive condition is the last one. The instruction at position num_args has to be a RETURN with a constant operand, and the positions before it hold the RECV instructions of the parameters. So the body of the function has to consist of exactly one return of a constant. Nothing else may be in there.

On top of that come the exclusions from the two flag lines above it: not abstract, not deprecated, not copied from a trait and, when the return value goes unused, not marked with #[\NoDiscard]. The loop further down throws out every function that takes a parameter by reference. The exclusion that catches the most, though, sits in the middle of the first flag line: ZEND_ACC_HAS_TYPE_HINTS must not be set. The compiler sets that flag as soon as one parameter has a declared type.

Here it is:

<?php declare(strict_types=1);
function epsilon($scale)
{
    return 0.00001;
}

function ask($x)
{
    return epsilon($x);
}
ask:
     ; (lines=2, args=1, vars=1, tmps=0)
     ; (after optimizer)
0000 CV0($x) = RECV 1
0001 RETURN float(1e-05)

The call is gone. Now the same thing with epsilon(float $scale) instead of epsilon($scale):

ask:
     ; (lines=5, args=1, vars=1, tmps=1)
     ; (after optimizer)
0000 CV0($x) = RECV 1
0001 INIT_FCALL 1 96 string("epsilon")
0002 SEND_VAR CV0($x) 1
0003 T1 = DO_UCALL
0004 RETURN T1

A single type declaration on one parameter, and the call is back, with its own call frame.

Before that starts to sound like an argument against type declarations: it is not one. Functions whose body is a single return of a constant are rare, and what they cost is little. What I find interesting about it is something else. “Inlining” in PHP does not mean what it means in C or Java. There is no mechanism in the engine that copies the body of a function to the call site. The tracing JIT is the first thing that does that, and it does it in a completely different way.

One file at a time

Take these two functions:

<?php declare(strict_types=1);
function square(int $x): int
{
    return $x * $x;
}

function total(int $n): int
{
    return square($n) + 1;
}

As long as they are in the same file, the call looks like this:

total:
     ; (after optimizer)
0000 CV0($n) = RECV 1
0001 INIT_FCALL 1 112 string("square")
0002 SEND_VAR CV0($n) 1
0003 T2 = DO_UCALL
0004 T1 = ADD T2 int(1)
0005 VERIFY_RETURN_TYPE T1
0006 RETURN T1

Split across two files, with a require between them, the same call looks like this:

total:
     ; (after optimizer)
0000 CV0($n) = RECV 1
0001 INIT_FCALL_BY_NAME 1 string("square")
0002 SEND_VAR_EX CV0($n) 1
0003 T2 = DO_FCALL_BY_NAME
0004 T1 = ADD T2 int(1)
0005 VERIFY_RETURN_TYPE T1
0006 RETURN T1

All three instructions of the call are different ones. In the first case the called function is known at compile time: the name does not have to be looked up at run time, the 112 is the size of the call frame in bytes and has already been worked out, and DO_UCALL knows a PHP function rather than an internal one is being called. In the second case square is just a string.

That is the unit of work of the optimiser, not sloppiness on its part. It is handed a zend_script: the main opcode array of one file plus the functions and classes declared in that file. Whatever is in another file may well not have been compiled yet at that point.

Preloading changes that. Preload both files at startup and total() gets the resolved form back:

total:
     ; (after optimizer)
0000 CV0($n) = RECV 1
0001 INIT_FCALL 1 112 string("square")
0002 SEND_VAR CV0($n) 1
0003 T2 = DO_UCALL
0004 T1 = ADD T2 int(1)
0005 VERIFY_RETURN_TYPE T1
0006 RETURN T1

In an application with one class per file that means: without preloading, pretty much every call across a file boundary is a call by name. What preloading buys you is therefore more than avoiding file system access; it enlarges the piece of program the optimiser gets to see at once.

What gets frozen at compile time

The optimiser evaluates a handful of function calls instead of leaving them in place:

zend_result zend_optimizer_eval_special_func_call(
        zval *result, const zend_string *name, zend_string *arg) {
    if (zend_string_equals_literal(name, "function_exists") ||
            zend_string_equals_literal(name, "is_callable")) {

        /* ... */
    }
    if (zend_string_equals_literal(name, "extension_loaded")) {

        /* ... */
    }
    if (zend_string_equals_literal(name, "constant")) {
        return zend_optimizer_get_persistent_constant(arg, result, 1) ? SUCCESS : FAILURE;
    }
    if (zend_string_equals_literal(name, "dirname")) {

        /* ... */
    }
    if (zend_string_equals_literal(name, "ini_get")) {
        zend_ini_entry *ini_entry = zend_hash_find_ptr(EG(ini_directives), arg);
        if (!ini_entry) {
            if (PG(enable_dl)) {
                return FAILURE;
            }
            ZVAL_FALSE(result);
        } else if (ini_entry->modifiable != ZEND_INI_SYSTEM) {
            return FAILURE;
        } else if (ini_entry->value) {
            ZVAL_STR_COPY(result, ini_entry->value);
        } else {
            ZVAL_EMPTY_STRING(result);
        }
        return SUCCESS;
    }

    return FAILURE;
}

function_exists(), is_callable(), extension_loaded(), constant(), dirname() with an absolute path and ini_get(), plus strlen() elsewhere. Whatever comes out sits in the bytecode as a constant afterwards.

This piece of code:

<?php declare(strict_types=1);
function f()
{
    if (!extension_loaded('json')) {
        throw new RuntimeException('json is required');
    }

    return strlen('phpunit') + PHP_INT_SIZE;
}

puts both of them next to each other:

f:
     ; (lines=11, args=0, vars=0, tmps=4)
     ; (before optimizer)
0000 INIT_FCALL 1 96 string("extension_loaded")
0001 SEND_VAL string("json") 1
0002 T0 = DO_ICALL
0003 T1 = BOOL_NOT T0
0004 JMPZ T1 0009
0005 T2 = NEW 1 string("RuntimeException")
0006 SEND_VAL string("json is required") 1
0007 DO_FCALL
0008 THROW T2
0009 RETURN int(15)
0010 RETURN null

f:
     ; (lines=1, args=0, vars=0, tmps=0)
     ; (after optimizer)
0000 RETURN int(15)

Two different things happened here, and they are easy to confuse. The RETURN int(15) is already in the upper listing: strlen('phpunit') + PHP_INT_SIZE was folded by the compiler, before the optimiser had its turn at all. The optimiser evaluated the extension_loaded() call, saw that the jump is always taken, and removed the whole branch along with the exception.

It is allowed to do that because the conditions are narrow. extension_loaded() is only evaluated for modules that are permanently loaded, not for ones that might arrive through dl(). ini_get() is only evaluated for settings declared as PHP_INI_SYSTEM, which therefore cannot change at run time.

It is still worth knowing that these calls no longer appear in the bytecode. What you are checking is the state of the process the code was compiled in, and that bytecode can live for a while.

What this does for real code

Enough of the examples I made up. I looked at the same thing in a raytracer I had to hand: final readonly classes with float properties, plenty of arithmetic, all of it wrapped in method calls. The link points at exactly the revision I analysed and measured.

One of the most used methods in it adds two tuples:

<?php declare(strict_types=1);
public function plus(self $that): self
{
    if ($this->isPoint() && $that->isPoint()) {
        throw new RuntimeException(
            'Cannot add point tuple to another point tuple',
        );
    }

    return new self(
        $this->x + $that->x,
        $this->y + $that->y,
        $this->z + $that->z,
        $this->w + $that->w,
    );
}

The compiler turns that into 34 instructions, the optimiser into 30. Four instructions fewer does not sound like much, and that is not where the difference lies either. It lies in what changed within those 30 (I have shortened the four uniform additions):

SebastianBergmann\Raytracer\Tuple::plus:
     ; (lines=30, args=1, vars=1, tmps=4)
     ; (after optimizer)
0000 CV0($that) = RECV 1
0001 INIT_METHOD_CALL 0 THIS string("isPoint")
0002 T1 = DO_UCALL
0003 JMPZ T1 0011
0004 INIT_METHOD_CALL 0 CV0($that) string("isPoint")
0005 T1 = DO_FCALL
0006 JMPZ T1 0011
0007 T1 = NEW 1 string("SebastianBergmann\\Raytracer\\RuntimeException")
0008 SEND_VAL_EX string("Cannot add point tuple to another point tuple") 1
0009 DO_FCALL
0010 THROW T1
0011 T1 = NEW 4 (self) (exception)
0012 T3 = FETCH_OBJ_R THIS string("x")
0013 T4 = FETCH_OBJ_R CV0($that) string("x")
0014 T2 = ADD T3 T4
0015 SEND_VAL T2 1
     ...
0028 DO_FCALL
0029 RETURN T1

The && expression used to be a JMPZ_EX, a BOOL and a JMPZ tied together through a temporary; now it is two JMPZ instructions that both jump straight to the same place. SEND_VAL_EX has become SEND_VAL, because it is established that the constructor expects no argument by reference. The call to isPoint() on $this has become a DO_UCALL, because the method could be resolved; the call to the same method on $that has stayed a DO_FCALL, even though the parameter is declared as self and the class is final. And both VERIFY_RETURN_TYPE instructions are gone: what is returned is the result of NEW (self), and that this is a self no longer needs checking.

The most striking number, though, is in the header line. tmps has dropped from 18 to 4.

That number is not cosmetic. The size of a call frame is worked out in zend_vm_calc_used_stack() from a fixed header of five zval slots, the compiled variables, which the parameters are part of, and the temporaries. For this method that is 24 slots of 16 bytes each before, so 384 bytes, and 10 slots after, so 160 bytes. On every single call.

Across all 235 opcode arrays of the raytracer:

before the optimiser after
Instructions 3,160 2,730
Compiled variables 322 322
Temporaries 1,234 312

14 % fewer instructions and 75 % fewer temporaries. The compiled variables stay as they are: those are the variables you wrote down. The temporaries are the intermediate results the compiler hands out generously, because it does not keep track of which of them is still needed.

Which leaves the question of how much of this survives into the run time. The test suite of this raytracer has 177 tests and computes with floating point numbers throughout.

On the command line the optimiser can be cleanly separated from the cache, because the cache buys you nothing there. Every process creates its own shared memory and starts with an empty cache:

$ php -d opcache.enable=1 -d opcache.enable_cli=1 -r 'require "square.php";
      $s = opcache_get_status(false)["opcache_statistics"];
      echo "hits=", $s["hits"], " misses=", $s["misses"], PHP_EOL;'
hits=0 misses=1
$ !!
hits=0 misses=1

That holds for the default configuration. With opcache.file_cache OPcache puts the bytecode into the file system and finds it again in the next process. I leave that configuration out here to keep things simple.

The difference between opcache.optimization_level=0 and the default is therefore the optimiser and nothing else, including the time it takes itself. What I measured is the total run time of the process, the number you see while you wait:

Run without OPcache optimization_level=0 default
bootstrap only, no test 0.19 s 0.22 s 0.37 s
one run 9.91 s 9.91 s 8.48 s
--repeat 3 29.28 s 29.85 s 26.35 s
--repeat 10 99.83 s 97.09 s 90.34 s

The first two columns cannot be told apart across all four rows. On the command line, switching OPcache on and the optimiser off is the same as not switching OPcache on.

The growth between the three runs gives the effort per repetition, the row without a test gives the fixed cost:

Configuration Fixed cost Per repetition Factor
without OPcache 0.19 s 10.01 s 1.00
optimization_level=0 0.22 s 9.67 s 1.04
default 0.37 s 9.11 s 1.10

9 % per run. The fixed cost is the first row of the table: a run that executes no test at all because of a filter that matches nothing takes 0.15 s longer with the optimiser. That is what it costs to send PHPUnit and the raytracer through the optimiser once. The factor of 1.04 in the second row is not one: the runs behind the first two columns overlap almost completely, at --repeat 10 roughly 95.47 s to 104.19 s against 97.51 s to 101.29 s.

9 % is not a factor anyone gives a talk about. It also costs nothing, it happens as soon as OPcache is loaded, and it is there before anybody has thought about the JIT. What that does to the same test suite is what I look at in my next article.

What I took away

The compiler produces instructions that are correct. The optimiser turns them into instructions that are also cheap. That this second step is tied to an extension whose name contains the word “cache” has a good reason behind it. Compilation is expensive, so keeping its result is worth it. And making that work even more expensive is only worth it when the result is kept. The bytecode cache and the bytecode optimiser both live in OPcache, cleanly separated from each other.

If you compare numbers between two PHP installations, opcache.optimization_level therefore belongs in the comparison next to ZEND_VM_KIND. The dispatch model sits in the binary and is the same for everyone who installed the same package; this setting is one line in a php.ini. And because it can switch the optimiser off without switching the cache off, “OPcache is on” on its own says nothing about whether the bytecode you are measuring is optimised. On the command line, opcache.enable_cli comes before it; that one defaults to 0. Your test suite therefore runs on unoptimised bytecode until you change that: on this machine it costs about 14 %, 9.91 s instead of 8.48 s for one run.

What stayed with me is the number for the temporaries. 1,234 against 312, across a project in which not one of them appears in the source code. I would have looked at instructions, and instructions are the smaller effect.

The last article was about getting control from one instruction to the next as quickly as possible. Before that sits a step that decides which handler gets jumped to in the first place. It decides that on the strength of a type inference many people take for an ingredient of the JIT. That inference runs as soon as OPcache is loaded, and the JIT has no part in it.