Bytecode-VM
Standardvägen för exekvering är en trädtolkare — besök varje AST-nod och returnera ett värde. För heta funktioner som anropas miljontals gånger blir dispatchkostnaden per nod flaskhalsen. Phasis lägger till en spekulativ kompilator från bytekod till PHP-closure för att minska den kostnaden.
När VM:n aktiveras
Varje JsFunction har en räknare för heta anrop. När den passerar en tröskel (standardvärde 100) försöker motorn kompilera funktionskroppen till en PHP-closure via src/Bytecode/JsToPhp.php.
Kompileringen lyckas när funktionskroppen passar in i en känd uppsättning former:
- Tilldelningar till lokala variabler med förutsägbara typer (numerisk, objekt, funktion, array, sträng).
- Aritmetik med tal.
- Medlemsläsningar från lokala variabler av objekttyp OCH från
this(this.v,obj.x). - Metodanrop på lokala variabler av objekttyp (
obj.method(args)), där mottagaren skickas med som$thisValue. new Ctor(args)-konstruktoranrop — uppgraderar LHS-lokalen till typenobjectoch skyddar med Bailout mot returvärden som inte är objekt.for- ochwhile-loopar med enkla villkorsformer.returnmed ett beräkningsbart uttryck.if/elsemed typade villkor.
Kompileringen misslyckas (och funktionen stannar i trädtolkaren) när kroppen innehåller:
with-satser.try / catch / finally(hanteras av trädtolkaren för korrekt semantik vid slutförande).eval()-anrop.- Direkt mutation av
arguments. - Sådant kompilatorn ännu inte vet hur den ska sänka.
När kompileringen lyckas byter motorn funktionens anropsväg till den kompilerade closuren. Efterföljande anrop kör inbyggd PHP med kostnaden för ett funktionsanrop, i stället för en kostnad per AST-nod.
Avbrott
Den kompilerade snabbvägen bygger på antaganden (att tal förblir tal och egenskaper behåller samma form). När ett antagande bryts vid körning kastar closuren en Bailout, och exekveringen startas om i trädtolkaren för det anropet.
Det är här korrekthet enligt specifikationen är som viktigast. Trädtolkaren måste kunna köra om det avbrutna anropet från början utan att observera några sidoeffekter från den misslyckade snabbvägen. Phasis hanterar detta på två sätt:
- Rena prefix — kompilatorn letar efter sidoeffekter före varje spekulationspunkt. Om funktionen utför I/O (console.log, värdfunktioner) före spekulationspunkten hoppar snabbvägen inte över det; den genomför det och fortsätter.
- Avböjning vid kompilering — för former som skulle kräva att observerbara sidoeffekter rullas tillbaka (en
let r = fn()som inte är numerisk, därfn()ändrar tillstånd) vägrar kompilatorn att kompilera över huvud taget. Funktionen stannar i trädtolkaren.
Det motiverande fallet: en tilldelning som inte är numerisk kör fn() i snabbvägen, kastar Bailout och körs sedan igen i trädtolkaren — vilket kör fn() två gånger med två uppsättningar sidoeffekter. Avböjning vid kompilering hindrar fn() från att köras i snabbvägen över huvud taget, så trädtolkaren ser det enda anropet.
VM-dispatch (det andra lagret)
Vissa funktioner kompileras till PHP-closures; andra faller vidare till en ännu enklare bytekodstolkare i src/Bytecode/VM.php. Bytekoden är en platt array med opkoder (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) som behandlas av en switch-dispatch.
VM:n används för funktioner som inte är värda att kompilera till en PHP-closure men ändå är tillräckligt heta för att vinna på att hoppa över AST-genomgången. Metodanrop på inbyggda objekt (Array.prototype.map, String.prototype.split, …) går via snabbvägar i VM:n som kringgår den generiska callFunction-trampolinen.
Anpassad anropsstack
Op::CALL, Op::CALL_METHOD och Op::NEW_CALL använder en inline-anropsväg som håller hela JS-anropsstacken i VM::execute — ingen ny PHP-ram per JS-anrop. Vid inträde läggs anroparens tillstånd (pc, kodström, operandstack, lokala variabler, env, this, strict-mode-flagga) i en savedFrames-pool per VM med SavedFrame-instanser, och dispatchloopen kopplar om sina lokala variabler till den anropade funktionen. Op::RET tar bort den sparade stackramen och återställer tillståndet. Undantag vecklas upp genom sparade stackramar innan exekveringen återgår till trädtolkaren.
Behörighet sparas memoiserad på JsFunction: vmInlineCallCache för vanliga anrop (BC-kompilerad kropp, inte bind()-bunden, inte inbyggd), vmInlineConstructCache för new-uttryck (dessutom isConstructable, inte härledd, inga initierare för instansfält eller privata metoder). Allt annat går fortfarande via den kanoniska callFunction / vmNewExpression-trampolinen.
Den arkitektoniska sidovinsten: taket för PHP-stacken begränsar inte längre JSs rekursionsdjup. CallStack::maxDepth är nu den enda gränsen — satt till 4 096, gott och väl över alla legitima test262-fall (de flesta kräver färre än 2 k stackramar) och tillräckligt låg för att patologiska oändlighetsrekursionstester snabbt ska misslyckas inom körarens tidsgräns. Den transpilerade closure-vägen (JsToPhp) har en egen spärr vid 8 stackramar av typen 192 för reservvägen utan inline-anrop.
Generatorer med stackramsögonblicksbilder
Enkla generatorkroppar — utan yield*, utan yield inuti try/catch/finally och utan await — kompileras till bytekod. Kompilatorn genererar en ny Op::YIELD-opkod där kroppen pausar; Op::YIELD fångar dispatchtillståndet (cf, pc+1, stack, sp, lokala variabler, env, thisValue, strict) i ett GeneratorSnapshot-heap-objekt och returnerar en YieldResult-sentinel från VM::execute. Den drivande JsGenerator sparar ögonblicksbilden och skickar tillbaka den till VM::execute vid nästa next() / throw(), och ersätter återupptagningsvärdet överst på operandstacken.
Detta ersätter PHP-Fiber-vägen för den delmängd som stöds. Den Fiber-baserade drivrutinen (executeGeneratorBody) finns kvar för former som kompilatorn avvisar — delegering med yield*, generatorer som ger ett värde inuti ett try-block, asynkrona generatorer och asynkrona funktioner med await. JsGenerator har två lägen: varje next() / throw() / return() kontrollerar om instansen har en ögonblicksbild eller en Fiber och skickar vidare därefter.
Ingen inställning kan finjusteras för bänktester; behörighet avgörs vid kompilering och memoiseras på JsFunction. Vinsten är en färre PHP-kontextväxling per yield i vägen med ögonblicksbilder; Fiber-vägen är oförändrad för allt annat.
Inline-cacher
Op::LOAD_MEMBER har en inline-cache per PC, nycklad efter prototypidentitet. Den används för mönstret med prototypmetoder på instanser (c.sum(...), arr.push(...) och varje dispatch av klassmetod). Träffvillkor: mottagaren har ingen egen plats för namnet, den cachade prototypreferensen är identisk med mottagarens getPrototype() och prototypens PropertyMap::$version matchar fortfarande den fångade versionen.
Cacheogiltigförklaring bygger på PropertyMap::$version. Strukturella mutationer (set / delete) ökar det alltid. Överskrivningar av dataplatsvärden ökar det bara när PropertyMap::$isUsedAsProto är sant — en skrivspärrflagga som slås på första gången objektet observeras som ett annat objekts [[Prototype]]. Instansobjekt som aldrig fungerar som prototyper betalar ingen kostnad för IC-infrastrukturen vid varje obj.x = v.
Getter-accessorer cachas aldrig (de måste köras om vid varje läsning).
PropertyMap har en andra räknare, $mutationVersion, som ökar ovillkorligt vid varje skrivning (utan skrivspärr). JSON.stringify-cachen fäster den här räknaren på varje besökt nästlat objekt/array, så att en mutation som endast berör en instans, som o.a = 99, korrekt ogiltigförklarar den cachade strängen även när IC:ns $version skulle ha hoppat över ökningen.
JSON.stringify-cache
JsObject har $jsonCacheString + $jsonCacheVersions (en lista med [obj, mutationVersion]-par för varje nästlat objekt som besöks under den triviala stringify-genomgången). Vid nästa JSON.stringify(o)-anrop utan replacer / space verifierar den triviala snabbvägen att varje fäst version är oförändrad och returnerar den cachade strängen utan att gå igenom eller koda om objekten. Heta mönster för djupkloning, som JSON.parse(JSON.stringify(o)), blir ungefär 2× snabbare vid stringify-steget.
Cachen tas bort tyst vid miss (om någon fäst version skiljer sig) och fylls på igen vid nästa genomgång. Objekt med toJSON, getters, accessorer, symboler eller [[PrimitiveValue]]-platser faller tillbaka till serialiseraren enligt specifikationen och fyller aldrig cachen — den cachade vägen hanterar därför enbart strukturellt enhetliga data.
Snabbvägar för inbyggda funktioner
De största prestandavinsterna kom av att lära VM:n om specifika inbyggda funktioner:
Atomics.load/store/compareExchangemed kooperativ schemaläggning vialoadSpinHook/storeNotifyHook— återställde 4 Atomics-spinnlooptester.Date.prototype.getTimezoneOffsetmed en direkt hashmapp för DST-cache — 45 % snabbare i SpiderMonkeys DST-stresstest.String.fromCharCodemed en inbyggd ASCII-snabbväg — sparar ~1 M dispatchar per typisk genomgång.decodeURI/decodeURIComponentmed en ultrasnabb 12-teckensväg för%XX%XX%XX%XX, med massavkodning viahex2bin.Object.keys/Object.values/Object.entriesmed direkt åtkomst till egenskapskartor.Array.prototype.map/filter/forEach/reducemed inbyggd iteration över den underliggande PHP-arrayen.
Varje snabbväg skyddas av en "formkontroll" — om mottagaren inte har den förväntade formen (e.g. en TypedArray-vy som inte stöds av en vanlig PHP string) går VM:n vidare till den långsamma vägen som följer specifikationen.
Vad detta inte gör
Bytekod-VM:n är inte en JIT i betydelsen V8. Den genererar inte inbyggd maskinkod per funktion och gör inga spekulationer utifrån observerade typer, bortsett från IC:ns monomorfa kontroll av prototypidentitet. Inlining över funktionsgränser approximeras med den anpassade anropsstacken (ingen extra PHP-ram per JS-anrop) i stället för att den anropade funktionens kropp sänks till anroparens bytekod.
JsToPhp-transpileraren är motorns närmaste motsvarighet till en JIT: den genererar PHP-kod för heta funktionskroppar, som PHP 8.5:s spårande JIT sedan kompilerar till inbyggd kod. JsToPhp går via eval(), så den betalar en engångskostnad för kompilering och den spårande JIT-kompilatorn måste upptäcka funktionen som om den vore användarkod. Om man hoppar över steget eval() genom att generera opcache-opkoder direkt minskar gapet ytterligare — se Benchmarks § Färdplan.
Bänktester
bench/microbench.js kör ett dussintal små bänktester (loop-aritmetik, 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). Medianväggtid:
php bench/run.php
Aktuella siffror checkas in i BENCH.md efter varje körning av bench-arbetsflödet. bench- och test262-matrisarbetsflödena startas båda manuellt.