Bájtkód-VM
Az alapértelmezett végrehajtási útvonal egy fastruktúra-bejáró: végigjár minden AST-csomópontot, és visszaad egy értéket. A milliószor meghívott, gyakran futó függvényeknél a csomópontonkénti diszpécselés többletköltsége jelenti a szűk keresztmetszetet. A Phasis egy spekulatív, bájtkódból PHP lezárást fordító réteget épít rá, hogy csökkentse ezt a többletköltséget.
Mikor lép működésbe a VM
Minden JsFunction rendelkezik egy gyakori hívásokat számlálóval. Amikor ez átlép egy küszöböt (alapértelmezés szerint 100), a motor megpróbálja a függvénytörzset src/Bytecode/JsToPhp.php segítségével PHP lezárássá fordítani.
A fordítás akkor sikerül, ha a függvénytörzs az ismert minták valamelyikébe illik:
- Lokális változók értékadása kiszámítható típusokkal (numerikus, objektum, függvény, tömb, karakterlánc).
- Számokon végzett aritmetika.
- Objektumtípusú lokális változókon ÉS
this-n végzett tagolvasások (this.v,obj.x). - Objektumtípusú lokális változókon végzett metódushívások (
obj.method(args)), ahol a fogadó$thisValue-ként kerül továbbításra. new Ctor(args)konstruktorhívások — az értékadás bal oldalán lévő lokális változó típusátobject-ra emelik, és visszaesésellenőrzéssel kezelik a nem objektum visszatérési értéket.- Egyszerű feltételmintákat használó
for/whileciklusok. - Számítható kifejezéssel rendelkező
return. if/elsetipizált feltételek felett.
A fordítás sikertelen (a függvény a fastruktúra-bejárón marad), ha a törzs a következőket tartalmazza:
withutasítások.try / catch / finally(a helyes befejezési szemantika érdekében a fastruktúra-bejáró kezeli).eval()hívások.- Közvetlen
argumentsmódosítás. - Bármi, amit a fordító még nem tud lefordítani.
Sikeres fordításkor a motor a függvény hívási útvonalát a lefordított lezárásra cseréli. A további hívások natív PHP kódot futtatnak, hívásonként egyetlen függvényhívási többletköltséggel, nem pedig AST-csomópontonként.
Visszaesések
A lefordított gyors útvonal feltételezésekkel él (a számok számok maradnak, a tulajdonságok alakja nem változik). Ha futásidőben valamelyik feltételezés sérül, a lezárás Bailout hibát dob, és az adott hívás a fastruktúra-bejáróban indul újra.
Itt a legfontosabb a specifikációnak való megfelelés. A fastruktúra-bejárónak képesnek kell lennie az egész visszaeső hívást elölről végrehajtani, anélkül hogy a sikertelen gyors útvonal mellékhatásai megfigyelhetők lennének. A Phasis ezt kétféleképpen kezeli:
- Mellékhatásmentes előtagok — a fordító minden spekuláció előtt megkeresi a mellékhatásokat. Ha a függvény a spekuláció előtt I/O-t végez (console.log, gazdafüggvények), a gyors útvonal nem hagyja ki: véglegesíti, majd folytatja a végrehajtást.
- Fordításkori elutasítás — azoknál a mintáknál, amelyeknél egy megfigyelhető mellékhatás visszagörgetése kellene (nem numerikus
let r = fn(), amelynél afn()módosítja az állapotot), a fordító teljesen elutasítja a fordítást. A függvény a fastruktúra-bejárón marad.
A motiváló eset: egy nem numerikus értékadás végrehajtja a fn() kódot a gyors útvonalon, Bailout hibát dob, majd újra végrehajtódik a fastruktúra-bejáróban — így a fn() kétszer fut le, két készletnyi mellékhatással. A fordításkori elutasítás megakadályozza, hogy a fn() egyáltalán lefusson a gyors útvonalon, így a fastruktúra-bejáró az egyetlen hívást látja.
VM-diszpécselés (a második réteg)
Egyes függvények PHP lezárásokká fordulnak; mások az src/Bytecode/VM.php még egyszerűbb bájtkód-értelmezőjéhez kerülnek. A bájtkód egy lapos opcode-tömb (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), amelyet egy switch diszpécselés dolgoz fel.
A VM az olyan függvényekhez kell, amelyeket nem érdemes PHP lezárássá fordítani, de elég gyakran futnak ahhoz, hogy megérje kihagyni az AST bejárását. A beépített objektumokon végzett metódushívások (Array.prototype.map, String.prototype.split, …) a VM gyors útvonalain haladnak át, megkerülve az általános callFunction közvetítőt.
Egyedi hívásiverem-kezelés
A Op::CALL, Op::CALL_METHOD és Op::NEW_CALL inline hívási útvonalat használ; ez a teljes JS hívási vermet a VM::execute-n belül tartja — minden JS híváshoz nem jön létre új PHP keret. Belépéskor a hívó állapota (pc, kódfolyam, operandusverem, lokális változók, env, this, szigorú mód jelzője) egy VM-enkénti, SavedFrame példányokból álló savedFrames készletbe kerül, a diszpécselési ciklus pedig átállítja a lokális változóit a hívott függvényre. A Op::RET kiveszi és visszaállítja az elmentett keretet. A kivételek a mentett kereteken keresztül bontják le a hívási vermet, mielőtt a végrehajtás visszatér a fastruktúra-bejáróhoz.
A jogosultság JsFunction-n van eltárolva: vmInlineCallCache normál hívásoknál (BC-fordított törzs, nincs bind()-hoz kötve, nem natív), vmInlineConstructCache pedig a new kifejezéseknél (továbbá isConstructable, nem származtatott, nincsenek példánymező- vagy privátmetódus-inicializálók). Minden más a kanonikus callFunction / vmNewExpression közvetítőn halad tovább.
Ennek a felépítésnek az előnye, hogy a PHP-verem korlátja többé nem szab határt a JS rekurzió mélységének. Most már csak a CallStack::maxDepth szab korlátot — az értéke 4 096, kényelmesen minden szabályos test262 eset fölött van (a legtöbbhöz 2 k-nál kevesebb keret kell), ugyanakkor elég alacsony ahhoz, hogy a kóros végtelen rekurziós tesztek gyorsan meghiúsuljanak a futtató időkorlátján belül. A transzpilált lezárások útvonalának (JsToPhp) saját, 8 192 keretes korlátja van a nem inline végrehajtású visszaeséshez.
Keretpillanatképes generátorok
Az egyszerű generátortörzsek — yield* nélkül, try/catch/finally blokkon belüli yield nélkül, await nélkül — bájtkódra fordulnak. A fordító új Op::YIELD opcode-ot bocsát ki ott, ahol a törzs felfüggeszti a végrehajtást; a Op::YIELD a diszpécselés állapotát (cf, pc+1, verem, sp, lokális változók, env, thisValue, strict) egy GeneratorSnapshot halomobjektumba menti, majd VM::execute-ből egy YieldResult jelzőértéket ad vissza. A vezérlő JsGenerator eltárolja a pillanatképet, és a következő next() / throw() során visszaadja a VM::execute-nek, lecserélve az operandusverem tetején lévő folytatási értéket.
Ez felváltja a támogatott részhalmaznál a PHP-Fiber útvonalat. A Fiber-alapú vezérlő (executeGeneratorBody) továbbra is megmarad azokhoz a mintákhoz, amelyeket a fordító elutasít — yield*-delegálás, try-blokkon belüli yieldet tartalmazó generátorok, aszinkron generátorok, valamint await-t tartalmazó aszinkron függvények. A JsGenerator kétmódú: minden next() / throw() / return() ellenőrzi, hogy a példány pillanatképet vagy Fiber-t tartalmaz-e, majd ennek megfelelően indítja el a diszpécselést.
Nincs a méréshez hangolható beállítás; a jogosultság fordításkor dől el, majd JsFunction-n van eltárolva. A pillanatképes útvonal előnye, hogy yieldenként eggyel kevesebb PHP kontextusváltás történik; minden másnál a Fiber-útvonal változatlan.
Inline gyorsítótárak
A Op::LOAD_MEMBER minden PC-hez prototípusazonosság alapján kulcsolt inline gyorsítótárat tart fenn, amelyet a prototípusmetódus-példányon minta használ (c.sum(...), arr.push(...), minden osztálymetódus-hívás). Találati feltétel: a fogadónak nincs saját mezője az adott névhez, a gyorsítótárazott prototípust ugyanaz a hivatkozás azonosítja, mint a fogadó getPrototype() értékét, és a prototípus PropertyMap::$version értéke továbbra is megegyezik az eltárolt verzióval.
A gyorsítótár érvénytelenítése a PropertyMap::$version értékén alapul. A strukturális módosítások (set / delete) feltétel nélkül növelik. Az adatmezők értékének felülírása csak akkor növeli, ha PropertyMap::$isUsedAsProto igaz — ez egy írási korlátot jelző flag, amely akkor vált át, amikor az objektumot először egy másik objektum [[Prototype]] értékeként észlelik. Azok a példányobjektumok, amelyek soha nem szolgálnak prototípusként, minden obj.x = v esetén megússzák az IC-infrastruktúra költségét.
A getter hozzáférők soha nincsenek gyorsítótárazva (minden olvasáskor újra le kell futniuk).
A PropertyMap egy második számlálót is fenntart, a $mutationVersion értékét, amely minden íráskor feltétel nélkül nő (írási korlát nélkül). A JSON.stringify gyorsítótár minden bejárt beágyazott objektumhoz/tömbhöz rögzíti ezt a számlálót, így egy csak példányt érintő módosítás, például a o.a = 99, helyesen érvényteleníti a gyorsítótárazott karakterláncot akkor is, ha az IC $version értéke kihagyta volna a növelést.
JSON.stringify gyorsítótár
A JsObject tárolja a $jsonCacheString + $jsonCacheVersions értékét (az összes bejárt beágyazott objektumhoz tartozó [obj, mutationVersion] párok listáját a triviális karakterlánccá alakítás során). A következő JSON.stringify(o) híváskor, ha nincs replacer / space, a triviális gyorsút ellenőrzi, hogy minden rögzített verzió változatlan-e, majd újrajárás és újrakódolás nélkül visszaadja a gyorsítótárazott karakterláncot. Az olyan gyakori mélymásolási minták, mint a JSON.parse(JSON.stringify(o)), nagyjából kétszeres gyorsulást érnek el a stringify fázisban.
Találat hiányakor (ha bármelyik rögzített verzió eltér) a gyorsítótár csendben törlődik, és a következő bejárás újratölti. A toJSON értékkel, getterekkel, hozzáférőkkel, szimbólumokkal vagy [[PrimitiveValue]] mezőkkel rendelkező objektumok visszatérnek a specifikáció szerinti szerializálóhoz, és soha nem töltik fel a gyorsítótárat — így a gyorsítótárazott útvonal csak szerkezetileg egységes adatokat lát.
Beépített gyorsutak
A legnagyobb teljesítménynövekedést az hozta, hogy a VM-et konkrét beépített elemekre optimalizáltuk:
Atomics.load/store/compareExchange,loadSpinHook/storeNotifyHookegyüttműködő ütemezéssel — 4 Atomics spin-loop tesztet sikerült helyreállítani.Date.prototype.getTimezoneOffset, közvetlen hashmap-alapú DST-gyorsítótárral — 45%-os gyorsulás a SpiderMonkey DST-terhelési tesztben.String.fromCharCode, inline ASCII gyorsúttal — egy tipikus menetben körülbelül 1 millió hívást takarít meg.decodeURI/decodeURIComponent, 12 karakteres%XX%XX%XX%XXultragyors úttal, amelyhex2bintömeges dekódolást használ.Object.keys/Object.values/Object.entries, közvetlen tulajdonságtérkép-hozzáféréssel.Array.prototype.map/filter/forEach/reduce, a mögöttes PHP tömb natív bejárásával.
Minden gyorsutat „alakellenőrzés” véd — ha a fogadó nem a várt alakú (e.g. egy olyan TypedArray-nézet, amelyet nem egy egyszerű PHP string támaszt alá), a VM a specifikációnak megfelelő lassú útra tér át.
Mire nem szolgál
A bytecode VM a V8 értelmében nem JIT. Nem generál natív gépi kódot függvényenként, és az IC monomorf prototípusazonosság-ellenőrzésén túl nem következtet a megfigyelt típusok alapján. A függvényhatárokon átívelő inline-olást az egyedi hívásverem közelíti meg (minden JS híváskor nincs plusz PHP keret), nem pedig a hívott függvény törzsének beemelése a hívó bytecode-jába.
A JsToPhp transzpiler áll a legközelebb a motor JIT-jéhez: PHP forráskódot generál a gyakran futó függvénytörzsekhez, amelyet a PHP 8.5 nyomkövető JIT-je natív kódra fordít. A JsToPhp a eval() útján működik, ezért egyszeri fordítási költséggel jár, a nyomkövető JIT-nek pedig úgy kell felfedeznie a függvényt, mintha felhasználói kód lenne. A eval() lépés kihagyása közvetlen opcache-opkódok kibocsátásával tovább csökkentené a különbséget — lásd: Benchmarks § Roadmap.
Benchmarkok
A bench/microbench.js tucatnyi apró benchmarkot futtat (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án futásidő:
php bench/run.php
Az aktuális eredményeket minden bench munkafolyamat-futás után rögzítjük a BENCH.md fájlban. A bench és test262 mátrixmunkafolyamatokat kézzel indítják el.