Navidezni stroj z bajtno kodo
Privzeta pot izvajanja je sprehajalnik dreves: obišče vsako vozlišče AST-ja in vrne vrednost. Pri vročih funkcijah, ki se pokličejo milijonkrat, je ozko grlo režija dispečiranja za posamezno vozlišče. Phasis temu doda špekulativni prevajalnik iz bajtne kode v zaprtje PHP, da zmanjša to režijo.
Kdaj se aktivira navidezni stroj
Vsaka funkcija JsFunction ima števec vročih klicev. Ko preseže prag (privzeto 100), pogon poskusi telo funkcije prek src/Bytecode/JsToPhp.php prevesti v zaprtje PHP.
Prevajanje uspe, kadar se telo funkcije ujema z znanimi oblikami:
- Dodelitve lokalnim spremenljivkam s predvidljivimi vrstami (številska, objektna, funkcijska, polje, niz).
- Aritmetične operacije nad števili.
- Branje članov iz lokalnih spremenljivk objektnega tipa IN iz
this(this.v,obj.x). - Klici metod na lokalnih spremenljivkah objektnega tipa (
obj.method(args)), pri čemer se prejemnik posreduje kot$thisValue. - Klici konstruktorja
new Ctor(args)— nadgradi tip lokalne spremenljivke LHS vobjectin z varovalom Bailout preveri, ali je vrnjena vrednost objekt. - Zanke
for/whiles preprostimi oblikami pogojev. returnz izrazom, ki ga je mogoče izračunati.if/elsenad pogoji z določenim tipom.
Prevajanje ne uspe (funkcija ostane v sprehajalniku dreves), če telo vsebuje:
- Stavke
with. try / catch / finally(obravnava jih sprehajalnik dreves, da zagotovi pravilno semantiko dokončanja).- Klice
eval(). - Neposredno spreminjanje
arguments. - Karkoli, česar prevajalnik še ne zna prevesti v nižjenivojsko obliko.
Ko prevajanje uspe, pogon preusmeri pot klicev funkcije na prevedeno zaprtje. Nadaljnji klici izvedejo izvorno PHP z režijo enega klica funkcije, namesto režije za vsako vozlišče AST-ja.
Izhodi iz optimizirane poti
Prevedena hitra pot temelji na predpostavkah (števila ostanejo števila, lastnosti ostanejo na isti obliki). Ko je predpostavka med izvajanjem kršena, zaprtje sproži Bailout, izvajanje pa se za ta klic znova začne v sprehajalniku dreves.
Tu je pravilnost glede na specifikacijo najpomembnejša. Spet mora biti mogoče klic, ki je sprožil izhod, od začetka izvesti v sprehajalniku dreves, ne da bi ta zaznal stranske učinke neuspešne hitre poti. Phasis to zagotavlja na dva načina:
- Čiste predpone — prevajalnik pred kakršnim koli špekuliranjem poišče stranske učinke. Če funkcija izvede V/I (console.log, funkcije gostitelja) pred točko špekuliranja, hitra pot tega ne izpusti, temveč se učinek zapiše in izvajanje nadaljuje.
- Zavrnitev med prevajanjem — pri oblikah, ki bi zahtevale povrnitev opaznih stranskih učinkov (neneštevilski
let r = fn(), pri kateremfn()spreminja stanje), prevajalnik prevajanje v celoti zavrne. Funkcija ostane v sprehajalniku dreves.
Primer, ki ponazarja težavo: neneštevilska dodelitev na hitri poti izvede fn(), sproži Bailout in se nato znova izvede v sprehajalniku dreves — s tem se fn() izvede dvakrat, vsakič z novimi stranskimi učinki. Zavrnitev med prevajanjem prepreči, da bi se fn() sploh izvedel na hitri poti, zato sprehajalnik dreves zazna edini klic.
Dispečiranje v navideznem stroju (drugi sloj)
Nekatere funkcije se prevedejo v zaprtja PHP, druge pa preidejo v še preprostejši tolmač bajtne kode v src/Bytecode/VM.php. Bajtna koda je ravno polje opkod (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), ki jih obdeluje dispečiranje switch.
Navidezni stroj je namenjen funkcijam, ki se jih ne splača prevajati v zaprtje PHP, vendar se izvajajo dovolj pogosto, da jim koristi preskok sprehoda po AST-ju. Klici metod na vgrajenih objektih (Array.prototype.map, String.prototype.split, …) uporabljajo hitre poti v navideznem stroju, ki zaobidejo generični posrednik callFunction.
Sklad klicev po meri
Op::CALL, Op::CALL_METHOD in Op::NEW_CALL uporabljajo vgrajeno pot klicev, ki ohranja celoten sklad klicev JS znotraj VM::execute — za vsak klic JS ni novega okvira PHP. Ob vstopu se stanje klicatelja (pc, tok kode, sklad operandov, lokalne spremenljivke, env, this, zastavica načina strict) shrani v področje savedFrames za posamezni navidezni stroj, sestavljeno iz primerkov SavedFrame, nato pa zanka dispečiranja preveže svoje lokalne spremenljivke na klicano funkcijo. Op::RET odstrani shranjeni okvir s sklada in obnovi stanje. Izjeme se odvijejo prek shranjenih okvirov, preden se izvajanje vrne v sprehajalnik dreves.
Upravičenost se predpomni na JsFunction: vmInlineCallCache za običajne klice (telo, prevedeno v bajtno kodo; ni vezano z bind() in ni izvorno), vmInlineConstructCache za izraze new (dodatno isConstructable, ne izpeljan, brez inicializatorjev polj primerka ali zasebnih metod). Vse drugo še vedno poteka prek kanoničnega posrednika callFunction / vmNewExpression.
Arhitekturna prednost: omejitev globine sklada PHP ne omejuje več globine rekurzije JS. Zdaj velja samo še omejitev CallStack::maxDepth, nastavljena na 4 096; to je precej več od vsake legitimne oblike test262 (večina jih potrebuje manj kot 2 k okvirov) in hkrati dovolj malo, da patološki testi neskončne rekurzije hitro spodletijo znotraj časovne omejitve izvajalnika. Pot prevedenih zaprtij (JsToPhp) ima za nadomestno pot, ki ni vgrajena, lastno omejitev 8 okvirov 192.
Generatorji s posnetki okvirov
Preprosta telesa generatorjev — brez yield*, brez yield znotraj try/catch/finally in brez await — se prevedejo v bajtno kodo. Prevajalnik na mesto prekinitve telesa vstavi novo opkodo Op::YIELD; Op::YIELD zajame stanje dispečiranja (cf, pc+1, sklad, sp, lokalne spremenljivke, env, thisValue, strict) v objekt GeneratorSnapshot na kopici in vrne označevalno vrednost YieldResult iz VM::execute. Pogonski JsGenerator shrani posnetek in ga ob naslednjem next() / throw() znova posreduje v VM::execute, pri čemer nadomesti vrednost za nadaljevanje na vrhu sklada operandov.
To za podprto podmnožico nadomesti pot prek vlakna PHP. Pogonski mehanizem, ki temelji na vlaknih (executeGeneratorBody), ostaja za oblike, ki jih prevajalnik zavrne — delegiranje yield*, generatorje s prekinitvijo znotraj bloka try, asinhrone generatorje in asinhrone funkcije z await. JsGenerator podpira oba načina: vsak next() / throw() / return() preveri, ali ima primerek posnetek ali vlakno, in ustrezno izvede dispečiranje.
Nastavitev, ki bi jo bilo mogoče prilagajati s primerjalnimi testi, ni; upravičenost se določi med prevajanjem in predpomni na JsFunction. Prednost poti s posnetki je eno preklapljanje konteksta PHP manj pri vsaki prekinitvi; pot z vlakni za vse drugo ostane nespremenjena.
Vgrajeni predpomnilniki
Op::LOAD_MEMBER ima vgrajeni predpomnilnik za vsak PC, ključan po identiteti prototipa; uporablja se za vzorec metode prototipa na primerku (c.sum(...), arr.push(...), vsako dispečiranje metode razreda). Pogoji zadetka: prejemnik nima lastnega mesta za to ime, predpomnjena referenca prototipa je enaka getPrototype() prejemnika, različica PropertyMap::$version prototipa pa se še ujema z zajeto različico.
Razveljavljanje predpomnilnika temelji na PropertyMap::$version. Strukturne spremembe (nastavitev ali izbris) ga vedno povečajo. Različica se pri prepisu vrednosti podatkovnega mesta poveča le, če velja PropertyMap::$isUsedAsProto — zastavica za zaščito pred pisanjem, ki se prvič vklopi, ko je objekt opažen kot [[Prototype]] drugega objekta. Primerki objektov, ki nikoli niso prototipi, pri vsakem obj.x = v ne plačajo režije infrastrukture IC.
Dostop do pridobivalnikov se nikoli ne predpomni (ob vsakem branju jih je treba znova izvesti).
PropertyMap ima drugi števec, $mutationVersion, ki se vedno poveča pri vsakem pisanju (brez zaščite pred pisanjem). Predpomnilnik JSON.stringify shrani ta števec za vsak obiskani ugnezdeni objekt ali polje, tako da sprememba, ki vpliva samo na primerek, kot je o.a = 99, pravilno razveljavi predpomnjeni niz, tudi kadar bi IC zaradi $version preskočil povečanje števca.
Predpomnilnik JSON.stringify
JsObject vsebuje $jsonCacheString + $jsonCacheVersions (seznam parov [obj, mutationVersion] za vsak ugnezdeni objekt, obiskan med preprostim sprehodom za pretvorbo v niz). Ob naslednjem klicu JSON.stringify(o) brez zamenjevalnika ali presledkov pred izhodno kodo hitra pot preveri, ali so vse shranjene različice nespremenjene, in vrne predpomnjeni niz brez ponovnega sprehoda ali kodiranja. Pogosti vzorci globokega kloniranja, kot je JSON.parse(JSON.stringify(o)), pospešijo del pretvorbe v niz za približno 2×.
Če pride do zgrešitve (shranjena različica se razlikuje), se predpomnilnik tiho zavrže in znova napolni ob naslednjem sprehodu. Objekti z toJSON, pridobivalniki, dostopniki, simboli ali mesti [[PrimitiveValue]] preidejo na serializator, skladen s specifikacijo, in ne napolnijo predpomnilnika, zato predpomnjena pot obravnava le strukturno enotne podatke.
Hitre poti za vgrajene elemente
Največje pohitritve so nastale, ko smo navidezni stroj naučili obravnavati določene vgrajene elemente:
Atomics.load/store/compareExchanges sodelovalnim razporejanjemloadSpinHook/storeNotifyHook— znova delujejo 4 testi spin-zank Atomics.Date.prototype.getTimezoneOffsetz neposrednim predpomnilnikom zgoščevalne tabele za DST — 45-odstotno pohitritev pri obremenitvenem testu DST SpiderMonkey.String.fromCharCodez vgrajeno hitro potjo za ASCII — pri običajnem pregledu prihrani približno 1 M dispečiranj.decodeURI/decodeURIComponentz izjemno hitro potjo za%XX%XX%XX%XXz 12 znaki, ki uporablja množično dekodiranjehex2bin.Object.keys/Object.values/Object.entriesz neposrednim dostopom do preslikave lastnosti.Array.prototype.map/filter/forEach/reducez izvornim ponavljanjem po osnovnem polju PHP.
Vsako hitro pot varuje »preverjanje oblike« — če se prejemnik ne ujema s pričakovano obliko (e.g, pogled TypedArray, ki ne temelji na navadnem PHP string), se izvajanje v navideznem stroju nadaljuje po počasni poti, skladni s specifikacijo.
Česa to ne počne
Navidezni stroj z bajtno kodo v smislu V8 ni JIT. Ne ustvarja izvorne strojne kode za vsako funkcijo in ne špekulira na podlagi opaženih tipov, razen preverjanja enomodne identitete prototipa v IC. Vgrajevanje na mejah funkcij približno dosega sklad klicev po meri (brez dodatnega okvira PHP za vsak klic JS), namesto da bi telo klicane funkcije prevedel v bajtno kodo klicatelja.
Transpiler JsToPhp je motorju najbližje JIT-u: ustvarja izvorno kodo PHP za vroča telesa funkcij, ki jo sledilni JIT v PHP 8.5 nato prevede v izvorno strojno kodo. JsToPhp deluje prek eval(), zato zahteva enkratni strošek prevajanja, sledilni JIT pa mora funkcijo odkriti, kot da bi šlo za uporabniško kodo. Preskok koraka eval() z neposrednim ustvarjanjem opkod opcache bi zmanjšal vrzel — glejte Primerjalni testi § Načrt razvoja.
Primerjalni testi
bench/microbench.js izvede malo več kot ducat drobnih primerjalnih testov (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). Srednji stenski čas:
php bench/run.php
Trenutne številke se po vsakem zagonu delovnega postopka bench zapišejo v BENCH.md. Delovna postopka za matriko bench in test262 se oba zaženeta ročno.