Bytecode-VM
Standardstien til afvikling er en træfortolker — besøg hver AST-node, og returnér en værdi. For varme funktioner, der kaldes millioner af gange, er dispatch-overheadet pr. node flaskehalsen. Phasis tilføjer en spekulativ compiler fra bytecode til PHP-closures for at reducere dette overhead.
Hvornår VM'en aktiveres
Hver JsFunction har en tæller for varme kald. Når den overskrider en tærskel (som standard 100), forsøger motoren at kompilere funktionens body til en PHP-closure via src/Bytecode/JsToPhp.php.
Kompileringen lykkes, når funktionens body passer til et kendt sæt former:
- Tildelinger til lokale variabler med forudsigelige typer (tal, objekt, funktion, array, streng).
- Aritmetik med tal.
- Medlemsopslag på lokale variabler med objekttype OG på
this(this.v,obj.x). - Metodekald på lokale variabler med objekttype (
obj.method(args)), hvor modtageren sendes med som$thisValue. - Kald af
new Ctor(args)-konstruktører — opgraderer LHS-lokalvariablen tilobjectsom type og sætter en Bailout-vagt ved retur af andet end et objekt. - Løkker af typen
for/whilemed enkle betingelser. returnmed et beregneligt udtryk.if/elseover typede betingelser.
Kompileringen mislykkes (og funktionen forbliver på træfortolkeren), når bodyen indeholder:
with-sætninger.try / catch / finally(håndteres af træfortolkeren for at sikre korrekt completion-semantik).eval()-kald.- Direkte mutation af
arguments. - Alt, som compileren endnu ikke ved, hvordan den skal sænke.
Når kompileringen lykkes, skifter motoren funktionens kaldesti til den kompilerede closure. Efterfølgende kald afvikler native PHP med overhead fra ét funktionskald i stedet for ét pr. AST-node.
Fallbacks
Den kompilerede hurtige sti bygger på antagelser (at tal forbliver tal, og at egenskaber forbliver på samme form). Når en antagelse brydes under kørsel, kaster closuren en Bailout, og afviklingen genstartes i træfortolkeren for det pågældende kald.
Det er her, korrekthed i forhold til specifikationen betyder mest. Træfortolkeren skal kunne genafvikle det kald, der udløste fallback, fra begyndelsen uden at observere bivirkninger fra den mislykkede hurtige sti. Phasis håndterer dette på to måder:
- Rene præfikser — compileren undersøger for bivirkninger før enhver spekulation. Hvis funktionen udfører I/O (console.log, værtsfunktioner) før spekulationspunktet, springer den hurtige sti ikke over dette; den gennemfører handlingen og fortsætter.
- Afvisning under kompilering — for former, der kræver tilbageførsel af observerbare bivirkninger (en ikke-numerisk
let r = fn(), hvorfn()ændrer tilstand), afviser compileren at kompilere overhovedet. Funktionen bliver på træfortolkeren.
Det motiverende tilfælde: en ikke-numerisk tildeling udfører fn() på den hurtige sti, kaster Bailout og afvikles derefter igen i træfortolkeren — så fn() køres to gange med to sæt bivirkninger. Afvisning under kompilering forhindrer fn() i overhovedet at blive kørt på den hurtige sti, så træfortolkeren kun observerer det ene kald.
VM-dispatch (det andet lag)
Nogle funktioner kompileres til PHP-closures; andre falder videre til en endnu enklere bytecodefortolker i src/Bytecode/VM.php. Bytecoden er et fladt array af opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), som afvikles af en switch-dispatch.
VM'en bruges til funktioner, der ikke er værd at kompilere til en PHP-closure, men som stadig er varme nok til at drage fordel af at springe AST-gennemgangen over. Metodekald på indbyggede objekter (Array.prototype.map, String.prototype.split, …) går gennem hurtige stier i VM'en, der omgår den generiske callFunction-trampolin.
Brugerdefineret kaldestak
Op::CALL, Op::CALL_METHOD og Op::NEW_CALL bruger en inline-kaldesti, der holder hele JS-kaldestakken inden for VM::execute — ingen ny PHP-frame for hvert JS-kald. Ved indgang lægges kalderens tilstand (pc, kodestrøm, operandstak, lokale variabler, env, this, strict-mode-flag) i en pulje pr. VM med SavedFrame-instanser af typen savedFrames, hvorefter dispatch-løkken kobler sine lokale variabler om til den kaldte funktion. Op::RET fjerner den gemte frame fra puljen og gendanner den. Undtagelser afvikles gennem gemte frames, før de sendes videre til træfortolkeren.
Egnetheden gemmes i cache på JsFunction: vmInlineCallCache for almindelige kald (BC-kompileret body, ikke bundet til bind(), ikke native), vmInlineConstructCache for new-udtryk (derudover isConstructable, ikke nedarvet, ingen initialisering af instansfelter eller private metoder). Alt andet går stadig gennem den kanoniske callFunction-/vmNewExpression-trampolin.
Den arkitektoniske sidegevinst: grænsen for PHP-stakken begrænser ikke længere rekursionsdybden for JS. Nu er CallStack::maxDepth den eneste grænse — sat til 4 096, hvilket er komfortabelt over alle legitime test262-tilfælde (de fleste kræver under 2 k frames) og lavt nok til, at patologiske tests med uendelig rekursion fejler hurtigt inden for runnerens timeout. Stien med transpilerede closures (JsToPhp) har sin egen vagt på 8 192-frames for fallback uden inlining.
Generators med framesnapshot
Enkle generator-bodies — uden yield*, uden yield i try/catch/finally og uden await — kompileres til bytecode. Compileren udsender en ny Op::YIELD-opcode dér, hvor bodyen suspenderes; Op::YIELD indfanger dispatch-tilstanden (cf, pc+1, stack, sp, lokale variabler, env, thisValue, strict) i et GeneratorSnapshot-heapobjekt og returnerer en YieldResult-sentinel fra VM::execute. Den drivende JsGenerator gemmer snapshot'et og sender det tilbage til VM::execute ved næste next() / throw(), hvor resume-værdien øverst på operandstakken erstattes.
Dette erstatter PHP-Fiber-stien for det understøttede delmængde. Den Fiber-baserede driver (executeGeneratorBody) bruges stadig til former, som compileren afviser — yield*-delegering, generators, der giver værdier inde i en try-blok, async generators og async-funktioner med await. JsGenerator har to tilstande: hver next() / throw() / return() kontrollerer, om instansen har et snapshot eller en Fiber, og sender afviklingen videre derefter.
Der er ingen indstilling, som kan finjusteres med benchmarks; egnetheden afgøres ved kompilering og gemmes i cache på JsFunction. Gevinsten er ét færre kontekstskift i PHP pr. yield på snapshot-stien; Fiber-stien er uændret for alt andet.
Inline-caches
Op::LOAD_MEMBER har en inline-cache pr. pc, der er indekseret efter prototypeidentitet og bruges til mønsteret med prototypemetode på instans (c.sum(...), arr.push(...), alle dispatch af klassemetoder). Betingelse for cache-hit: modtageren har ingen egen slot for navnet, den cachelagrede prototypehenvisning er identisk med modtagerens getPrototype(), og prototypens PropertyMap::$version svarer stadig til den registrerede version.
Cache-invalidering bygger på PropertyMap::$version. Strukturelle ændringer (set / delete) øger den altid. Overskrivning af dataplotsværdi øger kun tælleren, når PropertyMap::$isUsedAsProto er sand — et write-barrier-flag, der slås til første gang objektet observeres som et andet objekts [[Prototype]]. Instansobjekter, der aldrig fungerer som prototyper, betaler intet for IC-infrastrukturen ved hver obj.x = v.
Getter-adgangsfunktioner cachelagres aldrig (de skal køres igen ved hver læsning).
PropertyMap har en ekstra tæller, $mutationVersion, som øges altid ved hver skrivning (uden write-barrier). JSON.stringify-cachen fastlåser denne tæller for hvert besøgt indlejret objekt/array, så en ændring, der kun påvirker instansen, som o.a = 99, korrekt invaliderer den cachelagrede streng, selv når IC'ens $version ville have sprunget tællerøgningen over.
JSON.stringify-cache
JsObject har $jsonCacheString + $jsonCacheVersions (en liste med [obj, mutationVersion]-par for hvert indlejret objekt, der blev besøgt under den trivielle stringify-gennemgang). Ved næste JSON.stringify(o)-kald uden replacer / space kontrollerer den trivielle hurtige sti, at alle fastlåste versioner er uændrede, og returnerer den cachelagrede streng uden at gennemgå eller kode data igen. Hyppige mønstre med dyb kloning som JSON.parse(JSON.stringify(o)) bliver cirka 2× hurtigere i stringify-delen.
Cachen droppes uden varsel ved cache-miss (hvis en fastlåst version afviger) og udfyldes igen ved næste gennemgang. Objekter med toJSON, getters, adgangsfunktioner, symboler eller [[PrimitiveValue]]-slots falder tilbage til specifikationens serialiseringsfunktion og udfylder aldrig cachen — den cachelagrede sti ser derfor kun strukturelt ensartede data.
Indbyggede hurtige stier
De største ydelsesforbedringer kom af at lære VM'en om specifikke indbyggede funktioner:
Atomics.load/store/compareExchangemedloadSpinHook/storeNotifyHookkooperativ planlægning — genfandt 4 Atomics-tests med spin-løkker.Date.prototype.getTimezoneOffsetmed en DST-cache baseret på direkte hashmap — 45 % hurtigere ved SpiderMonkey DST-belastningstest.String.fromCharCodemed en inline-hurtig sti til ASCII — sparer ~1 M dispatches pr. typisk gennemløb.decodeURI/decodeURIComponentmed en ultrahurtig sti til%XX%XX%XX%XXpå 12 tegn, der brugerhex2bin-masséafkodning.Object.keys/Object.values/Object.entriesmed direkte adgang til egenskabskortet.Array.prototype.map/filter/forEach/reducemed indbygget iteration over det underliggende PHP-array.
Hver hurtig sti styres af et "formtjek" — hvis modtageren ikke har den forventede form (e.g. en TypedArray-visning, der ikke er understøttet af en almindelig PHP string), falder VM'en tilbage til den langsomme sti, der følger specifikationen.
Hvad dette ikke gør
Bytekode-VM'en er ikke en JIT i V8-forstand. Den genererer ikke native maskinkode pr. funktion og spekulerer ikke ud fra observerede typer ud over IC'ens monomorfe tjek af prototypeidentitet. Inlining ved funktionsgrænser efterlignes med den tilpassede kaldestak (ingen ekstra PHP-ramme pr. JS-kald) i stedet for at sænke den kaldte funktions krop til den kaldende funktions bytekode.
JsToPhp-transpileren er det nærmeste, motoren kommer på en JIT: den genererer PHP-kildekode til varme funktionskroppe, som PHP 8.5's sporings-JIT derefter kompilerer til native kode. JsToPhp går gennem eval() og betaler derfor en engangsudgift til kompilering, mens sporings-JIT'en skal opdage funktionen, som om den var brugerkode. Hvis eval()-trinnet springes over ved at generere opcache-opkoder direkte, ville forskellen blive mindre — se Benchmarks § Roadmap.
Benchmark
bench/microbench.js kører et dusin og lidt til små benchmarks (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). Median køretid:
php bench/run.php
De aktuelle tal gemmes i BENCH.md efter hver kørsel af bench-workflowen. Workflowene for bench og test262-matricen startes begge manuelt.