Virtuální stroj s bajtkódem
Výchozí cesta provádění používá stromový interpret — prochází každý uzel AST a vrací hodnotu. U často volaných funkcí v řádu milionů volání je úzkým hrdlem režie zpracování jednotlivých uzlů. Phasis nad ni přidává spekulativní kompilátor z bajtkódu do uzávěrů PHP, který tuto režii snižuje.
Kdy se aktivuje VM
Každá funkce JsFunction má počitadlo častých volání. Jakmile překročí prahovou hodnotu (výchozí 100), engine se pokusí zkompilovat tělo funkce do uzávěru PHP prostřednictvím src/Bytecode/JsToPhp.php.
Kompilace uspěje, pokud tělo funkce odpovídá některému ze známých tvarů:
- Přiřazení lokálním proměnným s předvídatelnými typy (číslo, objekt, funkce, pole, řetězec).
- Aritmetické operace s čísly.
- Čtení vlastností objektově typovaných lokálních proměnných A TAKÉ objektů
this(this.v,obj.x). - Volání metod nad objektově typovanými lokálními proměnnými (
obj.method(args)), při kterém se příjemce předává jako$thisValue. - Volání konstruktoru
new Ctor(args)— změní typ levé lokální proměnné naobjecta pomocí Bailout ověří, že návratová hodnota není objekt. - Smyčky
for/whiles jednoduchými tvary podmínek. returns výrazem, jehož hodnotu lze vypočítat.if/elsenad typovanými podmínkami.
Kompilace selže (a funkce zůstane ve stromovém interpretu), pokud tělo obsahuje:
- Příkazy
with. try / catch / finally(zpracovává je stromový interpret, aby správně dodržel sémantiku dokončení).- Volání
eval(). - Přímou změnu
arguments. - Cokoli, co kompilátor zatím neumí převést.
Pokud kompilace uspěje, engine přepne cestu volání funkce na zkompilovaný uzávěr. Další volání spouštějí nativní PHP s režií jediného volání funkce, nikoli jednoho volání na každý uzel AST.
Ústupová řešení
Zrychlená cesta zkompilovaného kódu vychází z předpokladů (čísla zůstávají čísly, vlastnosti zůstávají ve stejné struktuře). Pokud se některý předpoklad za běhu poruší, uzávěr vyvolá Bailout a provádění tohoto volání se znovu spustí ve stromovém interpretu.
Právě zde je správnost podle specifikace nejdůležitější. Stromový interpret musí umět znovu provést volání, u kterého došlo k ústupu, od začátku a bez pozorovatelných vedlejších účinků z neúspěšné zrychlené cesty. Phasis to zajišťuje dvěma způsoby:
- Čisté úvodní části — kompilátor před spekulací hledá vedlejší účinky. Pokud funkce před místem spekulace provádí I/O (console.log, hostitelské funkce), zrychlená cesta je nevynechá; potvrdí je a pokračuje.
- Odmítnutí při kompilaci — u tvarů, které by vyžadovaly vrácení pozorovatelných vedlejších účinků zpět (nečíselné
let r = fn(), kdefn()mění stav), kompilátor kompilaci zcela odmítne. Funkce zůstane ve stromovém interpretu.
Příklad: nečíselné přiřazení spustí fn() ve zrychlené cestě, vyvolá Bailout a poté se znovu provede ve stromovém interpretu — tím se fn() spustí dvakrát se dvěma sadami vedlejších účinků. Odmítnutí při kompilaci zabrání spuštění fn() ve zrychlené cestě, takže stromový interpret zaznamená jediné volání.
Zpracování ve VM (druhá vrstva)
Některé funkce se kompilují do uzávěrů PHP; ostatní přecházejí k ještě jednoduššímu interpretu bajtkódu v src/Bytecode/VM.php. Bajtkód je ploché pole operačních kódů (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), které zpracovává switch.
VM slouží funkcím, které se nevyplatí kompilovat do uzávěru PHP, ale volají se dost často na to, aby se vyplatilo přeskočit procházení AST. Volání metod vestavěných objektů (Array.prototype.map, String.prototype.split, …) procházejí rychlými cestami ve VM, které obcházejí obecný předávací mechanismus callFunction.
Vlastní zásobník volání
Op::CALL, Op::CALL_METHOD a Op::NEW_CALL používají cestu pro přímá volání, která udržuje celý zásobník volání JS uvnitř VM::execute — při každém volání JS nevzniká nový rámec PHP. Při vstupu se stav volajícího (pc, proud kódu, zásobník operandů, lokální proměnné, env, this, příznak strict mode) uloží do fondu SavedFrame instancí savedFrames pro daný VM a smyčka zpracování přenastaví lokální proměnné na volanou funkci. Op::RET vyjme uložený rámec a obnoví stav. Výjimky se postupně vracejí přes uložené rámce a poté se předají stromovému interpretu.
Způsobilost se ukládá do mezipaměti pro JsFunction: vmInlineCallCache pro běžná volání (tělo zkompilované do bajtkódu, bez vazby na bind(), nikoli nativní), vmInlineConstructCache pro výrazy new (navíc isConstructable, nikoli odvozené, bez inicializátorů polí instance nebo privátních metod). Vše ostatní nadále prochází kanonickým předávacím mechanismem callFunction / vmNewExpression.
Vedlejší přínos této architektury: limit zásobníku PHP už neomezuje hloubku rekurze JS. Nyní je jediným limitem CallStack::maxDepth — nastavený na 4 096, tedy výrazně nad potřebami všech legitimních případů test262 (většině stačí méně než 2 tisíce rámců) a zároveň dost nízký na to, aby patologické testy s nekonečnou rekurzí rychle selhaly v časovém limitu běhu. Cesta přes transpillovaný uzávěr (JsToPhp) má vlastní ochranný limit 8 rámců 192 pro záložní variantu bez přímého volání.
Generátory se snímkem rámce
Jednoduchá těla generátorů — bez yield*, bez yield uvnitř try/catch/finally a bez await — se kompilují do bajtkódu. Kompilátor vloží nový operační kód Op::YIELD do místa, kde se tělo pozastaví; Op::YIELD zachytí stav zpracování (cf, pc+1, zásobník, sp, lokální proměnné, env, thisValue, strict) do objektu na haldě typu GeneratorSnapshot a vrátí z VM::execute zástupnou hodnotu YieldResult. Řídicí JsGenerator uloží snímek a při dalším next() / throw() ho předá zpět VM::execute a nahradí hodnotu pro obnovení na vrcholu zásobníku operandů.
U podporované podmnožiny to nahrazuje cestu přes PHP Fiber. Ovladač založený na Fiber (executeGeneratorBody) zůstává pro tvary, které kompilátor odmítne — yield* delegování, generátory s příkazem yield uvnitř bloku try, asynchronní generátory a asynchronní funkce s await. JsGenerator pracuje ve dvou režimech: každý next() / throw() / return() zkontroluje, zda má instance snímek, nebo Fiber, a podle toho zvolí způsob zpracování.
Žádný parametr k ladění testů výkonu; způsobilost se určuje při kompilaci a ukládá do mezipaměti pro JsFunction. Zrychlení spočívá v jednom kontextovém přepnutí PHP méně při každém pozastavení ve variantě se snímkem; cesta přes Fiber zůstává pro vše ostatní beze změny.
Mezipaměti inline
Op::LOAD_MEMBER má inline cache pro každý PC, klíčovanou identitou prototypu, která se používá pro vzor metoda prototypu na instanci (c.sum(...), arr.push(...), každé volání metody třídy). Podmínka zásahu: příjemce nemá vlastní slot s daným názvem, odkaz na prototyp v cache je totožný s getPrototype() příjemce a PropertyMap::$version prototypu stále odpovídá zachycené verzi.
Zneplatnění cache probíhá přes PropertyMap::$version. Strukturální změny (nastavení / smazání) jej vždy zvýší. Přepsání hodnoty datového slotu jej zvýší jen tehdy, když je PropertyMap::$isUsedAsProto pravdivý — příznak bariéry zápisu, který se přepne při prvním zjištění, že objekt slouží jako [[Prototype]] jiného objektu. Instance, které nikdy neslouží jako prototypy, neplatí za infrastrukturu IC při každém obj.x = v.
Přístupory getterů se nikdy neukládají do cache (při každém čtení se musí spustit znovu).
PropertyMap má druhé počítadlo, $mutationVersion, které se zvýší vždy při každém zápisu (bez bariéry zápisu). Cache JSON.stringify připne toto počítadlo u každého navštíveného vnořeného objektu či pole, aby změna pouze instance, například o.a = 99, správně zneplatnila řetězec v cache, i když by $version IC zvýšení přeskočilo.
Cache JSON.stringify
JsObject uchovává $jsonCacheString + $jsonCacheVersions (seznam dvojic [obj, mutationVersion] pro každý navštívený vnořený objekt při průchodu pro triviální serializaci do řetězce). Při dalším volání JSON.stringify(o) bez replaceru a bez mezery triviální rychlá cesta ověří, že se žádná připnutá verze nezměnila, a vrátí řetězec z cache bez opakovaného průchodu či kódování. Časté vzory s hlubokým klonováním, jako je JSON.parse(JSON.stringify(o)), zrychlí serializaci do řetězce zhruba 2×.
Při neúspěšném zásahu (liší se některá připnutá verze) se cache tiše zahodí a znovu naplní při dalším průchodu. Objekty s toJSON, gettery, přístupory, symboly nebo sloty [[PrimitiveValue]] přejdou na serializátor podle specifikace a cache nikdy nenaplní — cesta přes cache tak pracuje pouze se strukturálně jednotnými daty.
Vestavěné rychlé cesty
Největší výkonnostní zisky přineslo přizpůsobení VM konkrétním vestavěným funkcím:
Atomics.load/store/compareExchanges kooperativním plánovánímloadSpinHook/storeNotifyHook— obnovilo 4 testy spin-loopů Atomics.Date.prototype.getTimezoneOffsets přímou cache DST založenou na hashmapě — o 45 % rychlejší při zátěžovém testu DST SpiderMonkey.String.fromCharCodes inline rychlou cestou pro ASCII — ušetří přibližně 1 M volání při typickém průchodu.decodeURI/decodeURIComponents ultra rychlou cestou%XX%XX%XX%XXo délce 12 znaků, která používá hromadné dekódováníhex2bin.Object.keys/Object.values/Object.entriess přímým přístupem k mapě vlastností.Array.prototype.map/filter/forEach/reduces nativní iterací nad podkladovým polem PHP.
Každá rychlá cesta je podmíněna „kontrolou tvaru“ — pokud příjemce neodpovídá očekávanému tvaru (e.g, například pohled TypedArray, který není podložen běžným PHP string), VM přejde na pomalejší cestu odpovídající specifikaci.
Co tato implementace nedělá
Bajtový kód VM není JIT ve smyslu V8. Negeneruje nativní strojový kód pro každou funkci a nespekuluje na základě pozorovaných typů nad rámec monomorfní kontroly identity prototypu v IC. Vkládání kódu přes hranice funkcí se napodobuje vlastním zásobníkem volání (bez dalšího rámce PHP při každém volání JS), nikoli převedením těla volané funkce do bajtového kódu volajícího.
Transpilátor JsToPhp je nejblíže JITu, co tento engine nabízí: generuje zdrojový kód PHP pro těla často volaných funkcí, který trasovací JIT v PHP 8.5 následně zkompiluje do nativního kódu. JsToPhp používá eval(), takže platí jednorázovou cenu za kompilaci a trasovací JIT musí funkci objevit, jako by šlo o uživatelský kód. Přeskočení kroku eval() přímým generováním opkódů opcache by rozdíl dále zmenšilo — viz Srovnávací testy § Plán.
Srovnávací testy
bench/microbench.js spouští něco přes tucet drobných srovnávacích testů (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). Mediánová doba běhu:
php bench/run.php
Aktuální výsledky se po každém spuštění pracovního postupu bench ukládají do souboru BENCH.md. Pracovní postupy bench a matice test262 se spouštějí ručně.