Ivan Centamori

EN IT


Il JIT di PHP 8

PHP non ha mai eseguito il tuo codice. Ha sempre eseguito una sua traduzione.

Quando scrivi $a + $b, la CPU non vede mai una somma. Vede un lungo ciclo che, ad ogni giro, legge un'istruzione intermedia, cerca chi sa gestirla, la esegue su strutture dati avvolte in metadati, e ricomincia. Questo strato di mediazione è ciò che rende PHP comodo da scrivere e lento da eseguire. È la tassa dell'interprete.

Il JIT (Just-In-Time compiler), introdotto in PHP 8.0 nel novembre 2020, è il primo meccanismo del linguaggio capace di rimuovere quella tassa: traduce il bytecode di PHP direttamente in codice macchina nativo, mentre il programma è in esecuzione. La CPU smette di interpretare e inizia a eseguire.

In questo articolo vediamo cosa succede realmente sotto il cofano — dalla pipeline di compilazione al loop della Zend VM, dalle due strategie di JIT alla configurazione CRTO — con un benchmark eseguito e verificato, non immaginato.


Il viaggio di uno script PHP

Prima di capire cosa il JIT accelera, bisogna capire cosa rallenta. Uno script PHP attraversa quattro fasi prima di produrre un risultato.

Fase Input Output Componente
Lexing Codice sorgente Token Lexer
Parsing Token AST Parser
Compilazione AST Opcode Compiler
Esecuzione Opcode Risultato Zend VM

Le prime tre fasi trasformano il testo che scrivi in opcode — istruzioni di basso livello del Zend Engine, l'equivalente PHP del bytecode. Una riga come $c = $a + $b non è una singola operazione: viene compilata in qualcosa come ADD $a, $b -> ~tmp seguita da ASSIGN $c, ~tmp. Ogni opcode ha un suo handler, una funzione C che sa eseguirlo.

L'ultima fase è quella che conta. La Zend Virtual Machine è, nella sua essenza, un loop che fa una cosa sola, milioni di volte:

while (opcode = next_opcode()) {
    handler = handlers[opcode->type];
    handler(opcode);
}

Questo è il cuore dell'interprete. Ad ogni iterazione la VM deve decidere quale handler chiamare in base al tipo di opcode — un dispatch che, ripetuto miliardi di volte, diventa un costo misurabile.

Perché è lento

Il problema non è solo il dispatch. È cosa fanno gli handler.

PHP ha tipi dinamici. La stessa variabile può contenere un intero, poi una stringa, poi un array. Per gestire questa flessibilità, ogni valore è incapsulato in una struttura chiamata zval, che contiene il valore, il suo tipo, e un contatore di riferimenti.

Quando l'handler di ADD esegue $a + $b, non somma due numeri. Deve: leggere lo zval di $a, leggere lo zval di $b, controllare i loro tipi a runtime, decidere se è una somma tra interi, tra float, una concatenazione mascherata, o un overload su un oggetto, eseguire l'operazione giusta, e infine costruire un nuovo zval per il risultato.

Tutta questa macchina si attiva anche quando $a e $b sono sempre due interi, e lo sono per ogni iterazione di un ciclo che gira un milione di volte.

L'interprete non lo sa. Non può saperlo: il suo lavoro è essere pronto a tutto. E pagare per essere pronto a tutto, ad ogni singolo opcode, è esattamente ciò che il JIT elimina.


OPcache, il prerequisito

Il JIT non è un'entità separata. Vive dentro OPcache, l'estensione che PHP usa da anni per evitare di ricompilare lo stesso script ad ogni richiesta.

Senza OPcache, ogni richiesta HTTP rifà tutto da capo: lexing, parsing, compilazione. Per uno script che non cambia, è uno spreco totale. OPcache risolve questo problema memorizzando gli opcode compilati in memoria condivisa. Dalla seconda richiesta in poi, PHP salta direttamente all'esecuzione.

Senza OPcache:  sorgente -> token -> AST -> opcode -> ESECUZIONE   (ogni richiesta)
Con OPcache:    [opcode in cache]            -> opcode -> ESECUZIONE   (dalla 2a richiesta)
Con JIT:        [opcode in cache]   -> [codice nativo in cache] -> CPU

OPcache elimina la ricompilazione. Ma l'esecuzione resta interpretata: gli opcode passano comunque attraverso il loop della Zend VM. Il JIT aggiunge un livello in più alla cache — non più solo opcode condivisi, ma codice nativo condiviso. Per questo si abilita tramite OPcache, e per questo non funziona se OPcache è spento.


Cosa fa davvero il JIT

Il JIT prende gli opcode e, invece di darli in pasto al loop della VM, li traduce in istruzioni macchina che la CPU esegue direttamente. Spariscono due cose: il dispatch (non c'è più nessun loop che decide quale handler chiamare) e, quando possibile, la macchina degli zval.

Il secondo punto è dove avviene la magia. Si chiama type inference — inferenza dei tipi.

Se il compilatore JIT riesce a dimostrare che, in un certo punto del codice, una variabile è sempre un float, allora non ha bisogno di emettere il codice generico che controlla il tipo, gestisce gli zval e contempla ogni caso possibile. Può emettere una singola istruzione macchina — un addsd su un registro SSE per una somma tra float — e nient'altro.

È la differenza tra:

fetch zval $a; check type; fetch zval $b; check type;
  branch su int/float/string/object; add; alloc zval risultato; write

e:

addsd xmm0, xmm1

Un'operazione contro una dozzina. Moltiplicata per ogni iterazione di un ciclo numerico stretto, è da qui che arriva il guadagno.


Function JIT contro Tracing JIT

PHP non ha un solo JIT, ma due strategie per decidere cosa e quando compilare. La scelta cambia radicalmente il comportamento.

Il Function JIT compila intere funzioni in codice nativo. Quando una funzione entra in scena, viene tradotta per intero, dall'inizio alla fine, indipendentemente da quali rami verranno effettivamente percorsi. È semplice e prevedibile, ma ha due difetti: compila anche il codice freddo, quello che gira raramente, sprecando tempo e memoria; e ottimizza ogni funzione in isolamento, con una conoscenza limitata dei tipi che vi transitano davvero.

Il Tracing JIT lavora come fanno i runtime moderni — LuaJIT, V8, PyPy. Prima lascia girare il codice nell'interprete e lo profila, contando quante volte ogni porzione viene eseguita. Quando un ciclo o un percorso diventa caldo, registra una traccia: la sequenza lineare di opcode realmente eseguiti, seguendo anche le chiamate a funzione e attraversando i loro confini. Osserva i tipi reali che fluiscono in quel percorso, e compila quella traccia in codice nativo iper-specializzato.

Per restare corretto, il Tracing JIT inserisce delle guardie: controlli che verificano che le assunzioni fatte (questo è un intero, quel ramo viene preso) siano ancora vere. Se una guardia fallisce — i tipi cambiano, il percorso devia — il codice esce dalla traccia e torna all'interprete. Compilando solo il codice caldo e specializzandolo sui tipi osservati, il Tracing JIT è quasi sempre più veloce e usa molta meno memoria.

È la modalità raccomandata, ed è il default.


La configurazione CRTO

Il JIT si controlla con la direttiva opcache.jit, che accetta un valore di quattro cifre nel formato CRTO. Ogni cifra governa un aspetto diverso del compilatore.

Posizione Significato Valori
C Ottimizzazioni CPU-specifiche 0 nessuna · 1 usa AVX se disponibile
R Allocazione dei registri 0 nessuna · 1 locale al blocco · 2 globale
T Trigger (quando compilare) 0 tutto al caricamento · 1 alla prima esecuzione · 2 profila la prima richiesta · 3 profila al volo · 4 (inutilizzato) · 5 tracing
O Livello di ottimizzazione 0 nessun JIT · 1 minimo · 2 inlining di handler selezionati · 3 type inference sulla singola funzione · 4 + call tree · 5 + ottimizzazione dei cicli interni

Due combinazioni hanno un alias, e sono le uniche che userai nella pratica:

C'è un dettaglio non negoziabile: il JIT non si attiva finché non gli riservi memoria. La direttiva opcache.jit_buffer_size deve essere maggiore di zero — è di fatto l'interruttore generale. Una configurazione tipica in php.ini:

opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=128M
opcache.jit=tracing

Senza jit_buffer_size, qualunque valore di opcache.jit viene ignorato e il JIT resta spento.


Il benchmark

La teoria dice che il JIT trasforma il codice CPU-bound e lascia indifferente il resto. Verifichiamolo su una macchina reale: un Intel Xeon a 2.80GHz, PHP 8.3.6 con Zend OPcache.

Il caso ideale per il JIT è il calcolo numerico puro. Il set di Mandelbrot è perfetto: un doppio ciclo che, per ogni pixel, itera un'operazione su float finché un valore non diverge. Nessun array, nessuna stringa, nessun I/O — solo aritmetica in virgola mobile dentro un ciclo stretto.

<?php

function mandelbrot(int $width, int $height, int $maxIter): int {
    $sum = 0;
    for ($py = 0; $py < $height; $py++) {
        $y0 = ($py / $height) * 2.0 - 1.0;
        for ($px = 0; $px < $width; $px++) {
            $x0 = ($px / $width) * 3.0 - 2.0;
            $x = 0.0;
            $y = 0.0;
            $iter = 0;
            while ($x * $x + $y * $y <= 4.0 && $iter < $maxIter) {
                $xtemp = $x * $x - $y * $y + $x0;
                $y = 2.0 * $x * $y + $y0;
                $x = $xtemp;
                $iter++;
            }
            $sum += $iter;
        }
    }
    return $sum;
}

$start = hrtime(true);
$result = mandelbrot(1000, 1000, 1000);
$end = hrtime(true);

printf("checksum: %d\n", $result);
printf("time: %.1f ms\n", ($end - $start) / 1e6);

Eseguito senza JIT, poi con Tracing JIT e con Function JIT, sullo stesso script:

=== NO JIT ===
checksum: 257988727
time: 8677.5 ms

=== JIT tracing (1254) ===
checksum: 257988727
time: 1154.0 ms

=== JIT function (1205) ===
checksum: 257988727
time: 1187.1 ms

Il checksum identico in tutti i casi conferma che il risultato è lo stesso: il JIT non cambia cosa fa il codice, solo quanto velocemente lo fa. E lo fa circa 7,5 volte più velocemente. Otto secondi e mezzo diventano poco più di uno. Questo è il loop numerico stretto che si riduce, iterazione dopo iterazione, a istruzioni SSE dirette invece che a handler della VM su zval.

Il controesempio

Ora il caso opposto. Un carico tipico di un'applicazione web reale non è aritmetica float: è popolare array, leggere chiavi stringa, navigare hash table.

<?php

function arraywork(int $n): int {
    $arr = [];
    for ($i = 0; $i < $n; $i++) {
        $arr["key_$i"] = $i * 2;
    }
    $sum = 0;
    foreach ($arr as $k => $v) {
        if (isset($arr[$k])) { $sum += $v; }
    }
    return $sum;
}

$r = 0;
for ($j = 0; $j < 200; $j++) { $r = arraywork(50000); }
=== Array-heavy (hash table, chiavi stringa) ===
[no jit]    time: 895.9 ms
[tracing]   time: 882.2 ms

Un guadagno dell'1,5%. Rumore statistico.

La ragione è la stessa che abbiamo visto nella Zend VM: accedere a $arr["key_$i"] non è un'istruzione macchina, è una ricerca in una hash table — calcolo dell'hash, individuazione del bucket, gestione dello zval. Quel costo vive nella struttura dati, non nel dispatch dell'interprete. Il JIT può eliminare la tassa dell'interprete, ma non può riscrivere la natura di una hash table dinamica.

Ecco perché applicazioni come WordPress, fatte per lo più di accessi ad array, manipolazione di stringhe e I/O verso database, vedono dal JIT miglioramenti nell'ordine del 5%, mentre un calcolo numerico vola. Il JIT non rende PHP veloce ovunque. Rende veloce il codice che era lento a causa dell'interprete, non quello che è lento per altri motivi.


Verificare che il JIT sia attivo

Abilitare il JIT e usarlo davvero sono due cose diverse. opcache_get_status() espone lo stato reale del compilatore:

<?php

$jit = opcache_get_status(false)['jit'];

printf("on: %s\n", $jit['on'] ? 'true' : 'false');
printf("opt_level: %d\n", $jit['opt_level']);
printf("used: %d bytes\n", $jit['buffer_size'] - $jit['buffer_free']);
on: true
opt_level: 4
used: 2448 bytes

Il campo on a true conferma che il JIT è operativo. Il fatto che used sia maggiore di zero — qui 2448 byte — dimostra che ha effettivamente compilato qualcosa: se il buffer resta intatto, il JIT è acceso ma non sta lavorando, segno che il tuo codice non offre nulla di abbastanza caldo da compilare.


Da DynASM al framework IR

Il modo in cui il JIT genera codice nativo è cambiato in profondità tra le versioni di PHP 8, ed è una storia che vale la pena conoscere.

PHP 8.0 e i suoi successori fino alla 8.3 — quello del benchmark qui sopra — generano codice macchina con DynASM, lo stesso assemblatore dinamico nato per il progetto LuaJIT. Con DynASM, l'assembly va scritto a mano, in file template separati per ogni architettura: zend_jit_x86.dasc per Intel/AMD, e dalla 8.1 (2021) zend_jit_arm64.dasc per AArch64 — il backend che fa funzionare il JIT su Apple Silicon. È efficace, ma fragile: ogni architettura è codice a parte da mantenere, e lo spazio per ottimizzazioni sofisticate è limitato.

PHP 8.4, nel novembre 2024, ha sostituito tutto questo con un nuovo JIT basato sul framework IR di Dmitry Stogov. L'idea è introdurre una rappresentazione intermedia — assente fino ad allora — tra gli opcode e il codice nativo. Questa IR è ispirata al modello Sea-of-Nodes, lo stesso usato dal compilatore server di Java HotSpot, da TurboFan di V8 e da Graal: dipendenze sui dati e sul controllo unificate in un unico grafo, in una forma vicina all'SSA (Static Single Assignment), dove ogni valore è assegnato una sola volta.

Il vantaggio è duplice. Da un lato la manutenibilità: invece di scrivere assembly a mano per ogni CPU, gli sviluppatori costruiscono il grafo IR una volta sola, e il framework si occupa di ottimizzazione, allocazione dei registri e generazione del codice per ogni architettura. Dall'altro, una IR vera apre la porta a ottimizzazioni che con DynASM erano impraticabili. Stogov stima un ulteriore 5-10% di prestazioni e un'occupazione di memoria minore per il codice compilato.

Il punto importante per chi scrive codice: dall'esterno non cambia nulla. La configurazione CRTO, gli alias tracing e function, il comportamento — tutto resta identico. È una sostituzione interna del motore, invisibile al di sopra della linea della direttiva opcache.jit.


Conclusione

Il JIT non trasforma PHP in C, e non dovrebbe. Non rende veloce tutto, e prometterlo sarebbe disonesto: l'abbiamo misurato, su una hash table il guadagno è rumore.

Quello che fa è più chirurgico. Per anni, ogni operazione in PHP ha pagato lo stesso pedaggio — il loop della VM, il dispatch, la macchina degli zval — indipendentemente dal fatto che servisse o no. Per il codice che aspetta un database, quel pedaggio è invisibile, perso nel rumore dell'I/O. Ma per un ciclo numerico che gira un milione di volte, è la voce di spesa dominante. Il JIT individua esattamente quel codice, dimostra cosa c'è di superfluo, e lo rimuove.

La distanza tra PHP e i linguaggi compilati non è mai stata, in fondo, una distanza di sintassi. Era lo strato di interprete che stava in mezzo. Sul codice che lo merita, il JIT lo toglie di mezzo — e i sette secondi che spariscono dal benchmark sono la misura precisa di quanto pesava.