VM de bytecode
Calea implicită de execuție este un parcurgător arborescent — vizitează fiecare nod AST și returnează o valoare. Pentru funcțiile intens apelate, de milioane de ori, costul dispatch-ului per nod devine un blocaj. Phasis adaugă deasupra un compilator speculativ de bytecode în closure-uri PHP, pentru a reduce acest cost.
Când intră în funcțiune VM-ul
Fiecare JsFunction are un contor de apeluri frecvente. Când depășește un prag (implicit 100), motorul încearcă să compileze corpul funcției într-un closure PHP prin src/Bytecode/JsToPhp.php.
Compilarea reușește când corpul funcției se încadrează într-un set cunoscut de forme:
- Atribuiri de variabile locale cu tipuri previzibile (numeric, obiect, funcție, array, șir de caractere).
- Operații aritmetice asupra numerelor.
- Citiri de membri din localuri de tip obiect ȘI din
this(this.v,obj.x). - Apeluri de metode asupra localurilor de tip obiect (
obj.method(args)), cu receptorul transmis mai departe ca$thisValue. - Apeluri de constructor
new Ctor(args)— promovează localul LHS la tipulobjectși verifică prin Bailout că rezultatul nu este un obiect. - Bucle
for/whilecu forme simple de condiție. returncu o expresie calculabilă.if/elsecu condiții tipizate.
Compilarea eșuează (iar funcția rămâne pe parcurgătorul arborescent) când corpul conține:
- Instrucțiuni
with. try / catch / finally(gestionat de parcurgătorul arborescent pentru semantica adecvată de finalizare).- Apeluri
eval(). - Modificare directă a
arguments. - Orice nu știe încă compilatorul să transforme în cod de nivel inferior.
Când compilarea reușește, motorul schimbă calea de apel a funcției cu closure-ul compilat. Apelurile ulterioare execută cod nativ PHP cu un singur cost de apel de funcție, nu unul pentru fiecare nod AST.
Abandonări
Calea rapidă compilată se bazează pe presupuneri (numerele rămân numere, proprietățile rămân pe aceeași formă). Când o presupunere este încălcată în timpul execuției, closure-ul aruncă un Bailout, iar execuția repornește în parcurgătorul arborescent pentru acel apel.
Aici contează cel mai mult corectitudinea față de specificație. Parcurgătorul arborescent trebuie să poată reexecuta de la zero apelul abandonat, fără să observe efecte secundare de pe calea rapidă eșuată. Phasis tratează situația în două moduri:
- Prefixe pure — compilatorul caută efecte secundare înainte de orice speculație. Dacă funcția efectuează I/O (console.log, funcții gazdă) înainte de punctul de speculație, calea rapidă nu îl elimină; îl finalizează și continuă.
- Refuz la compilare — pentru forme care ar necesita anularea unor efecte secundare observabile (un
let r = fn()non-numeric undefn()modifică starea), compilatorul refuză complet compilarea. Funcția rămâne pe parcurgătorul arborescent.
Cazul care a motivat această abordare: o atribuire non-numerică execută fn() pe calea rapidă, aruncă Bailout, apoi este reexecutată pe parcurgătorul arborescent — rulând fn() de două ori, cu două seturi de efecte secundare. Refuzul la compilare împiedică fn() să ruleze deloc pe calea rapidă, astfel încât parcurgătorul arborescent observă singurul apel.
Dispatch-ul VM-ului (al doilea nivel)
Unele funcții sunt compilate în closure-uri PHP; altele trec la un interpretor de bytecode și mai simplu în src/Bytecode/VM.php. Bytecode-ul este o matrice liniară de opcoduri (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), procesată prin dispatch switch.
VM-ul există pentru funcțiile care nu merită compilate într-un closure PHP, dar sunt suficient de frecvent apelate pentru a beneficia de omiterea parcurgerii AST-ului. Apelurile de metode asupra obiectelor încorporate (Array.prototype.map, String.prototype.split, …) trec prin căi rapide din VM, care ocolesc trampoline-ul generic callFunction.
Callstack personalizat
Op::CALL, Op::CALL_METHOD și Op::NEW_CALL folosesc o cale de apel inline care păstrează întregul callstack JS în VM::execute — fără un cadru PHP nou pentru fiecare apel JS. La intrare, starea apelantului (pc, flux de cod, stivă de operanzi, localuri, env, this, indicatorul de mod strict) este pusă într-un pool savedFrames per VM de instanțe SavedFrame, iar bucla de dispatch își reconectează localurile la apelat. Op::RET scoate cadrul salvat din pool și îl restaurează. Excepțiile parcurg cadrele salvate înapoi înainte de a reveni la parcurgătorul arborescent.
Eligibilitatea este memorată în cache pe JsFunction: vmInlineCallCache pentru apeluri obișnuite (corp compilat BC, nu legat de bind(), nu nativ), vmInlineConstructCache pentru expresii new (în plus isConstructable, nu derivat, fără inițializatoare de câmpuri de instanță sau metode private). Orice altceva trece în continuare prin trampoline-ul canonic callFunction / vmNewExpression.
Beneficiul arhitectural secundar: limita stivei PHP nu mai limitează adâncimea recursiei JS. Acum singura limită este CallStack::maxDepth — setată la 4 096, suficient de mare pentru orice caz legitim test262 (majoritatea necesită sub 2 k cadre) și suficient de strictă pentru ca testele de recursie infinită patologică să eșueze rapid în bugetul de timeout al runner-ului. Calea cu closure transpilat (JsToPhp) are propria protecție de 8 cadre 192 pentru fallback-ul neinliniat.
Generatoare cu instantanee de cadre
Corpurile simple ale generatoarelor — fără yield*, fără yield în interiorul try/catch/finally, fără await — sunt compilate în bytecode. Compilatorul emite un nou opcode Op::YIELD în locul unde corpul se suspendă; Op::YIELD capturează starea de dispatch (cf, pc+1, stivă, sp, localuri, env, thisValue, strict) într-un obiect heap GeneratorSnapshot și returnează un sentinel YieldResult din VM::execute. Mecanismul JsGenerator păstrează instantaneul și îl transmite înapoi către VM::execute la următorul next() / throw(), înlocuind valoarea de reluare din vârful stivei de operanzi.
Aceasta înlocuiește calea PHP-Fiber pentru subsetul acceptat. Driverul bazat pe Fiber (executeGeneratorBody) rămâne disponibil pentru formele respinse de compilator — delegarea yield*, generatoare care cedează execuția în interiorul unui bloc try, generatoare asincrone, funcții asincrone cu await. JsGenerator funcționează în mod dual: fiecare next() / throw() / return() verifică dacă instanța are un instantaneu sau un Fiber și face dispatch-ul corespunzător.
Nu există un parametru reglabil pentru benchmark; eligibilitatea este decisă la compilare și memorată în cache pe JsFunction. Câștigul este eliminarea unui PHP comutări de context la fiecare yield pe calea cu instantanee; calea Fiber rămâne neschimbată pentru toate celelalte cazuri.
Cache-uri inline
Op::LOAD_MEMBER păstrează un cache inline per PC, indexat după identitatea prototipului, folosit pentru tiparul metodei prototipului pe instanță (c.sum(...), arr.push(...), fiecare dispatch de metodă de clasă). Condiție de acces: receptorul nu are un slot propriu pentru nume, referința prototipului din cache este identică cu getPrototype() receptorului, iar PropertyMap::$version prototipului încă se potrivește cu versiunea capturată.
Invalidarea cache-ului se bazează pe PropertyMap::$version. Modificările structurale (setare/ștergere) îl incrementează necondiționat. Suprascrierile valorilor sloturilor de date îl incrementează numai când PropertyMap::$isUsedAsProto este activ — un indicator de write-barrier activat prima dată când obiectul este observat ca [[Prototype]] al altui obiect. Obiectele instanță care nu servesc niciodată drept prototipuri nu plătesc costul infrastructurii IC la fiecare obj.x = v.
Accesorii de tip getter nu sunt niciodată memorizați în cache (trebuie rulați la fiecare citire).
PropertyMap păstrează un al doilea contor, $mutationVersion, care crește necondiționat la fiecare scriere (fără write-barrier). Cache-ul JSON.stringify fixează acest contor pe fiecare obiect/matrice imbricată vizitată, astfel încât o modificare doar a instanței, precum o.a = 99, să invalideze corect șirul memorat în cache, chiar și atunci când $version al IC-ului ar fi omis incrementarea.
Cache JSON.stringify
JsObject păstrează $jsonCacheString + $jsonCacheVersions (o listă de perechi [obj, mutationVersion] pentru fiecare obiect imbricat vizitat în parcurgerea trivial-stringify). La următorul apel JSON.stringify(o) fără replacer/fără spațiere, calea rapidă trivială verifică dacă toate versiunile fixate au rămas neschimbate și returnează șirul memorat în cache fără să parcurgă din nou sau să recodifice. Tipare frecvente de clonare profundă, precum JSON.parse(JSON.stringify(o)), obțin o accelerare de aproximativ 2× la partea de stringify.
Cache-ul este eliminat fără notificare la o ratare (dacă orice versiune fixată diferă) și este reumplut la următoarea parcurgere. Obiectele cu toJSON, getters, accesorii, simboluri sau sloturi [[PrimitiveValue]] revin la serializatorul conform specificației și nu populează niciodată cache-ul — astfel, calea memorată în cache vede doar date uniforme structural.
Căi rapide pentru funcțiile încorporate
Cele mai mari câștiguri de performanță au venit din instruirea VM-ului să gestioneze anumite funcții încorporate:
Atomics.load/store/compareExchangecu planificare cooperativăloadSpinHook/storeNotifyHook— au recuperat 4 teste de buclă de așteptare Atomics.Date.prototype.getTimezoneOffsetcu un cache DST direct bazat pe hashmap — accelerare de 45 % la testul de stres DST SpiderMonkey.String.fromCharCodecu o cale rapidă ASCII inline — economisește aproximativ 1 M de operații dispatch la o parcurgere tipică.decodeURI/decodeURIComponentcu o cale ultra-rapidă%XX%XX%XX%XXde 12 caractere, folosind decodarea bulkhex2bin.Object.keys/Object.values/Object.entriescu acces direct la harta proprietăților.Array.prototype.map/filter/forEach/reducecu iterare nativă peste matricea PHP subiacentă.
Fiecare cale rapidă este condiționată de o „verificare a formei” — dacă receptorul nu corespunde formei așteptate (e.g. o vizualizare TypedArray care nu este susținută de o PHP string simplă), VM-ul trece la calea lentă conformă cu specificația.
Ce nu face
VM-ul de bytecode nu este un JIT în sensul V8. Nu generează cod mașină nativ per funcție și nu face speculații pe baza tipurilor observate, în afara verificării monomorfe a identității prototipului din IC. Inserarea inline la limita funcției este aproximată prin callstack-ul personalizat (fără cadru PHP suplimentar pentru fiecare apel JS), nu prin coborârea corpului apelat în bytecode-ul apelantului.
Transpilerul JsToPhp este lucrul cel mai apropiat de un JIT pe care îl are motorul: generează cod sursă PHP pentru corpurile funcțiilor frecvent apelate, pe care JIT-ul de trasare al versiunii PHP 8.5 îl compilează apoi în cod nativ. JsToPhp trece prin eval(), deci suportă costul compilării o singură dată, iar JIT-ul de trasare trebuie să descopere funcția ca și cum ar fi cod scris de utilizator. Eliminarea etapei eval() prin emiterea directă a opcodurilor opcache ar reduce mai mult diferența — consultați Benchmarks § Plan de dezvoltare.
Benchmark
bench/microbench.js rulează peste o duzină de benchmark-uri mici (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). Timp median de execuție:
php bench/run.php
Valorile curente sunt salvate în BENCH.md după fiecare rulare a fluxului de lucru bench. Fluxurile de lucru cu matricea bench și test262 sunt pornite manual.