Ga naar de inhoud

Bytecode-VM

Het standaarduitvoeringspad is een tree-walker: elk AST-knooppunt bezoeken en een waarde retourneren. Bij veelgebruikte functies die miljoenen keren worden aangeroepen, vormt de dispatch-overhead per knooppunt de bottleneck. Phasis voegt daarbovenop een speculatieve compiler toe die bytecode omzet naar PHP-closures om die overhead te verlagen.

Wanneer de VM in actie komt

Elke JsFunction heeft een teller voor veelgebruikte aanroepen. Zodra die een drempel overschrijdt (standaard 100), probeert de engine de functiebody via src/Bytecode/JsToPhp.php te compileren naar een PHP-closure.

Compilatie slaagt wanneer de functiebody past bij een bekende reeks vormen:

  • Toewijzingen aan lokale variabelen met voorspelbare typen (numeriek, object, functie, array, tekenreeks).
  • Rekenkundige bewerkingen op getallen.
  • Member-lezingen op objectgetypeerde lokale variabelen EN op this (this.v, obj.x).
  • Methodeaanroepen op objectgetypeerde lokale variabelen (obj.method(args)), waarbij de ontvanger wordt doorgegeven als $thisValue.
  • Aanroepen van new Ctor(args)-constructors — promoveert het type van de lokale variabele aan de linkerkant naar object en bewaakt met Bailout dat er geen niet-object wordt geretourneerd.
  • for- / while-lussen met eenvoudige voorwaardevormen.
  • return met een berekenbare expressie.
  • if / else met getypeerde voorwaarden.

Compilatie mislukt (en de functie blijft op de tree-walker) wanneer de body het volgende bevat:

  • with-statements.
  • try / catch / finally (de tree-walker handelt dit af voor correcte voltooiingssemantiek).
  • eval()-aanroepen.
  • Directe mutatie van arguments.
  • Alles wat de compiler nog niet kan omzetten.

Wanneer de compilatie slaagt, schakelt de engine het aanroeppad van de functie om naar de gecompileerde closure. Volgende aanroepen voeren native PHP uit met de overhead van één functieaanroep, in plaats van overhead per AST-knooppunt.

Bailouts

Het snelle gecompileerde pad maakt aannames (getallen blijven getallen, eigenschappen blijven dezelfde vorm hebben). Wanneer een aanname tijdens runtime niet meer klopt, werpt de closure een Bailout en wordt de uitvoering voor die aanroep vanaf het begin hervat in de tree-walker.

Hier is correctheid volgens de specificatie het belangrijkst. De tree-walker moet de afgebroken aanroep opnieuw kunnen uitvoeren zonder neveneffecten van het mislukte snelle pad waar te nemen. Phasis regelt dit op twee manieren:

  1. Zuivere voorvoegsels — de compiler zoekt naar neveneffecten vóór er wordt gespeculeerd. Als de functie I/O uitvoert (console.log, hostfuncties) vóór het speculatiepunt, slaat het snelle pad die bewerkingen niet over; het voert ze uit en gaat verder.
  2. Weigeren tijdens compilatie — voor vormen waarbij waarneembare neveneffecten zouden moeten worden teruggedraaid (een niet-numerieke let r = fn() waarbij fn() de status wijzigt), weigert de compiler de functie te compileren. De functie blijft op de tree-walker.

De casus die hiervoor aanleiding gaf: een niet-numerieke toewijzing voert op het snelle pad fn() uit, werpt Bailout en wordt vervolgens opnieuw uitgevoerd op de tree-walker — waardoor fn() tweemaal wordt uitgevoerd, met twee reeksen neveneffecten. Door tijdens compilatie te weigeren, wordt voorkomen dat fn() überhaupt op het snelle pad wordt uitgevoerd, zodat de tree-walker de enige aanroep verwerkt.

VM-dispatch (de tweede laag)

Sommige functies worden gecompileerd naar PHP-closures; andere komen terecht bij een nog eenvoudigere bytecode-interpreter in src/Bytecode/VM.php. De bytecode is een vlakke array van opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) die wordt verwerkt door een switch-dispatch.

De VM is bedoeld voor functies die niet de moeite waard zijn om naar een PHP-closure te compileren, maar wel vaak genoeg worden aangeroepen om voordeel te hebben bij het overslaan van AST-traversal. Methodeaanroepen op ingebouwde objecten (Array.prototype.map, String.prototype.split, …) gebruiken snelle paden in de VM die de generieke callFunction-trampoline omzeilen.

Aangepaste aanroepstack

Op::CALL, Op::CALL_METHOD en Op::NEW_CALL gebruiken een inline-aanroeppad dat de volledige JS-aanroepstack binnen VM::execute houdt — er komt geen nieuw PHP-frame per JS-aanroep. Bij binnenkomst wordt de status van de aanroeper (pc, codestroom, operandstack, lokale variabelen, env, this, strict-modevlag) opgeslagen in een pool per VM van SavedFrame-instanties van savedFrames, en de dispatchlus wijst zijn lokale variabelen opnieuw toe aan de aangeroepene. Op::RET haalt het opgeslagen frame op en herstelt de status. Uitzonderingen worden langs opgeslagen frames teruggedraaid voordat ze bij de tree-walker terechtkomen.

Geschiktheid wordt gememoiseerd op JsFunction: vmInlineCallCache voor gewone aanroepen (BC-gecompileerde body, niet bind()-gebonden, niet native), vmInlineConstructCache voor new-expressies (daarnaast isConstructable, niet afgeleid, zonder initializers voor instantievelden of privémethoden). Al het overige loopt nog via de canonieke trampoline callFunction / vmNewExpression.

Het architecturale voordeel: de limiet voor de PHP-stack begrenst de recursiediepte van JS niet langer. Alleen CallStack::maxDepth vormt nu een limiet — ingesteld op 4 096, ruim boven elke legitieme test262-casus (de meeste hebben minder dan 2 k frames nodig) en laag genoeg om pathologische tests met oneindige recursie snel te laten mislukken binnen de time-out van de runner. Het pad met getranspilede closures (JsToPhp) heeft een eigen bewaking van 8 192-frames voor de terugval die niet inline verloopt.

Generators met framesnapshots

Eenvoudige generatorbodies — zonder yield*, zonder yield binnen try/catch/finally en zonder await — worden gecompileerd naar bytecode. De compiler genereert een nieuwe Op::YIELD-opcode op de plek waar de body pauzeert; Op::YIELD legt de dispatchstatus (cf, pc+1, stack, sp, locals, env, thisValue, strict) vast in een GeneratorSnapshot-heapobject en retourneert een YieldResult-sentinel uit VM::execute. De aansturende JsGenerator slaat de snapshot op en geeft die bij de volgende next() / throw() terug aan VM::execute, waarbij de hervattingswaarde boven op de operandstack wordt vervangen.

Dit vervangt het PHP-Fiber-pad voor de ondersteunde subset. De op Fiber gebaseerde driver (executeGeneratorBody) blijft beschikbaar voor vormen die de compiler afwijst: yield*-delegatie, generators die binnen een try-blok een waarde opleveren, async-generators en async-functies met await. JsGenerator werkt in twee modi: bij elke next() / throw() / return() wordt gecontroleerd of de instantie een snapshot of een Fiber heeft, waarna de juiste wordt aangeroepen.

Er is geen instelling om benchmarks mee af te stemmen; de geschiktheid wordt bepaald tijdens compilatie en gememoiseerd op JsFunction. Het voordeel is één PHP-contextswitch minder per yield in het snapshotpad; het Fiber-pad blijft voor al het overige ongewijzigd.

Inlinecaches

Op::LOAD_MEMBER bevat een inlinecache per PC, gekoppeld aan de prototype-identiteit, voor het patroon waarbij een prototypemethode op een instantie wordt gebruikt (c.sum(...), arr.push(...), elke dispatch van een klassmethode). De cache wordt gebruikt als de ontvanger geen eigen slot voor de naam heeft, de gecachte prototypeverwijzing identiek is aan getPrototype() van de ontvanger en PropertyMap::$version van het prototype nog overeenkomt met de vastgelegde versie.

Cache-invalidatie verloopt via PropertyMap::$version. Structurele mutaties (instellen / verwijderen) verhogen dit altijd. Waarden van gegevensslots verhogen het alleen wanneer PropertyMap::$isUsedAsProto waar is — een write-barriervlag die voor het eerst wordt omgezet wanneer het object wordt waargenomen als [[Prototype]] van een ander object. Voor instantieobjecten die nooit als prototypes dienen, brengt de IC-infrastructuur bij elke obj.x = v geen kosten met zich mee.

Getter-accessors worden nooit gecachet (ze moeten bij elke lezing opnieuw worden uitgevoerd).

PropertyMap bevat een tweede teller, $mutationVersion, die bij elke schrijfactie onvoorwaardelijk wordt verhoogd (zonder write-barrier). De JSON.stringify-cache houdt deze teller bij voor elk bezocht genest object of array, zodat een mutatie die alleen de instantie betreft, zoals o.a = 99, de gecachete tekenreeks correct ongeldig maakt, zelfs wanneer de $version van de IC het verhogen van de teller zou hebben overgeslagen.

JSON.stringify-cache

JsObject bevat $jsonCacheString + $jsonCacheVersions (een lijst met paren van [obj, mutationVersion] voor elk genest object dat tijdens de trivial-stringify-walk is bezocht). Bij de volgende aanroep van JSON.stringify(o) zonder replacer of spatie controleert het snelle trivial-pad of elke vastgelegde versie ongewijzigd is en retourneert het de gecachete tekenreeks zonder opnieuw te lopen of te coderen. Veelvoorkomende patronen met diepe klonen, zoals JSON.parse(JSON.stringify(o)), leveren ongeveer 2× snelheidswinst op bij het stringify-gedeelte.

Bij een cachemiss wordt de cache stilzwijgend verwijderd (als een vastgelegde versie afwijkt) en bij de volgende walk opnieuw gevuld. Objecten met toJSON, getters, accessors, symbolen of [[PrimitiveValue]]-slots vallen terug op de serialiser volgens de specificatie en vullen de cache nooit; het gecachete pad verwerkt dus alleen structureel uniforme data.

Ingebouwde snelle paden

De grootste prestatiewinst kwam van het aanleren van specifieke ingebouwde functies aan de VM:

  • Atomics.load / store / compareExchange met coöperatieve planning van loadSpinHook / storeNotifyHook — 4 tests voor Atomics-spinloops teruggewonnen.
  • Date.prototype.getTimezoneOffset met een DST-cache op basis van een directe hashmap — 45 % sneller bij de SpiderMonkey-DST-stresstest.
  • String.fromCharCode met een inline ASCII-snel pad — bespaart ongeveer 1 M dispatches per gebruikelijke sweep.
  • decodeURI / decodeURIComponent met een ultrasnel pad voor 12 tekens via %XX%XX%XX%XX en bulkdecodering met hex2bin.
  • Object.keys / Object.values / Object.entries met directe toegang tot de property-map.
  • Array.prototype.map / filter / forEach / reduce met native iteratie over de onderliggende PHP-array.

Elk snel pad wordt afgeschermd door een vormcontrole: als de ontvanger niet de verwachte vorm heeft (e.g, een TypedArray-weergave die niet wordt ondersteund door een gewone PHP string), valt de VM terug op het trage pad dat aan de specificatie voldoet.

Wat dit niet doet

De bytecode-VM is geen JIT in de betekenis van V8. Hij genereert geen native machinecode per functie en speculeert niet op basis van waargenomen types, afgezien van de monomorfe controle van de prototype-identiteit door de IC. Inline-functiegrenzen worden benaderd via de aangepaste callstack (geen extra PHP-frame per JS-aanroep), niet door de body van de callee in de bytecode van de caller te verwerken.

De JsToPhp-transpiler komt het dichtst in de buurt van een JIT die de engine heeft: hij genereert PHP-broncode voor veelgebruikte functie-inhoud die PHP 8.5's tracing-JIT vervolgens naar native code compileert. JsToPhp loopt via eval() en betaalt daardoor een eenmalige compilatiekost; bovendien moet de tracing-JIT de functie ontdekken alsof het gebruikerscode is. Door de eval()-stap over te slaan en rechtstreeks opcache-opcodes uit te sturen, zou de achterstand verder kunnen worden verkleind — zie Benchmarks § Roadmap.

Benchmarks

bench/microbench.js voert een dozijn kleine benchmarks uit, en nog een paar (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). Mediane wandtijd:

Shell
php bench/run.php

De huidige cijfers worden na elke workflowrun van bench vastgelegd in BENCH.md. De matrixworkflows van bench en test262 worden beide handmatig gestart.