VM bytecode
Il percorso di esecuzione predefinito è un tree-walker: visita ogni nodo AST e restituisce un valore. Per le funzioni più usate, chiamate milioni di volte, il collo di bottiglia è il sovraccarico del dispatch per nodo. Phasis aggiunge un compilatore speculativo da bytecode a closure PHP per ridurre questo sovraccarico.
Quando entra in funzione la VM
Ogni JsFunction ha un contatore delle chiamate frequenti. Quando supera una soglia (predefinita: 100), il motore tenta di compilare il corpo della funzione in una closure PHP tramite src/Bytecode/JsToPhp.php.
La compilazione riesce quando il corpo della funzione corrisponde a un insieme noto di strutture:
- Assegnazioni a variabili locali con tipi prevedibili (numerico, oggetto, funzione, array, stringa).
- Operazioni aritmetiche sui numeri.
- Letture di membri su variabili locali di tipo oggetto E su
this(this.v,obj.x). - Chiamate a metodi su variabili locali di tipo oggetto (
obj.method(args)), con il ricevitore passato come$thisValue. - Chiamate al costruttore
new Ctor(args)— promuove il tipo della variabile locale LHS aobjecte protegge con Bailout un valore restituito non oggetto. - Cicli
for/whilecon condizioni semplici. returncon un'espressione calcolabile.if/elsecon condizioni tipizzate.
La compilazione non riesce (e la funzione resta nel tree-walker) quando il corpo contiene:
- Istruzioni
with. try / catch / finally(gestito dal tree-walker per garantire la corretta semantica di completamento).- Chiamate
eval(). - Modifiche dirette a
arguments. - Qualsiasi costrutto che il compilatore non sappia ancora trasformare.
Quando la compilazione riesce, il motore sostituisce il percorso di chiamata della funzione con la closure compilata. Le chiamate successive eseguono codice nativo PHP con il sovraccarico di una sola chiamata di funzione, invece che con un sovraccarico per ogni nodo AST.
Fallback
Il percorso veloce compilato si basa su alcune ipotesi (i numeri restano numeri, le proprietà mantengono la stessa struttura). Se un'ipotesi viene violata a runtime, la closure genera un Bailout e l'esecuzione riparte nel tree-walker per quella chiamata.
È qui che la correttezza rispetto alla specifica conta di più. Il tree-walker deve poter rieseguire da zero la chiamata per cui è scattato il fallback, senza rilevare effetti collaterali del percorso veloce non riuscito. Phasis gestisce questo caso in due modi:
- Prefissi puri — il compilatore cerca gli effetti collaterali prima di qualsiasi speculazione. Se la funzione esegue I/O (console.log, funzioni host) prima del punto di speculazione, il percorso veloce non lo salta: lo conferma e prosegue.
- Rifiuto in fase di compilazione — per le strutture che richiederebbero di annullare effetti collaterali osservabili (un
let r = fn()non numerico quandofn()modifica lo stato), il compilatore rifiuta la compilazione. La funzione resta nel tree-walker.
Il caso che ha motivato questa soluzione: un'assegnazione non numerica esegue fn() nel percorso veloce, genera Bailout e poi viene rieseguita nel tree-walker, eseguendo fn() due volte con due serie di effetti collaterali. Il rifiuto in fase di compilazione impedisce del tutto a fn() di essere eseguito nel percorso veloce, così il tree-walker osserva l'unica chiamata.
Dispatch della VM (il secondo livello)
Alcune funzioni vengono compilate in closure PHP; altre passano a un interprete bytecode ancora più semplice in src/Bytecode/VM.php. Il bytecode è un array lineare di opcode (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) eseguiti da un dispatch switch.
La VM è destinata alle funzioni che non vale la pena compilare in una closure PHP, ma che sono comunque abbastanza usate da trarre vantaggio dall'evitare l'attraversamento dell'AST. Le chiamate ai metodi sugli oggetti integrati (Array.prototype.map, String.prototype.split, …) passano per percorsi veloci nella VM che aggirano il trampoline generico callFunction.
Stack di chiamate personalizzato
Op::CALL, Op::CALL_METHOD e Op::NEW_CALL usano un percorso di chiamata inline che mantiene l'intero stack delle chiamate JS all'interno di VM::execute, senza un nuovo frame PHP per ogni chiamata JS. All'ingresso, lo stato del chiamante (pc, flusso di codice, stack degli operandi, variabili locali, env, this, flag strict-mode) viene inserito in un pool savedFrames per VM di istanze SavedFrame, e il ciclo di dispatch riassegna le variabili locali al chiamato. Op::RET estrae il frame salvato e ripristina lo stato. Le eccezioni risalgono i frame salvati prima di tornare al tree-walker.
L'idoneità viene memorizzata in cache su JsFunction: vmInlineCallCache per le chiamate normali (corpo compilato in BC, non associato a bind(), non nativo), vmInlineConstructCache per le espressioni new (inoltre isConstructable, non derivato, senza inizializzatori di campi di istanza o metodi privati). Tutto il resto continua a passare per il trampoline canonico callFunction / vmNewExpression.
Il vantaggio architetturale secondario: il limite dello stack PHP non limita più la profondità della ricorsione JS. Ora l'unico limite è CallStack::maxDepth, impostato su 4 096: ben al di sopra di ogni caso legittimo test262 (la maggior parte richiede meno di 2 k frame) e abbastanza rigoroso da far fallire rapidamente i test di ricorsione infinita patologica entro il limite di timeout del runner. Il percorso delle closure transpiled (JsToPhp) ha una protezione separata di 8 frame 192 per il fallback non inline.
Generatori con snapshot del frame
I corpi dei generatori semplici — senza yield*, senza yield all’interno di try/catch/finally, senza await — vengono compilati in bytecode. Il compilatore emette un nuovo opcode Op::YIELD nel punto in cui il corpo si sospende; Op::YIELD acquisisce lo stato di dispatch (cf, pc+1, stack, sp, locals, env, thisValue, strict) in un oggetto heap GeneratorSnapshot e restituisce da VM::execute un valore sentinella YieldResult. Il driver JsGenerator memorizza lo snapshot e lo passa a VM::execute al successivo next() / throw(), sostituendo il valore di ripresa in cima allo stack degli operandi.
Questo sostituisce il percorso PHP-Fiber per il sottoinsieme supportato. Il driver basato su Fiber (executeGeneratorBody) è ancora disponibile per le strutture rifiutate dal compilatore: delega yield*, generatori che eseguono yield all’interno di un blocco try, generatori asincroni, funzioni asincrone con await. JsGenerator è in modalità doppia: ogni next() / throw() / return() verifica se l’istanza dispone di uno snapshot o di un Fiber e instrada l’esecuzione di conseguenza.
Nessuna opzione regolabile tramite benchmark: l’idoneità viene decisa in fase di compilazione e memorizzata in cache su JsFunction. Nel percorso snapshot, il vantaggio è un cambio di contesto PHP in meno per ogni yield; il percorso Fiber resta invariato per tutto il resto.
Cache inline
Op::LOAD_MEMBER mantiene una cache inline per PC, indicizzata per identità del prototipo e usata per il modello metodo-sul-prototipo sull’istanza (c.sum(...), arr.push(...), ogni dispatch di metodo di classe). La condizione di cache hit è che il ricevitore non abbia uno slot proprio per il nome, che il riferimento al prototipo memorizzato in cache sia identico a getPrototype() del ricevitore e che PropertyMap::$version del prototipo corrisponda ancora alla versione acquisita.
L’invalidazione della cache si basa su PropertyMap::$version. Le modifiche strutturali (impostazione / eliminazione) lo incrementano sempre. Le sovrascritture dei valori degli slot dati lo incrementano solo quando PropertyMap::$isUsedAsProto è true: un flag di write barrier che viene attivato la prima volta che l’oggetto viene rilevato come [[Prototype]] di un altro oggetto. Gli oggetti istanza che non fungono mai da prototipi non sostengono alcun costo per l’infrastruttura IC a ogni obj.x = v.
Gli accessor getter non vengono mai memorizzati in cache (devono essere eseguiti di nuovo a ogni lettura).
PropertyMap mantiene un secondo contatore, $mutationVersion, che viene incrementato sempre a ogni scrittura (senza write barrier). La cache JSON.stringify memorizza questo contatore per ogni oggetto/array annidato visitato, così una modifica limitata all’istanza come o.a = 99 invalida correttamente la stringa memorizzata in cache, anche quando $version dell’IC avrebbe evitato l’incremento.
Cache JSON.stringify
JsObject mantiene $jsonCacheString + $jsonCacheVersions (un elenco di coppie [obj, mutationVersion] per ogni oggetto annidato visitato durante la serializzazione triviale in stringa). Alla successiva chiamata JSON.stringify(o) senza replacer né spazio, il percorso rapido triviale verifica che nessuna delle versioni memorizzate sia cambiata e restituisce la stringa in cache senza ripetere la visita né la codifica. I pattern frequenti di deep clone come JSON.parse(JSON.stringify(o)) ottengono un’accelerazione di circa 2× nella fase di stringify.
In caso di mancata corrispondenza (una versione memorizzata è diversa), la cache viene eliminata senza avvisi e ripopolata dalla visita successiva. Gli oggetti con toJSON, getter, accessor, simboli o slot [[PrimitiveValue]] ricorrono al serializzatore previsto dalle specifiche e non popolano mai la cache: il percorso in cache vede quindi solo dati con struttura uniforme.
Percorsi rapidi integrati
I maggiori miglioramenti delle prestazioni sono arrivati insegnando alla VM a gestire specifiche funzioni integrate:
Atomics.load/store/compareExchangecon pianificazione cooperativaloadSpinHook/storeNotifyHook— recuperati 4 test di spin loop di Atomics.Date.prototype.getTimezoneOffsetcon una cache DST basata su hashmap diretta: velocità superiore del 45 % nei test di stress DST di SpiderMonkey.String.fromCharCodecon un percorso rapido ASCII inline: risparmia circa 1 M dispatch per scansione tipica.decodeURI/decodeURIComponentcon un percorso ultra rapido%XX%XX%XX%XXa 12 caratteri che usa la decodifica massivahex2bin.Object.keys/Object.values/Object.entriescon accesso diretto alla mappa delle proprietà.Array.prototype.map/filter/forEach/reducecon iterazione nativa sull’array sottostante PHP.
Ogni percorso rapido è subordinato a un «controllo della forma»: se il ricevitore non corrisponde alla forma prevista (e.g, ad esempio una vista TypedArray non basata su un PHP string semplice), la VM ricorre al percorso lento conforme alle specifiche.
Cosa non fa
La VM bytecode non è un JIT nel senso di V8. Non genera codice macchina nativo per ogni funzione e non fa speculazioni sui tipi osservati oltre al controllo monomorfico dell’identità del prototipo usato dall’IC. L’inlining ai confini delle funzioni è approssimato dallo stack di chiamate personalizzato (senza un frame PHP aggiuntivo per chiamata JS), anziché compilando il corpo della funzione chiamata nel bytecode del chiamante.
Il transpiler JsToPhp è la cosa più simile a un JIT nell’engine: genera codice sorgente PHP per i corpi delle funzioni più usate, che il JIT con tracing di 8.5 di PHP compila poi in codice nativo. JsToPhp passa per eval(), quindi paga un costo di compilazione una tantum e il JIT con tracing deve individuare la funzione come se fosse codice utente. Evitare il passaggio eval() emettendo direttamente gli opcode opcache ridurrebbe ulteriormente il divario: vedi Roadmap dei benchmark.
Benchmark
bench/microbench.js esegue una dozzina abbondante di piccoli benchmark (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). Tempo mediano trascorso:
php bench/run.php
I numeri attuali vengono salvati in BENCH.md dopo ogni esecuzione del workflow bench. I workflow della matrice bench e test262 vengono avviati entrambi manualmente.