Ugrás a tartalomhoz

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át object-ra emelik, és visszaesésellenőrzéssel kezelik a nem objektum visszatérési értéket.
  • Egyszerű feltételmintákat használó for / while ciklusok.
  • Számítható kifejezéssel rendelkező return.
  • if / else tipizá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:

  • with utasítások.
  • try / catch / finally (a helyes befejezési szemantika érdekében a fastruktúra-bejáró kezeli).
  • eval() hívások.
  • Közvetlen arguments mó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:

  1. 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.
  2. 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 a fn() 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 / storeNotifyHook együ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%XX ultragyors úttal, amely hex2bin tö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ő:

Shell
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.