Ein Trampelpfad zieht sich quer über eine Rasenfläche in einem Park. Er ist kaum einen Schuh breit, das Gras an seinen Rändern ist niedergetreten, die Erde in der Mitte blank. Er verläuft nicht gerade, sondern in einem flachen Bogen den Hang hinauf, dorthin, wo er zwischen den Büschen verschwindet. Niemand hat diesen Weg geplant; er ist entstanden, weil alle ihn genommen haben. So entscheidet auch ein Tracing-JIT, welchen Code er kompiliert.
Geplant war dieser Weg nicht. Er zeigt genau die Strecke, die tatsächlich gegangen wurde.

Der JIT ist auf keiner Standardinstallation eingeschaltet. Für die Testsuite, an der ich das gemessen habe, ist das bei einer der beiden Betriebsarten auch richtig so: Mit opcache.jit=function dauert ein Testlauf 5,8 % länger als ganz ohne JIT, mit opcache.jit=tracing gut ein Viertel kürzer. Derselbe Compiler, dieselbe Testsuite, und der Unterschied liegt allein darin, wann entschieden wird, was kompiliert wird.

Ein Just-in-Time-Compiler (JIT) ist die zweite Übersetzung. Die erste macht der Compiler, bevor das Skript läuft: aus Quelltext werden Opcodes, und OPcache hebt sie auf. Der JIT übersetzt diese Opcodes weiter in Maschinencode, während das Skript bereits läuft. Seine Rechenzeit fehlt damit unmittelbar in der Antwortzeit, auf die gerade jemand wartet, und er muss sie wieder hereinholen. Ob ihm das gelingt, hängt daran, wie lange der kompilierte Code danach noch läuft.

Zuvor habe ich mir den Executor und den Optimizer angesehen. Der Artikel über den Executor endete mit einem Satz über den JIT, den ich nicht so stehen lassen wollte. Also habe ich weitergelesen. Alles, was folgt, bezieht sich auf PHP 8.6.

Was der Interpreter nicht loswird

Der Dispatch ist der Hot Spot eines Interpreters, aber er ist nicht die Arbeit. Die Arbeit steckt in den Handlern, und dort passiert bei jedem Durchlauf dasselbe.

Nimm diese Funktion:

<?php declare(strict_types=1);
function sum(int $n): int
{
    $result = 0;

    for ($i = 0; $i < $n; $i++) {
        $result += $i;
    }

    return $result;
}

Der Handler für ADD sieht sich die beiden Operanden an, stellt fest, dass beide Integer sind, addiert sie, prüft auf Überlauf und schreibt das Ergebnis mitsamt Typmarkierung zurück. Beim tausendsten Durchlauf weiß er darüber genau so viel wie beim ersten. Er kann nichts wissen: Er ist eine C-Funktion, die für jedes Vorkommen von ADD in jedem Skript aufgerufen wird.

Dazu kommt, wo die Werte liegen. Eine PHP-Variable ist ein zval: sechzehn Bytes, acht für den Wert und acht für Typinformationen und Verwaltung. $result und $i liegen im Call Frame, also im Speicher. Jede Addition lädt beide, prüft beide Typen, rechnet und schreibt zurück.

Ein JIT spart nicht das Springen ein, sondern das immer wieder neu Herausfinden. Wenn feststeht, dass in dieser Schleife immer zwei Integer addiert werden, dann ist die Addition eine einzige Maschineninstruktion auf zwei Registern, und der ganze Rest fällt weg.

Wie er zu diesem Wissen kommt, ist die Frage, an der sich die beiden Betriebsarten unterscheiden.

Der JIT ist nur ein weiterer Handler

Im Artikel über den Executor ging es um das Feld handler in zend_op: die Stelle, an der steht, wie eine Instruktion auszuführen ist. Je nach Dispatch-Modell ist das ein Funktionszeiger, eine Zahl oder eine Sprungadresse.

Der JIT benutzt dasselbe Feld. Am Ende der Kompilierung steht in ext/opcache/jit/zend_jit_ir.c diese Zuweisung:

if (jit->op_array) {
    /* Only for function JIT */
    const zend_op_array *op_array = jit->op_array;
    zend_op *opline = (zend_op*)op_array->opcodes;

    if (!(op_array->fn_flags & ZEND_ACC_HAS_TYPE_HINTS)) {
        while (opline->opcode == ZEND_RECV) {
            opline++;
        }
    }
    opline->handler = (zend_vm_opcode_handler_t)entry;

entry ist die Adresse des Maschinencodes, den der JIT gerade erzeugt hat. Er wird in das Feld geschrieben, aus dem der Dispatch-Code den Handler holt. Für den Executor ist kompilierter Code damit nicht von einem Handler zu unterscheiden: Er springt hin wie zu jedem anderen auch.

Der JIT trägt die Adresse seines Maschinencodes in das Feld handler ein
Dasselbe Bild wie im Artikel über den Executor, mit einer umgebogenen Kante. Der Handler dahinter existiert weiter, wird für diese Instruktion aber nicht mehr angesprungen.

Deshalb ersetzt der JIT den Executor nicht. Er hängt sich an einzelne Instruktionen und übernimmt von dort ab, solange es gut geht. Alles, was nicht kompiliert ist, läuft weiter durch die Dispatch-Schleife.

Diese enge Kopplung hat eine Konsequenz, die im Artikel über den Executor schon angeklungen ist. OPcache prüft beim Start, ob der Executor noch der eigene ist:

if (zend_execute_ex != execute_ex) {
    if (zend_dtrace_enabled) {
        zend_error(E_WARNING, "JIT is incompatible with DTrace. JIT disabled.");
    } else if (strcmp(sapi_module.name, "phpdbg") != 0) {
        zend_error(E_WARNING, "JIT is incompatible with third party extensions that override zend_execute_ex(). JIT disabled.");
    }
    JIT_G(enabled) = 0;
    JIT_G(on) = 0;
    return FAILURE;
}

zend_execute_ex ist der Funktionszeiger, über den die Engine in den Executor geht. Xdebug ersetzt ihn, um bei jedem Aufruf einer Funktion die Kontrolle zu bekommen: anders lässt sich kein Stack Trace aufzeichnen und kein Breakpoint anhalten. Sobald das passiert ist, schaltet sich der JIT ab, und zwar bevor er irgendetwas kompiliert hat. JIT und Xdebug schließen sich nicht deshalb aus, weil jemand das so entschieden hätte, sondern weil der JIT direkt in Handler hineinspringt, die dann gar nicht mehr laufen.

Der JIT ist aus

Der Satz vom Anfang lässt sich nachsehen:

$ php -r 'var_dump(ini_get("opcache.jit"), ini_get("opcache.jit_buffer_size"));'
string(7) "disable"
string(3) "64M"

Bis PHP 8.3 stand die Einstellung auf tracing und der Puffer auf 0, was auf dasselbe hinauslief, aber deutlich verwirrender war. Seit PHP 8.4 ist es umgekehrt: der Puffer ist da, der JIT ist abgeschaltet. Weder php.ini-production noch php.ini-development erwähnen opcache.jit überhaupt. Wer den JIT haben will, muss die Einstellung selbst in seine php.ini eintragen.

Es gibt zwei Betriebsarten, und beide werden über denselben Schalter eingestellt:

$ php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit=tracing \
      -r 'print_r(opcache_get_status()["jit"]);'
Array
(
    [enabled] => 1
    [on] => 1
    [kind] => 5
    [opt_level] => 4
    [opt_flags] => 6
    [buffer_size] => 67108848
    [buffer_free] => 67107035
)

opcache.jit akzeptiert außerdem eine vierstellige Zahl, in der jede Ziffer einen Aspekt einstellt, von links nach rechts: ob AVX-Instruktionen verwendet werden dürfen, die Registerallokation, der Auslöser und der Optimierungsgrad. tracing ist die Kurzschreibweise für 1254, function für 1205. Die Ziffern einzeln zu drehen ist etwas für die Fehlersuche im JIT selbst; für alles andere reichen die beiden Wörter.

Function-JIT: alles kompilieren, bevor irgendetwas läuft

Der Function-JIT wird ausgelöst, wenn ein Skript geladen wird, und kompiliert jede Funktion darin vollständig. Ob sie je aufgerufen wird, spielt keine Rolle.

Seine einzige Informationsquelle ist die Typinferenz, die der Optimizer ohnehin durchführt. Sie überführt die Funktion in Static-Single-Assignment-Form, kurz SSA, und leitet daraus ab, welche Typen eine Variable an einer bestimmten Stelle haben kann. Bei sum(int $n) ist $n ein Integer, das steht in der Signatur. $i beginnt bei 0 und wird nur inkrementiert. $result beginnt ebenfalls bei 0, aber $result + $i kann überlaufen, und dann ist es ein Float.

Was die Inferenz nicht ausschließen kann, muss zur Laufzeit geprüft werden, und beide Zweige müssen im erzeugten Code stehen. So sieht die Schleife aus, die dabei herauskommt:

.next:
mov    %rcx,0x60(%r14)       ; $result in den Call Frame zurückschreiben
.inc:
lea    0x1(%rax),%rax        ; $i++
cmpb   $0x0,0x20e79be        ; EG(vm_interrupt)
jne    .interrupt
mov    0x50(%r14),%rcx       ; $n aus dem Call Frame laden
cmp    %rax,%rcx
jle    .done
mov    %rcx,0x50(%r14)
movl   $0x4,0x58(%r14)
cmpb   $0x4,0x68(%r14)       ; ist $result ein int?
jne    .slow_path
mov    %rax,%rcx
add    0x60(%r14),%rcx       ; $result + $i, der Summand kommt aus dem Speicher
jno    .next
vxorps %xmm0,%xmm0,%xmm0     ; Überlauf: der float-Pfad, inline
vcvtsi2sdq 0x60(%r14),%xmm0,%xmm0
vxorps %xmm1,%xmm1,%xmm1
vcvtsi2sd %rax,%xmm1,%xmm1
vaddsd %xmm0,%xmm1,%xmm0
vmovsd %xmm0,0x60(%r14)
movl   $0x5,0x68(%r14)       ; Typ von $result auf IS_DOUBLE setzen
jmp    .inc

%r14 hält den Zeiger auf den Call Frame, die Zahlen davor sind Offsets darin. $result wird in jeder Iteration in den Speicher zurückgeschrieben und $n in jeder Iteration neu geladen. Der Typ von $result wird in jeder Iteration erneut geprüft. Und der Float-Pfad für den Überlauf steht mitten in der Schleife, mit sechs SSE-Instruktionen, die fast nie ausgeführt werden.

Der Code ist korrekt für jeden Fall, der eintreten kann, und bezahlt das mit Speicherzugriffen und Typprüfungen in jeder Iteration.

Tracing-JIT: erst zusehen, dann kompilieren

Der Tracing-JIT dreht die Reihenfolge um. Er kompiliert nichts, bevor er nicht gesehen hat, was tatsächlich passiert.

Dafür setzt er Zähler an die Stellen, an denen sich Wiederholung abzeichnet: an Schleifenköpfe und an Funktionseinstiege. Der Zähler ist wieder ein Handler, der den ursprünglichen Handler aufruft, wenn er noch nicht ausgelöst hat:

ZEND_OPCODE_HANDLER_RET ZEND_OPCODE_HANDLER_CCONV zend_jit_func_counter_helper(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_jit_op_array_hot_extension *jit_extension =
        (zend_jit_op_array_hot_extension*)ZEND_FUNC_INFO(&EX(func)->op_array);

    *(jit_extension->counter) -= ((ZEND_JIT_COUNTER_INIT + JIT_G(hot_func) - 1) / JIT_G(hot_func));

    if (UNEXPECTED(*(jit_extension->counter) <= 0)) {
        *(jit_extension->counter) = ZEND_JIT_COUNTER_INIT;
        zend_jit_hot_func(execute_data, opline);
        ZEND_OPCODE_RETURN();
    } else {
        zend_vm_opcode_handler_t handler = (zend_vm_opcode_handler_t)jit_extension->orig_handlers[opline - EX(func)->op_array.opcodes];
        ZEND_OPCODE_TAIL_CALL(handler);
    }
}

zend_jit_hot_counters ist ein Array von 128 Zählern für das gesamte Programm, und welchen eine Funktion bekommt, entscheidet ein Hash über die Adresse ihres Opcode-Arrays. Verschiedene Funktionen teilen sich also Zähler und heizen sich gegenseitig auf. Für den Zweck reicht das: Gebraucht wird keine genaue Zählung, nur ein Hinweis darauf, dass hier oft genug etwas passiert.

Die Schwellen stehen in opcache.jit_hot_loop (61), opcache.jit_hot_func (127) und opcache.jit_hot_return (8). Läuft ein Zähler ab, übernimmt ein zweiter Executor: ein aufzeichnender Interpreter. Er ruft dieselben Handler auf wie sonst auch, protokolliert dabei aber, welche Instruktionen tatsächlich ausgeführt wurden und welche Typen die Operanden hatten.

Was dabei entsteht, ist eine gerade Linie durch den Code. Keine Verzweigungen, keine Schleifenstruktur, nur die Instruktionen in der Reihenfolge, in der sie diesmal ausgeführt wurden. Und weil dem aufzeichnenden Interpreter Funktionsgrenzen egal sind, läuft die Aufzeichnung durch Aufrufe hindurch.

Für diese Schleife:

function square(int $x): int { return $x * $x; }

for ($i = 0; $i < $n; $i++) { $s += square($i); }

zeichnet der Tracer Folgendes auf:

0009 T3 = IS_SMALLER CV2($i) CV0($n) ; op1(int) op2(int)
0010 ;JMPNZ T3 0004
0004 INIT_FCALL 1 112 string("square")
     >init square
0005 SEND_VAR CV2($i) 1 ; op1(int)
0006 T3 = DO_UCALL
     >enter square
0000  CV0($x) = RECV 1
0001  T1 = MUL CV0($x) CV0($x) ; op1(int) op2(int)
0002  VERIFY_RETURN_TYPE T1 ; op1(int)
0003  RETURN T1 ; op1(int)
     <back run
0007 CV1($s) = ADD CV1($s) T3 ; op1(int) op2(int)
0008 PRE_INC CV2($i) ; op1(int)

Aus dem Aufruf ist eine Einrückung geworden. Die beobachteten Typen stehen als Kommentar hinter jeder Instruktion, und aus ihnen wird gleich der kompilierte Code.

Die Aufzeichnung endet aus einem von 28 Gründen, die als Makro in zend_jit_internal.h stehen:

#define ZEND_JIT_TRACE_STOP(_) \
    _(LOOP,              "loop") \
    _(RECURSIVE_CALL,    "recursive call") \
    _(RECURSIVE_RET,     "recursive return") \
    _(RETURN,            "return") \
    _(LINK,              "link to another trace") \
    _(INTERPRETER,       "exit to VM interpreter") \
    /* ... */ \
    _(NOT_SUPPORTED,     "not supported instructions") \
    _(EXCEPTION,         "exception") \
    _(TOO_LONG,          "trace too long") \
    _(TOO_DEEP,          "trace too deep") \
    _(LOOP_UNROLL,       "loop unroll limit reached") \
    _(LOOP_EXIT,         "exit from loop") \
    _(BLACK_LIST,        "trace blacklisted") \
    _(INNER_LOOP,        "inner loop")

Die ersten sind Erfolge: Die Schleife ist zum Anfang zurückgekehrt, die Funktion ist zurückgekehrt, der Trace mündet in einen anderen Trace. Die späteren sind Abbrüche. Nur drei Opcodes lassen sich überhaupt nicht aufzeichnen, nämlich CATCH, FAST_CALL und FAST_RET, also die Instruktionen, über die catch und finally angesprungen werden.

Jede Annahme kostet einen Ausgang

Hier zahlt der JIT für seinen Vorteil. Ein Compiler, der vor dem Start übersetzt, darf nur annehmen, was er aus dem Quelltext beweisen kann; so arbeiten C, Go und Rust. Der JIT darf annehmen, was er beobachtet hat, und bezahlt das damit, dass jede dieser Annahmen zur Laufzeit abgesichert werden muss.

Ein aufgezeichneter Trace hält fest, was einmal passiert ist. Über den nächsten Durchlauf sagt er nichts. Also wird jede Annahme zu einer Prüfung, und jede Prüfung braucht einen Ausgang.

Diese Ausgänge heißen Side Exits, und OPcache listet sie auf Wunsch auf. Für die Schleife aus sum() sind es vier:

---- TRACE 1 exit info
     exit_0: 0006/0000/1 CV0($n):int
     exit_1: 0008/0001/3 CV0($n):int(rax:1) CV1($result):int(rcx) CV2($i):int(rdx)
     exit_2: 0004/0004/3 CV0($n):int(rax:1) CV1($result):int(rcx) CV2($i):int(rdx)
     exit_3: 0006/0007/3/VM CV0($n):int(rax:1) CV1($result):int(rsi) CV2($i):int(rdx)

Der erste prüft einen Typ, bevor die Schleife losläuft. Der zweite ist das Schleifenende, der dritte der Überlauf bei der Addition, der vierte eine Unterbrechung durch die Engine. Hinter jedem Ausgang steht, welche Variablen dort noch leben und in welchem Register sie liegen.

Der Trace der Schleife aus sum() mit seinen vier Ausgängen
Der aufgezeichnete Trace ist die Schleife als gerade Linie, und an jeder Prüfung darin hängt ein Ausgang. Bei dieser Schleife sind es vier, und alle vier münden in dieselbe Rückübersetzung.

Wie viele Guards ein Trace braucht, hängt davon ab, wie viel schon feststeht. Ohne die Typdeklaration in sum(int $n) sieht dieselbe Schleife so aus:

---- TRACE 1 exit info
     exit_0: 0006/----/0
     exit_1: 0006/0000/1 CV0($n):int
     exit_2: 0006/0001/2 CV0($n):int CV1($result):int
     exit_3: 0008/0003/3 CV0($n):int(rax:1) CV1($result):int(rcx) CV2($i):int(rdx)
     exit_4: 0004/0006/3 CV0($n):int(rax:1) CV1($result):int(rcx) CV2($i):int(rdx)
     exit_5: 0006/0009/3/VM CV0($n):int(rax:1) CV1($result):int(rsi) CV2($i):int(rdx)

Aus vier Ausgängen werden sechs, aus 78 Bytes Maschinencode werden 100. Die zusätzlichen Prüfungen stehen vor der Schleife, nicht darin, der Laufzeitunterschied ist hier also klein. Interessant finde ich trotzdem, dass sich sehen lässt, wie eine Typdeklaration im Maschinencode ankommt.

An jedem Ausgang übernimmt der Interpreter

Im kompilierten Code liegen die Werte in Registern, und sie liegen dort ohne Typmarkierung, weil der Typ ja feststand. Der Interpreter erwartet aber zval-Strukturen im Call Frame. Zwischen beiden Welten muss jemand übersetzen.

Das tut zend_jit_trace_exit(). Ein Stub rettet zuerst sämtliche Register in einen Puffer. Dann geht die Funktion die Slots des Call Frames durch und schreibt jeden Wert als ordentliches zval zurück, anhand einer Karte, die beim Kompilieren angelegt wurde:

} else if (STACK_REG(stack, i) != ZREG_NONE) {
    if (STACK_TYPE(stack, i) == IS_LONG) {
        zend_long val = regs->gpr[STACK_REG(stack, i)];
        ZVAL_LONG(EX_VAR_NUM(i), val);
    } else if (STACK_TYPE(stack, i) == IS_DOUBLE) {
        double val = regs->fpr[STACK_REG(stack, i) - ZREG_FIRST_FPR];
        ZVAL_DOUBLE(EX_VAR_NUM(i), val);
    } else {
        ZEND_UNREACHABLE();
    }
}

Ein Wert kann aus einem Register kommen, aus einem Spill Slot auf dem Stack, aus einer Konstanten oder auch nur aus einer Typangabe ohne Wert. Am Ende wird EX(opline) auf die Instruktion gesetzt, bei der es weitergehen soll, und der Interpreter macht dort weiter, als wäre nie etwas gewesen. Diese Rückübersetzung heißt Deoptimierung, und sie ist der Vertrag zwischen JIT und Executor: An jedem Ausgang muss der Interpreter übernehmen können.

Deoptimierung: aus Registern und Spill Slots werden wieder zval
Im kompilierten Code liegt ein Wert in einem Register, in einem Spill Slot oder als Konstante, und immer ohne Typmarkierung. Der Interpreter bekommt an jedem Ausgang ordentliche zval im Call Frame und eine Instruktion, an der er weitermacht.

Wenn derselbe Ausgang oft genommen wird, war die Annahme dahinter zu eng. Nach acht Fehlschlägen, einstellbar über opcache.jit_hot_side_exit, zeichnet der JIT von dort aus einen neuen Trace auf. Diese Ausgabe stammt von einer Schleife über ein Array, das zuerst nur Integer und dann auch Floats enthält:

---- TRACE 1 start (loop) work() sidetrace.php:6
---- TRACE 1 stop (loop)
---- TRACE 1 compiled

     TRACE 1 exit 0 work() sidetrace.php:6
     TRACE 1 exit 0 work() sidetrace.php:6
     ... sechsmal mehr ...
---- TRACE 3 start (side trace 1/0) work() sidetrace.php:6
---- TRACE 3 stop (link to 1)
---- TRACE 3 compiled

Trace 1 gilt für Integer. Trace 3 ist der Nachzügler für Floats, und er verlinkt zurück auf Trace 1, sobald er fertig ist. So wächst mit der Zeit ein Netz aus Traces, das die tatsächlich vorkommenden Fälle abdeckt, statt alle denkbaren.

Trace 1 und der Side Trace 3, der auf ihn zurückverlinkt
Wird derselbe Ausgang achtmal genommen, zeichnet der JIT von dort einen neuen Trace auf. Trace 3 deckt den Fall ab, den Trace 1 nicht kannte.

Ein Compiler, der sich keine Zeit nehmen darf

Zwischen dem aufgezeichneten Trace und dem Maschinencode liegt ein vollständiger optimierender Compiler. Seit PHP 8.4 ist das nicht mehr PHP-eigener Code: darunter liegt das IR Framework von Dmitry Stogov, IR für Intermediate Representation, das in ext/opcache/jit/ir/ eingecheckt liegt und periodisch aus dem Ursprungsprojekt nachgezogen wird. Rund 47.600 Zeilen entfallen auf das Framework, rund 37.100 auf den PHP-spezifischen Teil, der Opcodes in seine Darstellung übersetzt.

Diese Darstellung ist ein Sea of Nodes: ein Graph, in dem Daten- und Kontrollabhängigkeiten dasselbe sind, nämlich Kanten zwischen Knoten. Es gibt keine getrennten Basisblöcke, an denen Instruktionen kleben; eine Addition schwebt so lange frei, bis feststeht, wo sie am günstigsten steht. Dieselbe Idee steckt in HotSpot und in V8.

IR-Graph der Schleife aus sum()
Der Graph, den opcache.jit_debug für diese Schleife ausgibt: Kontroll- und Datenabhängigkeiten sind beide Kanten zwischen Knoten. Blau ist der Kontrollfluss, grau der Datenfluss, und die Ausgänge hängen als Adressen an den Prüfungen.

Auf dieser Darstellung läuft dann die eigentliche Pipeline:

ir_build_def_use_lists(ctx);
ir_sccp(ctx);

ir_build_cfg(ctx);
ir_build_dominators_tree(ctx);
ir_find_loops(ctx);

ir_gcm(ctx);
ir_schedule(ctx);

ir_match(ctx);
ir_assign_virtual_registers(ctx);
ir_compute_live_ranges(ctx);
ir_coalesce(ctx);
ir_reg_alloc(ctx);

ir_schedule_blocks(ctx);

entry = ir_emit_code(ctx, size);

Das ist alles. Konstantenfaltung passiert schon beim Aufbau des Graphen, dann eine Runde Konstantenpropagierung, dann wird der Kontrollflussgraph aufgebaut und jeder schwebende Knoten an seine beste Stelle gehängt, dann Instruktionsauswahl, dann Registerallokation nach Linear Scan, dann Codeausgabe. Zum Vergleich: GCC und LLVM lassen Dutzende Durchläufe über den Code laufen.

Die Kürze ist Absicht, und das Framework selbst nennt die Zahlen dazu: Der erzeugte Code ist im Mittel etwa 5 % langsamer als der von gcc -O2, aber er entsteht rund vierzigmal schneller. Bei einem Compiler, der läuft, während jemand auf die Antwort wartet, ist das der richtige Handel.

Mir gefällt der Kontrast zum Executor. Der Executor wird von einem PHP-Skript aus Vorlagen erzeugt, einmal, bevor PHP gebaut wird, und darf sich dafür beliebig viel Zeit nehmen. Der JIT erzeugt Code während der Anfrage und muss jede Millisekunde rechtfertigen. Beide Male wird Code generiert, und die Rahmenbedingungen führen zu völlig verschiedenen Entwürfen.

Der Kreislauf des Tracing-JIT
Der Weg vom Zähler zum Maschinencode ist gerade. Interessant ist die Rückkehr: Jeder Ausgang gibt an den Interpreter ab, und ein Ausgang, der oft genug genommen wird, startet die Aufzeichnung von vorn.

Eine Schleife, neun Instruktionen

Ausgangspunkt ist wieder sum() von oben. Der aufgezeichnete Trace ist die Schleife, linearisiert:

0006 T3 = IS_SMALLER CV2($i) CV0($n) ; op1(int) op2(int)
0007 ;JMPNZ T3 0004
0004 CV1($result) = ADD CV1($result) CV2($i) ; op1(int) op2(int)
0005 PRE_INC CV2($i) ; op1(int)

Vier Instruktionen, alle Operanden als Integer beobachtet. Daraus werden 78 Bytes Maschinencode:

movl   $0x1,0x20e7990        ; EG(jit_trace_num) = 1
cmpb   $0x4,0x68(%r14)       ; ist $result ein int?
jne    .exit_0
mov    0x50(%r14),%rax       ; $n
mov    0x60(%r14),%rcx       ; $result
mov    0x70(%r14),%rdx       ; $i
.loop:
cmp    %rax,%rdx
jge    .exit_1                ; $i >= $n, die Schleife ist zu Ende
mov    %rdx,%rsi
add    %rcx,%rsi             ; $result + $i
jo     .exit_2                ; das Ergebnis passt nicht mehr in einen int
lea    0x1(%rdx),%rdx        ; $i++
cmpb   $0x0,0x20e79be        ; EG(vm_interrupt)
jne    .exit_3
mov    %rsi,%rcx             ; das neue $result
jmp    .loop

Die Sprungziele habe ich benannt; im Original stehen dort Adressen. Sonst ist das, was der JIT erzeugt hat.

Oben der Eintritt: eine Typprüfung, dann werden $n, $result und $i einmal aus dem Call Frame in Register geholt. Danach neun Instruktionen Schleifenrumpf, in denen kein einziger Speicherzugriff mehr vorkommt. Vergleichen, addieren, hochzählen, drei Sprünge für die drei Dinge, die schiefgehen können, und zurück zum Anfang.

Die beiden absoluten Adressen sind Variablen der Engine. 0x20e7990 ist EG(jit_trace_num), in das der Trace seine Nummer schreibt, damit die Deoptimierung später weiß, aus welchem Trace sie kommt. 0x20e79be ist EG(vm_interrupt), das Flag, über das Signale, Timeouts und der Garbage Collector die Ausführung unterbrechen. Auch kompilierter Code muss danach sehen; sonst ließe sich eine JIT-kompilierte Schleife nicht mehr abbrechen.

Wer sich das selbst ansehen will: Alle Ausgaben in diesem Artikel kommen aus opcache.jit_debug. Der Wert ist eine Bitmaske, und er muss dezimal angegeben werden. -d opcache.jit_debug=0x1000 wird stillschweigend als 0 gelesen und gibt gar nichts aus, was genau so aussieht, als wäre nichts kompiliert worden. Nützlich sind 4096 für Trace-Starts, 16384 für erfolgreiche Kompilierungen, 262144 für den aufgezeichneten Bytecode und 1048576 für die Ausgangsliste. Für den Assembler braucht es ein PHP, das mit --with-capstone gebaut wurde.

Und jetzt echter Code

Neun Instruktionen für eine Schleife sind ein schönes Ergebnis, aber sum() ist auch keine Software. Also habe ich mir dasselbe an dem Raytracer aus dem letzten Artikel angesehen: final readonly-Klassen mit float-Properties. Viel Rechnerei, aber in Objekte und Methodenaufrufe verpackt. Der Link zeigt auf genau den Stand, den ich untersucht und gemessen habe.

Die innerste Schleife der Matrixmultiplikation sieht so aus:

<?php declare(strict_types=1);
for ($i = 0; $i < $size; $i++) {
    for ($k = 0; $k < $size; $k++) {
        for ($j = 0; $j < $size; $j++) {
            $result[$i][$k] += $this->elements[$i][$j] * $that->element($j, $k);
        }
    }
}

Der Tracer zieht den Methodenaufruf mit hinein, wie erwartet:

0025 T7 = FETCH_DIM_R T6 CV4($j) ; op1(packed array) op2(int) val(float)
0026 INIT_METHOD_CALL 2 CV0($that) string("element")
     >init SebastianBergmann\Raytracer\Matrix::element
0029 T8 = DO_FCALL
     >enter SebastianBergmann\Raytracer\Matrix::element
0002  T2 = FETCH_OBJ_R THIS string("elements") ; val(array)
0003  T3 = FETCH_DIM_R T2 CV0($i) ; op1(packed array) op2(int) val(array)
0004  T2 = FETCH_DIM_R T3 CV1($j) ; op1(packed array) op2(int) val(float)
0006  RETURN T2 ; op1(float)
     <back SebastianBergmann\Raytracer\Matrix::multiply
0030 T6 = MUL T7 T8 ; op1(float) op2(float)

Eine Methode auf einem anderen Objekt derselben Klasse, aufgelöst und flach in den Trace gelegt. Das ist die Sorte Optimierung, die ein Interpreter grundsätzlich nicht anbieten kann.

Der Preis dafür steht in der Ausgangsliste. Dieser eine Trace hat 29 Ausgänge. Jeder Array-Zugriff kann auf einen anderen Array-Typ treffen und jeder Property-Zugriff auf eine andere Klasse; dazu kann jede Rechnung überlaufen. Zwei Ausgänge stehen beispielhaft für den Rest:

exit_13: 0026/0088/8/POLY(rax, rdi) ... X6:array X7:float
exit_25: 0035/0145/9 ... CV4($j):float(9.22337e+18) ...

Der erste ist ein Guard auf den Methodenaufruf: Der JIT hat sich gemerkt, welche Methode $that->element aufgelöst hat, und prüft bei jedem Durchlauf, ob es immer noch dieselbe ist. Der zweite ist der Ausgang für den Fall, dass $j++ den Integer-Bereich verlässt und zu einem Float wird.

Aus vier Ausgängen sind 29 geworden, aus 78 Bytes Maschinencode 1.457. Derselbe Mechanismus, dasselbe Vorgehen, aber an objektorientiertem PHP hängt an fast jedem Schritt eine Prüfung. Es lohnt sich trotzdem: Diese Multiplikation läuft mit dem Tracing-JIT gut zweieinhalbmal so schnell wie ohne.

Der Trace der Matrixmultiplikation mit seinen 29 Ausgängen
Derselbe Mechanismus an einer Zeile aus einem echten Projekt. Welche Instruktion wie viele Ausgänge trägt, ist hier schematisch; aus der Debug-Ausgabe stammen die Gesamtzahl und die beiden benannten Ausgänge.

Was das in einer Testsuite bringt

Die Testsuite dieses Raytracers hat 177 Tests und läuft ohne JIT gut neun Sekunden, das meiste davon in Fließkommaarithmetik. Wenn ein JIT irgendwo etwas bringt, dann hier.

Gemessen habe ich die Gesamtlaufzeit des Prozesses, also einschließlich Start, Bootstrap und Kompilierung. Das ist die Zahl, die du beim Warten siehst. opcache.jit_buffer_size stand dabei auf 64M, dem Wert aus der Ausgabe weiter oben.

Lauf ohne JIT tracing function
nur Bootstrap, kein Test 0,34 s 0,44 s 3,57 s
ein Durchlauf 9,16 s 6,48 s 9,69 s
--repeat 3 27,08 s 22,15 s 21,75 s
--repeat 10 88,25 s 56,78 s 66,68 s

Die Zeile, die mich überrascht hat, ist die erste. Ein Lauf, der wegen eines nicht passenden Filters keinen einzigen Test ausführt, dauert mit dem Function-JIT dreieinhalb Sekunden. Das ist reine Kompilierzeit für Code, der danach nie ausgeführt wird.

In der zweiten Zeile kommt das wieder heraus. Ein einzelner Testlauf dauert mit dem Function-JIT 9,69 s gegen 9,16 s ganz ohne JIT, also 5,8 % länger. Das ist wenig, und ich habe fünfzehn Läufe je Konfiguration gebraucht, um sicher zu sein, dass es kein Rauschen ist.

--repeat lässt PHPUnit jeden Test mehrfach im selben Prozess ausführen, und das trennt die Fixkosten sauber vom Aufwand pro Wiederholung. Eine Gerade durch die drei Messpunkte ergibt das:

Modus Fixkosten pro Wiederholung Faktor
ohne JIT 0,34 s 8,77 s 1,00
tracing 0,44 s 5,42 s 1,62
function 3,57 s 6,35 s 1,38
Laufzeit der Testsuite über der Zahl der Wiederholungen
Die Steigung ist der Aufwand pro Wiederholung; die Fixkosten stehen in der ersten Tabellenzeile.

Der Function-JIT holt seine dreieinhalb Sekunden erst ab der zweiten Wiederholung wieder herein. Der Tracing-JIT hat den kleineren Achsenabschnitt und die kleinere Steigung; er ist ab dem ersten Durchlauf vorn und bleibt es. Sein Code ist auch dauerhaft besser, weil er auf beobachteten statt auf bewiesenen Typen spezialisieren kann.

Der Unterschied lässt sich auch in Bytes ausdrücken. Nach einem vollen Testlauf hat der Tracing-JIT rund 0,86 MB Maschinencode erzeugt, der Function-JIT rund 22,7 MB. Das sind sechsundzwanzigmal so viele Bytes für ein schlechteres Ergebnis.

Was der Tracing-JIT in dieser Testsuite überhaupt kompiliert, zeigt ein eigener Lauf mit eingeschalteter Debug-Ausgabe. Er beginnt 1.184 Aufzeichnungen und bringt 908 davon bis zum Maschinencode. Die vier Anlässe, aus denen eine Aufzeichnung beginnt, tragen dazu sehr unterschiedlich bei:

Anlass begonnen kompiliert
return, wo ein Aufruf zurückgekehrt ist 438 356
enter, beim Betreten einer Funktion 312 298
loop, an einem Schleifenkopf 233 64
side trace, an einem Ausgang eines anderen Trace 201 190

Schleifen sind hier nicht nur die Minderheit, sie sind auch der einzige Anlass, aus dem eine Aufzeichnung überwiegend scheitert: Von 233 begonnenen Schleifen-Traces werden 64 fertig, bei den anderen drei sind es über 80 %. Was der JIT in einer Testsuite kompiliert, beginnt also meistens dort, wo gerade ein Aufruf zurückgekehrt ist oder eine Funktion betreten wird. Das ist der Unterschied zwischen Anwendungscode und einem Benchmark, in dem eine Zahl millionenfach hochgezählt wird, und es ist ein Grund, warum Benchmarkzahlen zum JIT so weit auseinandergehen.

Wann sich das lohnt

Wenn die Zeit in PHP-Code verbraucht wird, der rechnet, bringt der JIT einen Faktor. Wenn die Zeit in der Datenbank, im Dateisystem oder im Netzwerk liegt, bringt er nichts, und daran ändert keine Einstellung etwas. Bevor die Frage nach dem JIT-Modus überhaupt sinnvoll ist, muss also klar sein, wo die Zeit hingeht.

Steht das fest, ist tracing für alles, was ich gemessen habe, die richtige Wahl. Es hat kaum Fixkosten und erzeugt auch den besseren Code. Der Function-JIT kompiliert alles im Voraus und ist damit auf der Kommandozeile strukturell im Nachteil, weil seine Fixkosten bei jedem Aufruf neu anfallen.

Nur habe ich alles auf der Kommandozeile gemessen, und das ist genau der Fall, der dem Function-JIT nicht liegt. Unter FPM fallen seine Fixkosten einmal pro Worker an und verteilen sich danach auf jede Anfrage, die dieser Worker noch bedient. Ein Worker, der zehntausend Anfragen durch dieselben Klassen schickt, ist der Fall, auf den der Function-JIT gebaut ist. Gemessen habe ich das nicht, und ich würde die Empfehlung oben deshalb nicht auf FPM übertragen: Dort läuft der Code lange genug, dass sich die Rechnung umdrehen kann.

Und wenn du Zahlen zwischen zwei PHP-Installationen vergleichst, gehört opcache.jit in den Vergleich, so wie in den beiden Artikeln davor schon ZEND_VM_KIND und opcache.optimization_level hineingehörten. Der Unterschied zwischen den beiden Modi ist in meinen Messungen größer als der zwischen zwei PHP-Versionen.

Was ich mitgenommen habe

Am meisten hängen geblieben ist mir der Guard-Zähler. Vier Ausgänge bei einer Integer-Schleife, 29 bei einer Zeile aus einem echten Projekt. Diese beiden Zahlen sagen mehr darüber, was ein JIT für welchen Code tun kann, als jede Prozentangabe aus einem Benchmark.

Den JIT einzuschalten ist eine Wette darauf, dass der Code lange genug läuft, um die Kompilierung wieder hereinzuholen. Bei einem langlaufenden Prozess geht sie meistens auf. Bei einem kurzen Kommandozeilenaufruf gewinnt oft genug der Interpreter, an dem zwanzig Jahre optimiert wurde. Dass eine Testsuite zu den Fällen gehört, in denen der Function-JIT diese Wette verliert, hätte ich vorher nicht erwartet.