Zwei Hände führen einen Handhobel über ein helles Brett. Aus dem Hobel läuft eine Locke heraus, eine zweite kringelt sich vor der Schneide auf dem Holz. Unterhalb des Bretts liegt ein Haufen Späne. Das Brett selbst ist glatt und zeigt seine Maserung; hinzugekommen ist nichts.
Das Brett ist dasselbe geblieben. Der Unterschied liegt in dem, was danebenliegt.

Über jedem Opcode-Dump im letzten Artikel stand eine Zeile, die ich unkommentiert gelassen habe:
; (before optimizer). Es gibt auch ein after, und dazwischen liegt der Optimizer von OPcache. Alles, was folgt, bezieht sich auf PHP 8.6.

In Gesprächen begegnet mir regelmäßig die Annahme, OPcache sei ein Cache und sonst nichts: einmal kompilieren, das Ergebnis aufheben, fertig. Das ist naheliegend, der Name sagt es ja. Es ist aber nur die Hälfte. Zwischen Compiler und Executor baut OPcache den Bytecode um, und zwar so gründlich, dass der Executor am Ende andere Instruktionen ausführt, als der Compiler erzeugt hat. In einem Fall führt er sogar andere Handler aus.

Was der Compiler übrig lässt

Der Compiler übersetzt deinen Quelltext in Opcodes, und ohne OPcache ist das die ganze Geschichte. Der Executor bekommt genau das, was der Compiler abgeliefert hat, und arbeitet es ab.

Ist OPcache geladen, kommt ein Schritt dazwischen. Bevor ein kompiliertes Skript in den Shared Memory wandert, ruft OPcache zend_optimize_script() auf. Diese Funktion und alles, was sie aufruft, liegt in Zend/Optimizer/, nicht in ext/opcache/: Der Optimizer ist Teil der Engine, wird aber nur von OPcache in Gang gesetzt. Kein OPcache, kein Optimizer.

Der zeitliche Rahmen ist dabei ein anderer als beim Just-in-Time-Compiler (JIT). Der Optimizer läuft, während kompiliert wird, und sein Ergebnis wird aufgehoben. Er darf sich also Analysen leisten, die pro Anfrage viel zu teuer wären, weil sie pro Anfrage gar nicht anfallen.

Der Weg vom Quelltext zum Executor, mit und ohne OPcache
Ohne OPcache geht der Bytecode direkt aus pass_two() in den Executor. Mit OPcache liegen fünfzehn Passes dazwischen, und der Bytecode wird danach ein zweites Mal durch pass_two() geschickt.

Zwei Zahlen aus der php.ini

opcache.opt_debug_level kennst du aus dem letzten Artikel: 0x10000 gibt den Bytecode aus, wie der Compiler ihn erzeugt hat, 0x20000 noch einmal, nachdem der Optimizer darüber gelaufen ist. Zusammen also 0x30000.

Die zweite Zahl ist opcache.optimization_level, eine Bitmaske mit der Voreinstellung 0x7FFEBFFF, in der jedes Bit einen Pass ein- oder ausschaltet. Sie auf 0 zu setzen schaltet den Optimizer ab, ohne den Cache abzuschalten, und das brauchen wir später zum Messen.

Nehmen wir das Programm aus dem letzten Artikel. CV0 und CV1 in der Ausgabe sind Compiled Variables, also die Variablen, die im Quelltext stehen:

<?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)

Fünf Instruktionen vorher, fünf Instruktionen nachher. Aus T4 ist T2 geworden und aus tmps=3 ist tmps=1 geworden, sonst nichts. Das war der Moment, in dem ich dachte, ich hätte etwas falsch gemacht.

Hatte ich nicht. $a und $b stehen hier auf Dateiebene und sind damit globale Variablen. Ein include, ein Zugriff über $GLOBALS, ein extract(): Es gibt zu viele Wege, auf denen jemand anderes an diese Variablen kommen kann, als dass der Optimizer über sie etwas annehmen dürfte.

Dieselben drei Zeilen in einer Funktion:

<?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)

Fünf Instruktionen werden zu einer, und zwei Variablen verschwinden ganz. Der Optimizer arbeitet an Funktionen. Auf Dateiebene tut er fast nichts, und deshalb ist der erste Versuch, ihm zuzusehen, meistens eine Enttäuschung.

Sechzehn Nummern, fünfzehn Passes

Die Bits der Bitmaske stehen in Zend/Optimizer/zend_optimizer.h, jedes mit einem Kommentar dahinter:

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

Der Kommentar hinter Pass 2 ist leer, und der Name kommt in keiner .c-Datei des Optimizers vor. Diese Nummerierung ist gewachsen und nicht entworfen: Passes sind über die Jahre dazugekommen, verschmolzen und wieder verschwunden, und die Nummern der Verschwundenen sind frei geblieben. Die Nummern reichen bis sechzehn, die Passes sind fünfzehn.

Verglichen mit ZEND_OPTIMIZER_ALL_PASSES, das 0x7FFFFFFF ist, fehlen der Voreinstellung genau zwei Bits: Pass 15 und ZEND_OPTIMIZER_IGNORE_OVERLOADING. Das sind die beiden, deren Kommentar mit (unsafe) beginnt. Alles andere ist eingeschaltet, einschließlich des Inlinings aus Pass 16. Die beiden Bits nach Pass 16 sind ohnehin keine Passes, sondern Erlaubnisse an die Typinferenz. ZEND_OPTIMIZER_NARROW_TO_DOUBLE ist eingeschaltet und darf aus einem $x = 0 ein $x = 0.0 machen, wenn keine Verwendung von $x dadurch ein anderes Ergebnis bekommt: Aus long|double wird double, und was das wert ist, steht weiter unten.

Die Passes sind außerdem miteinander verzahnt, und zwar in beide Richtungen. Pass 10 entfernt NOP-Instruktionen, läuft aber nur, wenn Pass 5 abgeschaltet ist, weil die Optimierung über den Kontrollflussgraphen das ohnehin erledigt. Pass 6 läuft nur, wenn Pass 7 abgeschaltet ist, weil die Datenflussanalyse mit einem Aufrufgraphen anders angestoßen wird als ohne. An einzelnen Bits zu drehen führt deshalb schnell zu Kombinationen, die niemand ausprobiert hat.

Die niederwertigen Bits von opcache.opt_debug_level geben den Bytecode nach einem bestimmten Pass aus: 1 nach Pass 1, 16 nach Pass 5, 128 nach der Konstantenpropagierung, 8192 nach der Beseitigung toten Codes. Damit lässt sich der ganze Weg verfolgen, und das machen wir gleich.

Rückwärts und wieder vorwärts

Vorher noch ein Detail, das mir gefallen hat, weil es direkt an den letzten Artikel anschließt.

Nach dem Kompilieren läuft über jede Funktion ein Schritt namens pass_two(). Er macht aus der abstrakten Instruktion eine ausführbare: Konstanten werden zu Positionen in der Literaltabelle, Sprungziele zu Adressen, und jede Instruktion bekommt ihren handler, also jenes Feld, um das sich der letzte Artikel gedreht hat.

Auf dieser Form kann der Optimizer nicht arbeiten. Er verschiebt Instruktionen, entfernt welche und schreibt andere um; Adressen, die auf feste Positionen zeigen, wären danach falsch. Also macht er pass_two() erst rückgängig:

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);
    }
}

Auf den letzten Schritt kommt es hier an. zend_redo_pass_two() geht alle Instruktionen noch einmal durch und weist jeder einen handler zu. Welchen, ist die Frage, auf die ich weiter unten zurückkomme.

Eine Funktion durch die Pipeline

Nehmen wir eine Funktion, die so niemand schreiben würde, dafür aber mehreren Passes etwas zu tun gibt:

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

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

    return $x;
}

Der Compiler macht daraus zehn Instruktionen:

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 baut den Kontrollflussgraphen auf und wirft weg, was von keinem Weg aus erreichbar ist. Das trifft das RETURN null, das der Compiler hinter jede Funktion setzt, damit auch ein Durchlaufen ohne return etwas zurückgibt. Neun Instruktionen.

Für die nächsten Passes überführt der Optimizer die Funktion in Static Single Assignment-Form, kurz SSA: Jede Zuweisung an eine Variable bekommt eine eigene Nummer, sodass jede Nummer genau einmal geschrieben wird. Das klingt nach Buchhaltung und ist der Punkt, an dem eine Analyse überhaupt erst etwas sagen kann, weil zu jeder Verwendung genau eine Definition gehört. Die Ausgabe zeigt diese Form mitsamt den Typen, die dabei abfallen; BB0, BB1 und BB2 sind die Basisblöcke des Kontrollflussgraphen:

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] heißt: die dritte SSA-Variable, sie gehört zu $x, und sie ist ein Float, weil das in der Signatur steht. #4.CV1($factor) [long] RANGE[2..2] ist genauer: nicht nur ein Integer, sondern ein Integer zwischen 2 und 2. Damit ist 1 < $factor entschieden, der Sprung fällt weg, und der zweite return steht in einem Block, den die Ausgabe als unreachable markiert. In Zeile 0006 steht jetzt MUL $x int(2), weil $factor durch seinen Wert ersetzt wurde.

Diese Analyse heißt SCCP, Sparse Conditional Constant Propagation. Sie propagiert Konstanten und entscheidet Verzweigungen gleichzeitig, statt nacheinander, und findet dadurch Konstanten, die eine Runde Konstantenfaltung gefolgt von einer Runde Zweigentfernung nicht findet.

Danach kommt die Beseitigung toten Codes:

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 wird nirgends mehr gelesen, $unused war nie gelesen worden, also verschwinden beide Zuweisungen mitsamt der Multiplikation, die niemand braucht. Nach dem Aufräumen der NOPs, dem Zusammenlegen der Temporaries und dem Neunummerieren der Variablen bleibt das hier:

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

Zehn Instruktionen sind zu dreien geworden, und aus MUL $x, 2 ist ADD $x, $x geworden. Diese letzte Umformung steht in Zend/Optimizer/dfa_pass.c, mit einem Kommentar, der sie in einer Zeile beschreibt:

} 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;

        /* ... */
    }
}

Beachte die Bedingung darüber. Die Umformung greift nur, wenn die Typinferenz ausgeschlossen hat, dass der Operand etwas anderes als ein Integer oder ein Float ist. Bei einem String wäre $x * 2 etwas anderes als $x + $x, und bei einem Objekt mit überladenen Operatoren erst recht.

Die Typinferenz wählt den Handler

Damit zurück zu der Frage von vorhin: Welchen handler weist zend_redo_pass_two() zu?

Im letzten Artikel ging es um die 987 Handler-Funktionen, die der Generator erzeugt, spezialisiert nach der Art der Operanden: Konstante, Temporary, Variable oder Compiled Variable. Diese Auswahl trifft die Engine auch ohne OPcache.

Hat der Optimizer eine Datenflussanalyse durchgeführt, nimmt er eine andere Funktion, die zusätzlich die inferierten Typen berücksichtigt. Sie steht in zend_vm_execute.h, ist also erzeugt und nicht geschrieben, wie die Handler selbst:

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)];
}

Für eine Addition gibt es drei solcher Sonderfälle: beide Operanden Integer und das Ergebnis ebenfalls, beide Operanden Integer mit möglichem Überlauf, beide Operanden Float. Insgesamt stehen 42 dieser typspezialisierten Vorlagen in zend_vm_def.h, für Addition, Subtraktion und Multiplikation, die Vergleiche, PRE_INC, SEND_VAL, FETCH_DIM_R und einige mehr. Für die Division gibt es keine: 7 / 2 ist ein Float und 1 / 0 wirft, es gibt also keinen Fall, in dem zwei Integer garantiert einen Integer ergeben.

Der erste Fall sieht so aus:

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();
}

Zwei Werte holen, addieren, weiter. Keine Typprüfung, keine Überlaufprüfung, kein Zweig für Strings, kein Zweig für überladene Operatoren, kein Hilfsaufruf für den allgemeinen Fall. Vergleiche das mit der Vorlage für ADD aus dem letzten Artikel, die mit einer Kaskade von Typprüfungen anfängt und mit einem Sprung in einen Helfer endet.

Wie der Handler für eine Addition ausgewählt wird, mit und ohne OPcache
Dieselbe Instruktion, zwei verschiedene Handler. Welchen der Executor anspringt, hängt davon ab, ob beim Kompilieren eine Typinferenz gelaufen ist.

Das ist die Stelle, an der am deutlichsten zu sehen ist, dass in OPcache zwei Dinge stecken. Dieselbe Addition in derselben Funktion führt einen anderen Handler aus, je nachdem, ob die Extension geladen ist, und das hat mit Zwischenspeichern nichts zu tun.

Inlining, das fast nie passiert

Pass 16 heißt Inline functions, und was er tut, ist sehr viel kleiner, als dieser Name vermuten lässt:

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;
                }
            }

            /* ... */
        }
    }
}

Die entscheidende Bedingung ist die letzte. Die Instruktion an der Position num_args muss ein RETURN mit einem konstanten Operanden sein, und an den Positionen davor stehen die RECV-Instruktionen der Parameter. Der Rumpf der Funktion muss also aus genau einem return einer Konstante bestehen. Mehr darf darin nicht stehen.

Dazu kommen die Ausschlüsse aus den beiden Flag-Zeilen darüber: nicht abstrakt, nicht deprecated, nicht aus einem Trait kopiert und, wenn der Rückgabewert gar nicht verwendet wird, nicht mit #[\NoDiscard] markiert. Die Schleife weiter unten wirft außerdem jede Funktion hinaus, die einen Parameter per Referenz nimmt. Der Ausschluss, der die meisten trifft, steht aber mitten in der ersten Flag-Zeile: ZEND_ACC_HAS_TYPE_HINTS darf nicht gesetzt sein. Dieses Flag setzt der Compiler, sobald ein Parameter einen deklarierten Typ hat.

Das lässt sich zeigen:

<?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)

Der Aufruf ist weg. Jetzt dasselbe mit epsilon(float $scale) statt 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

Eine einzige Typdeklaration an einem Parameter, und der Aufruf ist wieder da, mitsamt eigenem Call Frame.

Bevor das nach einem Argument gegen Typdeklarationen klingt: Es ist keins. Funktionen, deren Rumpf aus einem einzigen return einer Konstante besteht, sind selten, und was sie kosten, ist wenig. Interessant finde ich daran etwas anderes. „Inlining“ heißt in PHP nicht das, was es in C oder Java heißt. Es gibt in der Engine keinen Mechanismus, der den Rumpf einer Funktion an die Aufrufstelle kopiert. Diesen Schritt macht erst der Tracing-JIT, und der macht ihn ganz anders.

Eine Datei nach der anderen

Nimm diese beiden Funktionen:

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

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

Solange sie in derselben Datei stehen, sieht der Aufruf so aus:

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

Verteilt auf zwei Dateien, mit einem require dazwischen, sieht derselbe Aufruf so aus:

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

Alle drei Instruktionen des Aufrufs sind andere. Im ersten Fall steht die aufgerufene Funktion beim Kompilieren fest: Der Name muss zur Laufzeit nicht mehr nachgeschlagen werden, das 112 ist die Größe des Call Frames in Bytes, die schon ausgerechnet ist, und DO_UCALL weiß, dass eine PHP-Funktion und keine interne Funktion aufgerufen wird. Im zweiten Fall ist square nur ein String.

Das ist keine Nachlässigkeit, sondern die Arbeitseinheit des Optimizers. Er bekommt ein zend_script: das Haupt-Opcode-Array einer Datei plus die Funktionen und Klassen, die in dieser Datei deklariert werden. Was in einer anderen Datei steht, ist zu diesem Zeitpunkt womöglich noch gar nicht kompiliert.

Das ändert Preloading. Werden beide Dateien beim Start vorgeladen, bekommt total() die aufgelöste Form zurück:

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 einer Anwendung mit einer Klasse pro Datei heißt das: Ohne Preloading ist so ziemlich jeder Aufruf über eine Dateigrenze hinweg ein Aufruf über den Namen. Was Preloading bringt, ist damit mehr als das Einsparen von Dateizugriffen; es vergrößert das Stück Programm, das der Optimizer auf einmal sieht.

Was beim Kompilieren eingefroren wird

Ein paar Funktionsaufrufe wertet der Optimizer aus, statt sie stehen zu lassen:

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() mit einem absoluten Pfad und ini_get(), dazu strlen() an anderer Stelle. Was dabei herauskommt, steht danach als Konstante im Bytecode.

An diesem Stück Code:

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

    return strlen('phpunit') + PHP_INT_SIZE;
}

lässt sich beides nebeneinander ansehen:

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)

Hier sind zwei verschiedene Dinge passiert, die leicht durcheinandergeraten. Das RETURN int(15) steht schon in der oberen Ausgabe: strlen('phpunit') + PHP_INT_SIZE hat der Compiler gefaltet, bevor der Optimizer überhaupt an der Reihe war. Der Optimizer hat den extension_loaded()-Aufruf ausgewertet, gesehen, dass der Sprung immer genommen wird, und den ganzen Zweig mitsamt der Exception entfernt.

Das darf er, weil die Bedingungen eng gefasst sind. extension_loaded() wird nur für Module ausgewertet, die dauerhaft geladen sind, nicht für solche, die per dl() dazukommen könnten. ini_get() wird nur für Einstellungen ausgewertet, die als PHP_INI_SYSTEM deklariert sind und sich zur Laufzeit also nicht mehr ändern lassen.

Trotzdem ist es gut zu wissen, dass diese Aufrufe im Bytecode nicht mehr vorkommen. Was du prüfst, ist der Zustand des Prozesses, in dem kompiliert wurde, und dieser Bytecode kann eine Weile leben.

Was das an echtem Code bringt

Genug von Beispielen, die ich mir ausgedacht habe. Ich habe mir dasselbe an einem Raytracer angesehen, den ich zur Hand hatte: final readonly-Klassen mit float-Properties, viel Rechnerei, alles in Methodenaufrufe verpackt. Der Link zeigt auf genau den Stand, den ich untersucht und gemessen habe.

Eine der meistgenutzten Methoden darin addiert zwei Tupel:

<?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,
    );
}

Der Compiler macht daraus 34 Instruktionen, der Optimizer 30. Vier Instruktionen weniger klingt nach wenig, und darauf kommt es hier auch nicht an. Es geht um das, was sich innerhalb dieser 30 verändert hat (die vier gleichförmigen Additionen habe ich gekürzt):

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

Der &&-Ausdruck bestand vorher aus JMPZ_EX, einem BOOL und einem JMPZ, die über ein Temporary miteinander verbunden waren; jetzt sind es zwei JMPZ, die beide direkt an dieselbe Stelle springen. Aus SEND_VAL_EX ist SEND_VAL geworden, weil feststeht, dass der Konstruktor kein Argument per Referenz erwartet. Der Aufruf von isPoint() auf $this ist ein DO_UCALL geworden, weil die Methode aufgelöst werden konnte; der Aufruf derselben Methode auf $that ist ein DO_FCALL geblieben, obwohl der Parameter als self deklariert und die Klasse final ist. Und die beiden VERIFY_RETURN_TYPE-Instruktionen sind weg: Zurückgegeben wird das Ergebnis von NEW (self), und dass das ein self ist, muss niemand mehr prüfen.

Die auffälligste Zahl steht aber in der Kopfzeile. tmps ist von 18 auf 4 gefallen.

Diese Zahl ist nicht kosmetisch. Die Größe eines Call Frames berechnet sich in zend_vm_calc_used_stack() aus einem festen Kopf von fünf zval-Plätzen, den Compiled Variables, zu denen die Parameter gehören, und den Temporaries. Für diese Methode sind das vorher 24 Plätze zu je 16 Bytes, also 384 Bytes, und nachher 10 Plätze, also 160 Bytes. Bei jedem einzelnen Aufruf.

Über alle 235 Opcode-Arrays des Raytracers gerechnet:

vor dem Optimizer danach
Instruktionen 3.160 2.730
Compiled Variables 322 322
Temporaries 1.234 312

14 % weniger Instruktionen und 75 % weniger Temporaries. Die Compiled Variables bleiben, wie sie sind: Das sind die Variablen, die du hingeschrieben hast. Die Temporaries sind die Zwischenergebnisse, die der Compiler großzügig verteilt, weil er nicht nachhält, welches davon noch gebraucht wird.

Bleibt die Frage, was davon in der Laufzeit ankommt. Die Testsuite dieses Raytracers hat 177 Tests und rechnet die ganze Zeit mit Fließkommazahlen.

Auf der Kommandozeile lässt sich der Optimizer sauber vom Cache trennen, weil der Cache dort nichts bringt. Jeder Prozess legt seinen eigenen Shared Memory an und startet mit einem leeren 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

Das gilt für die Voreinstellung. Mit opcache.file_cache legt OPcache den Bytecode im Dateisystem ab und findet ihn im nächsten Prozess wieder. Zur Vereinfachung lasse ich diese Konfiguration hier außen vor.

Der Unterschied zwischen opcache.optimization_level=0 und der Voreinstellung ist damit der Optimizer und sonst nichts, einschließlich der Zeit, die er selbst braucht. Gemessen habe ich die Gesamtlaufzeit des Prozesses, also das, was du beim Warten siehst:

Lauf ohne OPcache optimization_level=0 Voreinstellung
nur Bootstrap, kein Test 0,19 s 0,22 s 0,37 s
ein Durchlauf 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

Die ersten beiden Spalten sind über alle vier Zeilen nicht zu unterscheiden. OPcache einzuschalten und den Optimizer abzuschalten ist auf der Kommandozeile dasselbe, wie OPcache nicht einzuschalten.

Aus dem Zuwachs zwischen den drei Läufen ergibt sich der Aufwand pro Wiederholung, aus der Zeile ohne Test die Fixkosten:

Konfiguration Fixkosten pro Wiederholung Faktor
ohne OPcache 0,19 s 10,01 s 1,00
optimization_level=0 0,22 s 9,67 s 1,04
Voreinstellung 0,37 s 9,11 s 1,10

9 % pro Durchlauf. Die Fixkosten stehen in der ersten Tabellenzeile: Ein Lauf, der wegen eines nicht passenden Filters keinen einzigen Test ausführt, dauert mit Optimizer 0,15 s länger. So viel kostet es, PHPUnit und den Raytracer einmal durch den Optimizer zu schicken. Der Faktor 1,04 in der zweiten Zeile ist keiner: Die Läufe der beiden ersten Spalten überlappen sich fast vollständig, bei --repeat 10 etwa 95,47 s bis 104,19 s gegen 97,51 s bis 101,29 s.

9 % sind kein Faktor, über den jemand einen Vortrag hält. Sie kosten aber auch nichts, sie fallen an, sobald OPcache geladen ist, und sie sind da, bevor irgendjemand über den JIT nachgedacht hat. Was der an derselben Testsuite ausrichtet, sehe ich mir im nächsten Artikel an.

Was ich mitgenommen habe

Der Compiler erzeugt Instruktionen, die richtig sind. Der Optimizer erzeugt daraus Instruktionen, die außerdem billig sind. Dass dieser zweite Schritt an eine Extension gebunden ist, deren Name „Cache“ enthält, hat einen guten Grund. Kompilieren ist teuer, deshalb lohnt es sich, das Ergebnis aufzuheben. Und es lohnt sich, diese Arbeit nur dann noch teurer zu machen, wenn das Ergebnis auch aufgehoben wird. Bytecode Cache und Bytecode Optimizer stecken beide in OPcache, sind dort aber sauber voneinander getrennt.

Wenn du Zahlen zwischen zwei PHP-Installationen vergleichst, gehört opcache.optimization_level deshalb neben ZEND_VM_KIND in den Vergleich. Das Dispatch-Modell steckt im Binary und ist für alle gleich, die dasselbe Paket installiert haben; diese Einstellung ist eine Zeile in einer php.ini. Und weil sie den Optimizer abschalten kann, ohne den Cache abzuschalten, sagt „OPcache ist an“ allein noch nichts darüber, ob der Bytecode, den du misst, optimiert ist. Auf der Kommandozeile kommt opcache.enable_cli davor, das voreingestellt 0 ist. Deine Testsuite läuft also mit unoptimiertem Bytecode, solange du das nicht änderst: auf dieser Maschine kostet das rund 14 %, 9,91 s statt 8,48 s für einen Durchlauf.

Hängen geblieben ist mir die Zahl mit den Temporaries. 1.234 zu 312, über ein Projekt, in dem keine einzige davon im Quelltext vorkommt. Ich hätte auf Instruktionen geschaut, und die Instruktionen sind der kleinere Effekt.

Der letzte Artikel handelte davon, wie die Kontrolle möglichst schnell von einer Instruktion zur nächsten kommt. Davor sitzt ein Schritt, der darüber entscheidet, welcher Handler überhaupt angesprungen wird. Er entscheidet das auf Basis einer Typinferenz, die viele für eine Zutat des JIT halten. Sie läuft, sobald OPcache geladen ist, und der JIT ist daran nicht beteiligt.