Ein Gleisvorfeld im Gegenlicht der tief stehenden Sonne. Dutzende Schienen verzweigen sich über Weichen und laufen wieder zusammen, die Schienenköpfe glänzen golden. Im Hintergrund steht ein Triebwagen unter einem dichten Netz aus Oberleitungen. Ein Sinnbild für die Kernfrage jedes Interpreters: Wie kommt die Ausführung möglichst schnell von einer Instruktion zur nächsten?
Alle diese Gleise führen zum selben Bahnhof. Der Unterschied liegt darin, wie schnell die Weichen einen Zug weiterreichen.

Als ich mir das letzte Mal genauer angesehen habe, wie PHP Bytecode ausführt, hatte die Engine einen Handler pro Opcode, und diese Handler waren von Hand geschrieben. Spezialisierte Handler kamen mit PHP 5.1 im November 2005, und zwei der fünf Dispatch-Modelle aus diesem Artikel gab es damals ebenfalls noch nicht. Mich hat interessiert, worin sich diese Dispatch-Modelle unterscheiden und welche Anwendungsfälle sie lösen, also habe ich mich hineingegraben. In diesem Artikel möchte ich mit dir teilen, was ich dabei gelernt habe.

Was jetzt folgt, ist ein Rundgang durch den Teil der Zend Engine, der deinen Code tatsächlich ausführt. Es geht dabei um C und um die Maschine darunter, deshalb erkläre ich den Hintergrund unterwegs. Du musst weder das eine noch das andere kennen, um mitzukommen.

Von PHP-Code zu Opcodes

PHP führt deinen Quellcode nicht aus. Es kompiliert ihn in eine Zwischendarstellung aus Instruktionen, die die Engine Opcodes nennt. Ein kompiliertes Skript ist ein Array solcher Instruktionen. Eine Komponente namens Executor läuft dieses Array durch und führt eine Instruktion nach der anderen aus.

Wir können uns dieses Array ansehen. OPcache bringt dafür eine Debug-Funktion mit, gesteuert über die Einstellung opcache.opt_debug_level. Ihr Wert ist eine Bitmaske: 0x10000 gibt den Bytecode so aus, wie der Compiler ihn erzeugt hat, 0x20000 gibt ihn noch einmal aus, nachdem der Optimizer darüber gelaufen ist. Nimm das kleinste Programm, das mir einfällt:

<?php declare(strict_types=1);
print 2 + 3;

OPcache muss geladen und für die Kommandozeile aktiviert sein, was den Aufruf etwas wortreich macht:

$ php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 program.php

Wenn du dich mit OPcache gar nicht befassen möchtest: phpdbg -p -r program.php gibt eine sehr ähnliche Auflistung aus. So oder so ist das hier, was der Compiler erzeugt hat:

$_main:
     ; (lines=2, args=0, vars=0, tmps=0)
     ; (before optimizer)
0000 ECHO int(5)
0001 RETURN int(1)

Hier gibt es keine Addition. Der Compiler hat 2 + 3 beim Kompilieren ausgewertet und das Ergebnis in die Instruktion geschrieben. Das ist Sache des Compilers und heute nicht unser Thema. Es ist aber eine nützliche Erinnerung daran, dass der Executor immer nur das zu sehen bekommt, was die Kompilierung überlebt hat. Für eine echte Addition müssen wir die Operanden vor dem Compiler verstecken:

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

Fünf Instruktionen. Jede hat eine Adresse, eine Operation, bis zu zwei Operanden und möglicherweise ein Ergebnis. CV0 und CV1 sind Compiled Variables: Plätze für $a und $b, die der Compiler im Call Frame reserviert hat, adressiert über eine Nummer statt über einen Namen. T4 ist ein Temporary, das nur zwischen dem ADD und dem ECHO existiert.

In C ist eine solche Instruktion ein struct namens _zend_op, deklariert in Zend/zend_compile.h. Ein struct ist in C das, was in PHP eine Klasse ohne Methoden wäre: ein fester Satz benannter Felder:

struct _zend_op {
    zend_vm_opcode_handler_t handler;
    znode_op op1;
    znode_op op2;
    znode_op result;
    uint32_t extended_value;
    uint32_t lineno;
    uint8_t opcode;       /* Opcodes defined in Zend/zend_vm_opcodes.h */
    uint8_t op1_type;     /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */
    uint8_t op2_type;     /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */
    uint8_t result_type;  /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */
};

Zwei dieser Felder sind für das Folgende wichtig.

opcode ist ein einzelnes Byte. Es kann also höchstens 256 verschiedene Operationen geben, und PHP 8.6 nutzt 212 davon. Es ist die Nummer hinter den Namen, die du in einem Opcode-Dump siehst: ADD, ECHO, ASSIGN, RETURN.

handler ist das Feld, das der Executor tatsächlich verwendet. Während ein Skript kompiliert wird, bekommt jede Instruktion einen handler zugewiesen, der festlegt, wie diese Instruktion auszuführen ist. Zur Laufzeit geht der Executor direkt an handler und sieht sich opcode, von einer Handvoll Sonderfällen abgesehen, überhaupt nicht mehr an. Dieses Feld ist vor allem für den Optimizer da, für den JIT und für Werkzeuge, die den Bytecode untersuchen.

Das Feld handler jeder Instruktion zeigt auf eine Handler-Funktion
Der Executor geht direkt an handler. Das Feld opcode lesen der Optimizer, der JIT und Werkzeuge wie phpdbg.

Was ein handler genau ist, ob eine Funktion zum Aufrufen, eine Zahl zum Verzweigen oder eine Adresse zum Anspringen, ist die Frage, um die sich dieser ganze Artikel dreht.

Was ein Executor leisten muss

Von allem anderen befreit, verwaltet ein Executor zwei Dinge:

  • opline zeigt auf die Instruktion, die gerade ausgeführt wird. Es ist das Äquivalent eines Lesezeichens, und eine CPU würde es Instruction Pointer nennen.
  • execute_data zeigt auf den aktuellen Call Frame: den Speicherbereich, der die Argumente, die Compiled Variables und die Temporaries der gerade laufenden Funktion enthält. CV0 im Dump oben bedeutet „Platz 0 in execute_data“.

Mit diesen beiden ist die Aufgabe eine Schleife: opline ansehen, tun, was dort steht, opline weiterrücken, wiederholen. Das Weiterrücken steht in einem Makro in Zend/zend_execute.c; ein Makro ist in C ein Stück Text, das der Compiler überall dort einsetzt, wo das Makro verwendet wird:

#define ZEND_VM_NEXT_OPCODE_EX(check_exception, skip) \
    CHECK_SYMBOL_TABLES() \
    if (check_exception) { \
        OPLINE = EX(opline) + (skip); \
    } else { \
        ZEND_ASSERT(!EG(exception)); \
        OPLINE = opline + (skip); \
    } \
    ZEND_VM_CONTINUE()

Die letzte Zeile ist die, auf die es hier ankommt. ZEND_VM_CONTINUE() heißt „und jetzt weiter zur nächsten Instruktion“, und der ganze Punkt dieses Artikels ist, dass es dafür fünf verschiedene Schreibweisen gibt. Die Übergabe der Kontrolle von einer Instruktion zur nächsten heißt Dispatch, und sie ist der Hot Spot jedes Interpreters.

Zum Executor geht es über execute_ex(), und diese Funktion wird nicht direkt aufgerufen. Die Engine ruft über einen Funktionszeiger namens zend_execute_ex, der standardmäßig auf execute_ex() zeigt. Extensions können ihn ersetzen, und auf diesem Weg kommt Xdebug zwischen dich und deinen Code. Das ist auch einer der Gründe, warum Xdebug PHP so stark verlangsamt: Jeder Funktionsaufruf im Userland läuft dann durch einen anderen, deutlich teureren Executor.

Ein PHP-Funktionsaufruf bedeutet dagegen in der Regel nicht, dass execute_ex() erneut aufgerufen wird. Wenn eine PHP-Funktion eine andere aufruft, legt der Executor einen neuen Call Frame an, richtet execute_data und opline darauf aus und macht in derselben Schleife weiter. Dein PHP-Callstack und der C-Callstack sind zwei verschiedene Dinge.

C-Stack und VM-Stack, ohne und mit Xdebug
Ohne Xdebug liegt ein einziger execute_ex()-Frame auf dem C-Stack, gleich wie viele PHP-Funktionen einander gerade aufgerufen haben. Ersetzt Xdebug den Executor, wächst der C-Stack mit jedem Aufruf im Userland mit.

Ein Umweg über die CPU

Die fünf Dispatch-Modelle unterscheiden sich auf eine Weise, die erst Sinn ergibt, wenn du ein wenig darüber weißt, was ein Prozessor mit dem Code anstellt, den ein C-Compiler erzeugt. Deshalb hier das Nötigste. Das meiste davon musste ich selbst auffrischen. Das letzte Mal, dass ich mich mit einem Prozessor wirklich zu Hause gefühlt habe, war beim Motorola 68000. Dort stand im Handbuch für jede Instruktion eine Taktzahl, und du konntest sie zusammenzählen, um zu wissen, wie lange eine Routine dauert. Fast alles in diesem Abschnitt kam danach.

Eine CPU führt Maschineninstruktionen aus und hat eine Handvoll extrem schneller Speicherplätze, die Register heißen. Ein moderner 64-Bit-x86-Prozessor hat sechzehn davon zur allgemeinen Verwendung. Register sind der einzige Ort, an dem die CPU tatsächlich rechnen kann; alles andere muss erst aus dem Speicher in ein Register geladen werden. Zu entscheiden, was in einem Register liegt und was neu aus dem Speicher geholt werden muss, ist eine der Hauptaufgaben eines C-Compilers.

Wenn eine C-Funktion eine andere aufruft, gibt es ein Protokoll, an das sich beide Seiten halten: die Calling Convention. Argumente gehen in vereinbarte Register, die Rücksprungadresse kommt auf den Stack. Teuer wird es dadurch, dass einige Register den Aufruf überstehen müssen. Das sind die Callee-saved Register: Will die aufgerufene Funktion eines davon benutzen, muss sie den alten Wert vorher sichern und vor dem Rücksprung wiederherstellen. Für einen einzelnen Aufruf fällt diese Buchhaltung nicht ins Gewicht, über die Milliarden Aufrufe eines Interpreters summiert sie sich.

Das andere, was du wissen solltest: Eine CPU führt nicht eine Instruktion aus und sieht dann nach der nächsten. Sie hat eine Pipeline, die Dutzende Stufen tief ist, und arbeitet weit vor dem her, was sie tatsächlich abgeschlossen hat. Das funktioniert nur, solange sie weiß, welche Instruktion als Nächstes kommt. Trifft sie auf einen Sprung, dessen Ziel nicht feststeht, sagt sie das Ziel vorher und läuft spekulativ auf dieser Annahme weiter. Eine richtige Vorhersage kostet nichts, eine falsche wirft die gesamte spekulative Arbeit weg, zu einem Preis in der Größenordnung von fünfzehn bis zwanzig Takten. An dieser Stelle hörte mein Taktzählen auf zu funktionieren: Was eine Instruktion kostet, hängt jetzt davon ab, was der Prozessor einen Moment vorher geraten hat.

Das Dispatchen eines Opcodes ist so ein Sprung. Es ist ein indirekter Sprung: Das Ziel steht nicht in der Instruktion, sondern wird aus dem Speicher gelesen, aus eben jenem Feld handler. Die Sprungvorhersage der CPU führt ihre Historie pro Sprungstelle, adressiert über die Adresse der Sprunginstruktion selbst. Dieses Detail prägt einen großen Teil des Designs von PHPs Executor.

Wenn alle Opcodes über eine einzige gemeinsame Sprunginstruktion verteilt werden, muss dieser eine Eintrag in der Vorhersage für jede Instruktion jedes Skripts raten, welcher von tausend Handlern als Nächstes kommt. Er wird meistens danebenliegen. Endet dagegen jeder Handler mit seiner eigenen Sprunginstruktion, bekommt jeder dieser Sprünge eine eigene Historie. Die Vorhersage kann dann lernen, dass auf einen Vergleich meist ein bedingter Sprung folgt, dass auf einen Variablenzugriff meist eine Zuweisung folgt und so weiter. Die geleistete Arbeit ist dieselbe, aber der Prozessor rät sehr viel häufiger richtig. Um diesen Unterschied geht es bei den fünf Modellen.

Ein generierter Executor

Noch eine Sache vor den Modellen selbst: PHPs Executor schreibt niemand von Hand. Er wird von einem PHP-Skript generiert.

Zwei Dateien in Zend/ sind die Eingabe. zend_vm_def.h enthält eine Vorlage für jeden Opcode. zend_vm_execute.skl ist ein Gerüst des Executors mit Platzhaltern an den Stellen, an die der Dispatch-Code kommt. Eine dritte Datei, zend_vm_gen.php, liest beide und schreibt zend_vm_execute.h, in PHP 8.6 rund 123.700 Zeilen C. Die generierten Dateien liegen in PHPs Repository, du musst den Generator also nur laufen lassen, wenn du etwas ändern willst.

Der Generator kopiert die Vorlagen nicht nur, er spezialisiert sie. Ein Operand einer Instruktion kann von fünf Arten sein: eine im Skript fest eingebaute Konstante (CONST), ein Temporary (TMP), eine per Referenz genutzte Variable (VAR), eine Compiled Variable (CV) oder gar nichts (UNUSED). Welche Art es ist, steht beim Kompilieren des Skripts fest und ändert sich danach nicht mehr. Statt eines Handlers, der die Operandenarten bei jedem Durchlauf prüft, erzeugt der Generator deshalb einen eigenen Handler pro vorkommender Kombination.

So sieht die Vorlage für ADD aus:

ZEND_VM_HOT_NOCONSTCONST_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV)
{
    USE_OPLINE
    zval *op1, *op2, *result;
    double d1, d2;

    op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
    op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
    if (ZEND_VM_SPEC && OP1_TYPE == IS_CONST && OP2_TYPE == IS_CONST) {
        /* pass */
    } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) {
        if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
            result = EX_VAR(opline->result.var);
            fast_long_add_function(result, op1, op2);
            ZEND_VM_NEXT_OPCODE();
        }

        /* ... */
    }

    /* ... */

    ZEND_VM_DISPATCH_TO_HELPER(zend_add_helper, op_1, op1, op_2, op2);
}

Und das ist einer der Handler, die der Generator daraus erzeugt:

static ZEND_VM_HOT ZEND_OPCODE_HANDLER_RET ZEND_OPCODE_HANDLER_FUNC_CCONV
ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    zval *op1, *op2, *result;
    double d1, d2;

    op1 = EX_VAR(opline->op1.var);
    op2 = EX_VAR(opline->op2.var);
    if (1 && (IS_TMP_VAR|IS_VAR|IS_CV) == IS_CONST && (IS_TMP_VAR|IS_VAR|IS_CV) == IS_CONST) {
        /* pass */
    } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) {
        if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
            result = EX_VAR(opline->result.var);
            fast_long_add_function(result, op1, op2);
            ZEND_VM_NEXT_OPCODE();
        }

        /* ... */
    }

    /* ... */

    ZEND_VM_DISPATCH_TO_HELPER(zend_add_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU_EX op1, op2));
}

Die abstrakten Operandenzugriffe sind durch konkrete ersetzt, und die Prüfung auf zwei konstante Operanden ist zu einem Vergleich von Konstanten zusammengefallen, den der C-Compiler wegoptimieren wird. Aus den rund 290 Vorlagen in zend_vm_def.h werden auf diesem Weg 987 Handler-Funktionen. PHP 5.1, das Release mit dem Generator, brachte davon 833 mit; das Ausmaß der Spezialisierung ist über zwanzig Jahre also recht gemächlich gewachsen. Bei kommutativen Operationen wie ADD vertauscht die Engine zusätzlich die beiden Operanden, wenn sie den Handler zuweist, sodass nur die Hälfte der Kombinationen überhaupt einen Handler braucht.

Die Tabelle, die eine Instruktion auf ihren Handler abbildet, hat rund 3.500 Einträge, einen pro Kombination aus Opcode und Operandenarten, und viele davon zeigen auf denselben Handler.

Vom zend_op über die Handler-Tabelle zur Handler-Funktion
Der Generator macht aus 290 Vorlagen 987 Handler-Funktionen. Beim Kompilieren eines Skripts wählt die Kombination aus Opcode und Operandenarten einen Eintrag der Tabelle, und bei einer kommutativen Operation zeigen zwei Einträge auf denselben Handler.

Das Gerüst enthält einen Platzhalter für den Dispatch-Code, und der Generator kann diesen Platzhalter auf fünf verschiedene Arten füllen.

CALL: Ein Funktionsaufruf pro Instruktion

CALL ist das einfachste der fünf. Jeder Handler ist eine gewöhnliche C-Funktion, und handler ist ein Zeiger auf eine davon. Der Executor ist eine Schleife, die den Handler aufruft und die Adresse der nächsten Instruktion aus dessen Rückgabewert nimmt:

while (1) {
    opline = (opline->handler)(execute_data, opline);

    if (UNEXPECTED(((uintptr_t)opline & ZEND_VM_ENTER_BIT))) {
        opline = (const zend_op*)((uintptr_t)opline & ~ZEND_VM_ENTER_BIT);

        if (EXPECTED(opline != NULL)) {
            execute_data = EG(current_execute_data);
            ZEND_VM_LOOP_INTERRUPT_CHECK();
        } else {
            return;
        }
    }
}

Ein Handler endet damit, dass er die Adresse der als Nächstes auszuführenden Instruktion zurückgibt:

#define ZEND_OPCODE_HANDLER_RET  const zend_op *

#define ZEND_VM_CONTINUE()       return opline
#define ZEND_VM_ENTER_BIT        1ULL
#define ZEND_VM_ENTER_EX()       return (zend_op*)((uintptr_t)opline | ZEND_VM_ENTER_BIT)
#define ZEND_VM_RETURN()         return (const zend_op*)ZEND_VM_ENTER_BIT

Darin steckt ein kleiner Kniff, den ich mag. Manchmal will ein Handler nicht nur sagen „hier ist die nächste Instruktion“. Er muss auch sagen können „ich habe einen neuen Call Frame betreten“, „ich habe einen verlassen“ oder „wir sind fertig“. Statt eines zweiten Rückgabewerts werden diese Fälle über das niederwertigste Bit des zurückgegebenen Zeigers signalisiert. Ein zend_op liegt ausgerichtet im Speicher, deshalb ist das niederwertigste Bit eines gültigen Zeigers darauf immer null und steht als Flag zur Verfügung.

CALL braucht nichts weiter als einen Standard-C-Compiler, was es zum Modell macht, das überall funktioniert. Es kostet auch am meisten: Für jeden einzelnen Opcode gibt es einen indirekten Aufruf, einen Rücksprung und eine Schleifenbedingung. Und weil dieser Aufruf immer von derselben Stelle im Code ausgeht, teilen sich alle Opcodes aller Skripte einen einzigen Eintrag in der Sprungvorhersage, also den oben beschriebenen ungünstigsten Fall.

Dispatch-Topologie von CALL
Blau ist die Stelle, an der die indirekte Sprunginstruktion steht. Bei CALL gibt es genau eine, und zwar für alle Opcodes aller Skripte.

SWITCH: Ein sehr großes switch-Statement

Das nächste Modell wird die Funktionsaufrufe los, indem es den Rumpf jedes Handlers in den Executor selbst einfügt, als Fall eines einzigen switch-Statements. handler ist dann kein Zeiger, sondern eine Zahl: das Sprungziel im switch.

#define ZEND_VM_CONTINUE() goto zend_vm_continue

while (1) {
zend_vm_continue:
    dispatch_handler = OPLINE->handler;
zend_vm_dispatch:
    switch ((int)(uintptr_t)dispatch_handler) {
        /* ... */
        case 311: /* ZEND_ADD_SPEC_TMPVARCV_TMPVARCV */
        {
            /* the whole body of the ADD handler, pasted in here */
        }
        /* ... */
    }
}

Keine Aufrufe, keine Rücksprünge, kein Sichern und Wiederherstellen von Registern, und alle Handler können sich dieselben lokalen Variablen teilen. Dafür wird der Executor zu einer einzigen Funktion, die die Rümpfe von tausend Handlern enthält; mehr, als die meisten C-Compiler gut optimieren können. Und aller Dispatch läuft weiterhin über einen Sprung, den, zu dem das switch kompiliert wird.

SWITCH braucht wie CALL nichts über Standard-C hinaus und wird nie automatisch ausgewählt.

Dispatch-Topologie von SWITCH
Die Handler-Rümpfe stehen als Fälle in einer einzigen Funktion. Der Dispatch bleibt trotzdem an einer Stelle, nämlich dort, wo das switch zu einem Sprung kompiliert.

GOTO: Direkt in den nächsten Handler springen

Dieses hier braucht eine C-Erweiterung, die GCC eingeführt hat und die andere Compiler übernommen haben: Labels as Values. Standard-C kann nur zu einem Label springen, das im Code steht. GCC erlaubt es, mit && die Adresse eines Labels zu nehmen und an eine Adresse zu springen, die in einer Variablen steht:

void *target = &&arrived;   /* take the address of a label */

goto *target;               /* jump to the address in a variable */

arrived:
    printf("here we are\n");

Das nennt sich Computed Goto und kompiliert zu einer einzigen indirekten Sprunginstruktion. Damit kann handler die Adresse des Labels aufnehmen, an dem der Code eines Handlers beginnt, und Dispatch wird zu einem Sprung:

#define ZEND_VM_CONTINUE() goto *(void**)(OPLINE->handler)

while (1) {
    goto *(void**)(OPLINE->handler); {
        /* ... */
ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL:
        {
            /* the whole body of the ADD handler, pasted in here */
        }
        /* ... */
    }
}

Die Handler-Rümpfe stehen weiterhin in einer einzigen riesigen Funktion, wie bei SWITCH. Der entscheidende Unterschied liegt darin, wo der Sprung steht. Jeder Handler endet mit seinem eigenen goto *, an seiner eigenen Adresse im Speicher. Jeder davon bekommt einen eigenen Eintrag in der Sprungvorhersage der CPU, und die Vorhersage kann die Muster lernen, die in echtem PHP-Code tatsächlich vorkommen. Das while (1), das das Ganze umschließt, ist eine Formalie; die Kontrolle kehrt nie dorthin zurück.

Dieses Design ist als Direct Threaded Interpreter bekannt. Wie SWITCH wird GOTO nie automatisch ausgewählt.

Dispatch-Topologie von GOTO
Jeder Handler endet mit seinem eigenen goto *. Jeder dieser Sprünge bekommt eine eigene Historie in der Sprungvorhersage, und die kann die Muster lernen, die in echtem PHP-Code vorkommen.

HYBRID: Eigene Funktionen plus Computed Goto

HYBRID ist das, was die meisten PHP-Binaries unter Linux tatsächlich verwenden, und es kombiniert die beiden vorigen Ideen. Handler bleiben eigene C-Funktionen wie bei CALL, sodass der Compiler jede für sich optimieren kann. Der Executor ist aber keine Schleife, die sie aufruft und auf einen Rückgabewert wartet. Er ist eine Tabelle von Labels, eines pro Handler, und jedes Label ruft seinen Handler auf und springt dann per Computed Goto zum nächsten Label:

#define HYBRID_NEXT()     HYBRID_JIT_GUARD(); goto *(void**)(OPLINE->handler)
#define HYBRID_SWITCH()   HYBRID_NEXT();
#define HYBRID_CASE(op)   op ## _LABEL
#define HYBRID_BREAK()    HYBRID_NEXT()
while (1) {
    HYBRID_SWITCH() {
        /* ... */
        HYBRID_CASE(ZEND_ADD_SPEC_TMPVARCV_TMPVARCV):
            ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
            HYBRID_BREAK();
        /* ... */
        HYBRID_CASE(HYBRID_HALT):
            /* restore the saved registers and return from execute_ex() */
    }
}

Die Schleife ist also wieder eine Formalie: Der Sprung von einem Label zum nächsten läuft nie über den Schleifenkopf. Handler, die in zend_vm_def.h als hot markiert sind, bekommen in diesem Modell das Attribut zend_always_inline. Der C-Compiler fügt ihren Rumpf dann in das Label ein, statt einen Aufruf zu erzeugen. Für die häufigsten Operationen bleibt so überhaupt kein Funktionsaufruf übrig, nur der Code des Handlers, gefolgt von seinem eigenen goto *.

HYBRID geht noch einen Schritt weiter und heftet die beiden heißesten Variablen an feste CPU-Register, über eine weitere GCC-Erweiterung namens Global Register Variables:

#if defined(__GNUC__) && ZEND_GCC_VERSION >= 4008 && defined(__x86_64__)
#  define ZEND_VM_FP_GLOBAL_REG "%r14"
#  define ZEND_VM_IP_GLOBAL_REG "%r15"
#endif

register zend_execute_data* volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG);
register const zend_op*     volatile opline       __asm__(ZEND_VM_IP_GLOBAL_REG);

execute_data und opline liegen jetzt dauerhaft in zwei Registern des Prozessors, für die gesamte Lebensdauer des Prozesses. Sie müssen nie aus dem Speicher geladen, nie zurückgeschrieben und nie als Argumente übergeben werden. Deshalb nehmen Handler-Funktionen in diesem Modell überhaupt keine Argumente entgegen und geben nichts zurück. Ein Handler aktualisiert das Register und kehrt zurück; das Label, von dem aus er aufgerufen wurde, springt dann dorthin, wo dieses Register jetzt hinzeigt.

Die Register werden pro Architektur gewählt, und es sind Callee-saved Register, damit sie Aufrufe in gewöhnliche C-Funktionen überstehen. Auf x86-64 sind es %r14 und %r15, auf ARM64 x27 und x28.

Ein Detail, das mich zum Schmunzeln gebracht hat: Um die Dispatch-Tabelle zu füllen, müssen die Adressen all dieser Labels eingesammelt werden, und die Adresse eines Labels lässt sich nur innerhalb der Funktion nehmen, die es enthält. Also ruft die Engine execute_ex() beim Start einmal mit einem Nullzeiger statt eines Call Frames auf. Dieser Aufruf nimmt einen Sonderzweig, der nichts anderes tut, als aufzuschreiben, wo jedes Label liegt:

if (UNEXPECTED(execute_data == NULL)) {
    static zend_vm_opcode_handler_t const labels[] = {
        (void*)&&ZEND_NOP_SPEC_LABEL,
        (void*)&&ZEND_ADD_SPEC_CONST_CONST_LABEL,
        (void*)&&ZEND_ADD_SPEC_CONST_TMPVARCV_LABEL,
        /* ... about 3,500 entries in total ... */
    };

    zend_opcode_handlers = (zend_vm_opcode_handler_t*) labels;
    zend_handlers_count  = sizeof(labels) / sizeof(labels[0]);

    memset(&hybrid_halt_op, 0, sizeof(hybrid_halt_op));
    hybrid_halt_op.handler = (void*)&&HYBRID_HALT_LABEL;

    /* ... */

    goto HYBRID_HALT_LABEL;
}

Das Anhalten funktioniert über denselben Mechanismus. hybrid_halt_op ist eine Schein-Instruktion, deren Handler die Adresse des Labels ist, das aus execute_ex() zurückkehrt. Zum Anhalten richtet ein Handler opline auf diese Schein-Instruktion und kehrt zurück; der nächste Dispatch springt dann zum Ausgang.

HYBRID braucht GCC mit Computed Gotos und Global Register Variables. Wo beides verfügbar ist, ist es das schnellste der fünf.

Dispatch-Topologie von HYBRID
Handler bleiben eigene Funktionen, der Sprung zum nächsten Handler steht aber im Label. Was in zend_vm_def.h als hot markiert ist, wird in das Label eingefügt, sodass für die häufigsten Operationen kein Aufruf übrig bleibt.

TAILCALL: Handler, die einander anspringen

Das neueste Modell, hinzugekommen in PHP 8.5, kommt ungefähr dorthin, wo HYBRID ist, ohne GCC zu brauchen. Es existiert, weil Clang zwar die Computed-Goto-Syntax unterstützt, aber keine Global Register Variables. Mit Clang gebaute PHP-Binaries fielen deshalb auf CALL zurück und waren messbar langsamer.

Die Idee beruht auf dem Konzept des Tail Calls: einem Funktionsaufruf, der das Allerletzte ist, was eine Funktion vor ihrer Rückkehr tut. Erkennt ein Compiler so einen Aufruf, darf er den aktuellen Stack Frame wiederverwenden, statt einen neuen anzulegen, und den Aufruf in einen einfachen Sprung verwandeln. Nach dem Sprung ist nichts mehr zu tun, es gibt also nichts, wohin zurückzukehren wäre. Eine Kette aus einer Milliarde Tail Calls braucht so viel Stack wie ein einzelner.

Bei TAILCALL ist jeder Handler wieder eine gewöhnliche Funktion wie bei CALL. Statt zu einer Schleife zurückzukehren, ruft er den Handler der nächsten Instruktion per Tail Call auf:

#define ZEND_VM_TAIL_CALL(call) ZEND_MUSTTAIL return call
#define ZEND_VM_CONTINUE()      ZEND_VM_TAIL_CALL(opline->handler(execute_data, opline))
#define ZEND_VM_RETURN()        opline = &call_halt_op; ZEND_VM_CONTINUE()

ZEND_MUSTTAIL ist das Attribut musttail, und das „must“ ist wesentlich. Ein Compiler darf Tail Calls optimieren, er muss es aber nicht. Würde er hier darauf verzichten, wüchse der Stack um einen Frame pro Opcode, und jedes nicht triviale Skript liefe aus dem Stack. musttail macht aus „du darfst“ ein „du musst, oder du verweigerst die Übersetzung“.

Die zweite Zutat ist eine Calling Convention namens preserve_none. Bei ihr erklärt die aufrufende Seite, dass sie in keinem Register etwas Erhaltenswertes liegen hat. Die aufgerufene Funktion darf deshalb fast alle Register benutzen, ohne etwas zu sichern und wiederherzustellen. Für eine Kette von Handlern, die nie zurückkehren, passt das gut. Sie hat einen zweiten Effekt, der noch wichtiger ist: preserve_none übergibt ihre ersten Argumente in Registern, die über Aufrufe hinweg nicht überschrieben werden. execute_data und opline bleiben so von Handler zu Handler in denselben zwei Registern, und es ist kein Code nötig, um sie weiterzureichen. Das ist derselbe Vorteil, den HYBRID durch das Anheften von Registern bekommt, erreicht über eine Calling Convention statt über eine Compiler-Erweiterung.

Das Anhalten läuft wieder über eine Schein-Instruktion: call_halt_op, deren Handler eine Funktion ist, die einfach zurückkehrt.

Im Commit, der TAILCALL eingeführt hat, steht, dass mit Clang gebaute Binaries davor je nach Benchmark zwischen 2,8 % und 44 % langsamer waren als mit GCC gebaute. Danach liegen sie gleichauf.

Dispatch-Topologie von TAILCALL
Kein Handler kehrt zurück, jeder springt den nächsten an. execute_data und opline bleiben dabei in denselben Registern, weil preserve_none sie dort übergibt.

Welches Modell nutzt dein PHP?

Seit PHP 8.5 gibt es eine Konstante dafür:

$ php -r 'echo ZEND_VM_KIND, PHP_EOL;'
ZEND_VM_KIND_HYBRID

Die Wahl fällt, wenn PHP kompiliert wird, und sie fällt im C-Präprozessor anhand dessen, was das Build-System über den Compiler herausgefunden hat:

#if 0
/* HYBRID requires support for computed GOTO and global register variables*/
#elif (defined(__GNUC__) && defined(HAVE_GCC_GLOBAL_REGS))
# define ZEND_VM_KIND ZEND_VM_KIND_HYBRID
#elif defined(HAVE_MUSTTAIL) && defined(HAVE_PRESERVE_NONE) && (defined(__x86_64__) || defined(_M_X64) || defined(__aarch64__))
# define ZEND_VM_KIND ZEND_VM_KIND_TAILCALL
#else
# define ZEND_VM_KIND ZEND_VM_KIND_CALL
#endif

Diese drei HAVE_-Namen stammen aus Prüfungen, die das Build-System durchführt, bevor überhaupt etwas kompiliert wird. HAVE_GCC_GLOBAL_REGS wird gesetzt, wenn ein kleines Testprogramm mit Global Register Variables kompiliert und läuft. HAVE_MUSTTAIL wird gesetzt, wenn der Compiler das Attribut musttail kennt. HAVE_PRESERVE_NONE ist die gründlichste der drei Prüfungen: Sie kompiliert und startet ein Programm, das nicht nur prüft, ob preserve_none verstanden wird, sondern auch, ob der Compiler die ersten beiden Argumente und den Rückgabewert in genau die Register legt, die der JIT erwartet. In der Praxis bedeutet diese Kombination Clang 19 oder neuer auf x86-64 oder ARM64. Trifft kein Zweig zu, wird CALL verwendet. SWITCH und GOTO tauchen in dieser Kaskade nicht auf: Sie lassen sich nicht dadurch auswählen, dass du PHP anders baust, sondern nur, indem du den Executor neu generierst.

Entscheidungsbaum für die Wahl des Dispatch-Modells
Die Kaskade im Präprozessor hat nur drei Ausgänge. SWITCH und GOTO sind daraus nicht erreichbar.

Dieses Neugenerieren ist ein Schritt, den du vor dem Kompilieren von PHP ausführst:

$ cd Zend
$ php zend_vm_gen.php --with-vm-kind=SWITCH

Erlaubte Werte sind CALL, SWITCH, GOTO und HYBRID, mit HYBRID als Voreinstellung. TAILCALL fehlt hier bewusst: Es ist kein eigener Modus des Generators, sondern eine Variante, die der Generator neben CALL und HYBRID mit ausgibt, abgesichert durch die Kaskade von oben. Deshalb bekommst du mit einem per --with-vm-kind=SWITCH erzeugten Header SWITCH und sonst nichts.

Zwei weitere Optionen des Generators lohnt es sich zu kennen, falls du dich einmal umsiehst: --without-specializer schaltet die weiter oben beschriebene Spezialisierung der Operanden ab, was die generierte Datei sehr viel kleiner und sehr viel langsamer macht; --with-lines gibt #line-Direktiven aus, sodass dir ein Debugger die Vorlage in zend_vm_def.h zeigt statt des generierten Codes.

Weil handler in jedem Modell etwas anderes bedeutet, ändert sich auch sein C-Typ mit dem Modell:

#if ZEND_VM_KIND == ZEND_VM_KIND_HYBRID
typedef const void* zend_vm_opcode_handler_t;
#elif ZEND_VM_KIND == ZEND_VM_KIND_CALL || ZEND_VM_KIND == ZEND_VM_KIND_TAILCALL
typedef const struct _zend_op *(ZEND_OPCODE_HANDLER_CCONV *zend_vm_opcode_handler_t)(struct _zend_execute_data *execute_data, const struct _zend_op *opline);
#elif ZEND_VM_KIND == ZEND_VM_KIND_SWITCH
typedef int zend_vm_opcode_handler_t;
#elif ZEND_VM_KIND == ZEND_VM_KIND_GOTO
typedef const void* zend_vm_opcode_handler_t;
#endif

Wofür die Modelle jeweils gut sind

Das war die Frage, mit der ich eigentlich angefangen habe, und die Antwort fiel weniger exotisch aus als erwartet. Drei der Modelle sind im produktiven Einsatz, zwei nicht.

Modell Dispatch Handler sind Verwendet für
CALL indirekter Aufruf aus einer Schleife eigene Funktionen Windows-Builds und jede andere Toolchain
SWITCH ein switch-Statement eingefügte Fälle nur Referenz und Benchmarking
GOTO Computed goto eingefügte Label-Blöcke nur Referenz und Benchmarking
HYBRID Computed goto plus Aufruf eigene Funktionen GCC-Builds, also die meisten Linux-Pakete
TAILCALL garantierter Tail Call eigene Funktionen Clang-Builds auf x86-64 und ARM64

CALL ist die Untergrenze der Portabilität. Windows-Binaries werden mit Microsofts Compiler gebaut, der weder Computed Gotos noch Global Register Variables noch preserve_none kennt. Dort bekommst du also dieses Modell. Es ist auch das Modell, mit dem sich am leichtesten arbeiten lässt: Jeder Handler ist eine normale Funktion, ein Profiler zeigt dir also Handler-Namen, und ein Debugger kann einen Breakpoint hineinsetzen.

HYBRID ist das, was das PHP-Paket einer Linux-Distribution verwendet, weil diese mit GCC gebaut werden. TAILCALL bekommst du, wenn du mit einem aktuellen Clang auf x86-64 oder ARM64 baust; das deckt macOS und FreeBSD ab, wo Clang die Standard-Toolchain ist. Bei diesen beiden findet die Performance-Arbeit statt.

SWITCH und GOTO sind die beiden, die von keinem Build ausgewählt werden. Sie werden gepflegt, weil sie sich gut zum Vergleichen eignen und weil sie den Entwurfsraum dokumentieren. Wenn du wissen willst, was Direct Threading gegenüber einem switch bringt, kannst du beides bauen und messen. Dass sie keine produktiven Pfade sind, steht ziemlich deutlich in OPcache, das sich gegen sie nicht einmal übersetzen lässt:

#if ZEND_VM_KIND != ZEND_VM_KIND_CALL && ZEND_VM_KIND != ZEND_VM_KIND_TAILCALL && ZEND_VM_KIND != ZEND_VM_KIND_HYBRID
# error JIT is compatible only with CALL and HYBRID VM
#endif

Die Meldung ist der Bedingung nicht ganz gefolgt, TAILCALL ist ebenfalls erlaubt. Der Punkt bleibt aber: Mit einem SWITCH- oder GOTO-Executor lässt sich OPcache nicht bauen.

Womit wir beim JIT wären. Er tritt neben all das, statt es zu ersetzen: OPcache kann einige Funktionen zu Maschinencode kompilieren, und diese Funktionen laufen dann gar nicht mehr durch die Dispatch-Schleife. Alles, was nicht kompiliert ist, läuft weiterhin hindurch; deshalb bleibt der Executor auch mit aktiviertem JIT wichtig. Die beiden sind außerdem eng verflochten: Im HYBRID-Modus springt der JIT direkt zu den Handler-Labels in execute_ex(). Deshalb gibt es im Dispatch-Pfad ein Makro, dessen einzige Aufgabe darin besteht, bestimmte Register für den JIT unangetastet zu lassen.

Was ich mitgenommen habe

Der Executor beruht auf einer sehr kleinen Idee: eine Instruktion ansehen, sie ausführen, zur nächsten gehen. In diesen letzten Schritt sind Jahrzehnte Arbeit geflossen. Alle fünf Modelle führen dieselben Instruktionen in derselben Reihenfolge aus und liefern dieselben Ergebnisse. Sie unterscheiden sich nur darin, wie die Kontrolle von einer Instruktion zur nächsten kommt, und bei manchen Workloads ist dieser Unterschied einen zweistelligen Prozentsatz wert.

Nichts davon ändert daran etwas, wie wir PHP schreiben. Geändert hat sich, worauf ich achte, wenn mir jemand einen Benchmark zeigt. Der Compiler, mit dem ein PHP-Binary gebaut wurde, ist eine Performance-Entscheidung, und bis PHP 8.5 war es eine ziemlich große. Wenn du Zahlen zwischen zwei PHP-Installationen vergleichst, gehört ZEND_VM_KIND neben der Versionsnummer und den relevanten INI-Einstellungen in den Vergleich.

Worauf ich immer wieder zurückkomme, ist der Generator. Ein Kernstück der Zend Engine wird von einem PHP-Skript aus Vorlagen erzeugt, zu hunderttausend Zeilen C, die niemand am Stück liest. Das macht es praktikabel, fünf Executoren zu pflegen, sie auf dasselbe Verhalten zu verpflichten und eine sechste Idee auszuprobieren, ohne tausend Handler von Hand zu schreiben.