Preskočiť na obsah

VM s bajtkódom

Predvolenou cestou vykonávania je stromový interpreter — prejde každý uzol AST a vráti hodnotu. Pri horúcich funkciách, ktoré sa volajú miliónkrát, je úzkym hrdlom réžia dispečingu každého uzla. Phasis na to pridáva vrstvu špekulatívneho kompilátora z bajtkódu do uzáverov PHP, ktorý túto réžiu znižuje.

Kedy sa aktivuje VM

Každá funkcia JsFunction má počítadlo horúcich volaní. Keď prekročí prah (predvolene 100), engine sa pokúsi skompilovať telo funkcie do uzáveru PHP pomocou src/Bytecode/JsToPhp.php.

Kompilácia uspeje, ak telo funkcie zodpovedá známej množine tvarov:

  • Priradenia lokálnym premenným s predvídateľnými typmi (číslo, objekt, funkcia, pole, reťazec).
  • Aritmetické operácie s číslami.
  • Čítanie členov v lokálnych premenných objektového typu A AJ v this (this.v, obj.x).
  • Volania metód nad lokálnymi premennými objektového typu (obj.method(args)), pri ktorých sa prijímajúci objekt odovzdáva ako $thisValue.
  • Volania konštruktora new Ctor(args) — povýšia typ lokálnej premennej na ľavej strane na object a kontrolou ústupu ošetria návratovú hodnotu, ktorá nie je objektom.
  • Cykly for / while s jednoduchými podmienkami.
  • return s vypočítateľným výrazom.
  • if / else s podmienkami s typovanými hodnotami.

Kompilácia zlyhá (a funkcia zostane v stromovom interpreteri), ak telo obsahuje:

  • Príkazy with.
  • try / catch / finally (stromový interpreter ich spracúva, aby zachoval správnu sémantiku dokončenia).
  • Volania eval().
  • Priamu mutáciu arguments.
  • Všetko, čo kompilátor zatiaľ nevie preložiť.

Po úspešnej kompilácii engine presmeruje cestu volania funkcie na skompilovaný uzáver. Nasledujúce volania vykonávajú natívny kód PHP s réžiou jedného volania funkcie, nie s réžiou každého uzla AST.

Ústup

Skompilovaná rýchla cesta predpokladá určité vlastnosti (čísla zostávajú číslami, vlastnosti zostávajú na rovnakom tvare objektu). Ak sa niektorý predpoklad za behu poruší, uzáver vyhodí Bailout a dané volanie sa znova spustí v stromovom interpreteri.

Práve tu je najdôležitejšia správnosť podľa špecifikácie. Stromový interpreter musí vedieť znova vykonať volanie, pri ktorom nastal ústup, od začiatku a bez toho, aby zaznamenal vedľajšie účinky neúspešnej rýchlej cesty. Phasis to rieši dvoma spôsobmi:

  1. Čisté prefixy — kompilátor kontroluje vedľajšie účinky pred akoukoľvek špekuláciou. Ak funkcia vykonáva I/O (console.log, hostiteľské funkcie) pred bodom špekulácie, rýchla cesta ho nevynechá; potvrdí ho a pokračuje.
  2. Odmietnutie pri kompilácii — pri tvaroch, ktoré by vyžadovali vrátenie pozorovateľných vedľajších účinkov (nenumerický príkaz let r = fn(), pri ktorom fn() mení stav), kompilátor odmietne kompiláciu. Funkcia zostane v stromovom interpreteri.

Motivačný príklad: nenumerické priradenie vykoná fn() na rýchlej ceste, vyhodí Bailout a potom sa znova vykoná v stromovom interpreteri — takže fn() sa spustí dvakrát a spôsobí dve sady vedľajších účinkov. Odmietnutie kompilácie zabráni spusteniu fn() na rýchlej ceste, takže stromový interpreter pozoruje jediné volanie.

Dispečing VM (druhá vrstva)

Niektoré funkcie sa kompilujú do uzáverov PHP; ostatné prechádzajú do ešte jednoduchšieho interpretera bajtkódu v src/Bytecode/VM.php. Bajtkód tvorí ploché pole opkódov (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), ktoré spracúva switch dispečing.

VM slúži funkciám, ktoré sa neoplatí kompilovať do uzáveru PHP, no sú dosť horúce na to, aby im prospelo vynechanie prechodu cez AST. Volania metód vstavaných objektov (Array.prototype.map, String.prototype.split, …) využívajú rýchle cesty vo VM, ktoré obchádzajú všeobecný mostík callFunction.

Vlastný zásobník volaní

Op::CALL, Op::CALL_METHOD a Op::NEW_CALL používajú cestu inline volaní, ktorá udržiava celý zásobník volaní JS v VM::execute — pri každom volaní JS sa nevytvára nový rámec PHP. Pri vstupe sa stav volajúceho (pc, prúd kódu, zásobník operandov, lokálne premenné, env, this, príznak prísneho režimu) uloží do fondu inštancií SavedFrame v rámci každého VM savedFrames a cyklus dispečingu nastaví svoje lokálne premenné na hodnoty volanej funkcie. Op::RET vyberie uložený rámec a obnoví stav. Výnimky sa postupne šíria cez uložené rámce a potom sa spracujú v stromovom interpreteri.

Oprávnenosť sa zapamätá na JsFunction: vmInlineCallCache pre bežné volania (telo skompilované do BC, nie je viazané pomocou bind() ani natívne), vmInlineConstructCache pre výrazy new (navyše isConstructable, nie je odvodený, bez inicializátorov polí inštancie a súkromných metód). Všetko ostatné naďalej prechádza cez kanonický mostík callFunction / vmNewExpression.

Vedľajší architektonický prínos: limit zásobníka PHP už neobmedzuje hĺbku rekurzie JS. Jediným limitom je teraz CallStack::maxDepth — nastavený na 4 096, čo je s pohodlnou rezervou nad každým bežným prípadom test262 (väčšina potrebuje menej než 2 k rámcov) a zároveň dosť nízko na to, aby sa patologické testy nekonečnej rekurzie rýchlo ukončili v časovom limite spúšťača. Cesta transpilovaného uzáveru (JsToPhp) má vlastný limit 8 rámcov 192 pre náhradnú cestu bez inline volania.

Generátory so snímkou rámca

Jednoduché telá generátorov — bez yield*, bez yield vnútri try/catch/finally a bez await — sa kompilujú do bajtkódu. Kompilátor pri pozastavení tela vytvorí nový opkód Op::YIELD; Op::YIELD zachytí stav dispečingu (cf, pc+1, zásobník, sp, lokálne premenné, env, thisValue, strict) do objektu na halde typu GeneratorSnapshot a vráti sentinel YieldResult z VM::execute. Riadiaci JsGenerator uloží snímku a pri ďalšom next() / throw() ju odovzdá späť do VM::execute, pričom nahradí návratovú hodnotu na vrchu zásobníka operandov.

Pre podporovanú podmnožinu to nahrádza cestu PHP-Fiber. Ovládač založený na Fiber (executeGeneratorBody) sa naďalej používa pre tvary, ktoré kompilátor odmietne — delegovanie yield*, generátory s yield vo vnútri bloku try, asynchrónne generátory a asynchrónne funkcie s await. JsGenerator podporuje oba režimy: pri každom next() / throw() / return() sa kontroluje, či má inštancia snímku alebo Fiber, a podľa toho sa zvolí spôsob dispečingu.

Nastavenie sa nedá ladiť pomocou benchmarkov; oprávnenosť sa určuje pri kompilácii a zapamätá sa na JsFunction. Zlepšenie spočíva v jednom menšom prepnutí kontextu PHP na každý yield v ceste so snímkou; cesta Fiber zostáva pre všetko ostatné nezmenená.

Inline cache

Op::LOAD_MEMBER obsahuje inline cache pre každé PC, ktorej kľúčom je identita prototypu; používa sa pri vzore metódy prototypu na inštancii (c.sum(...), arr.push(...), každé volanie metódy triedy). Podmienka zásahu: prijímajúci objekt nemá vlastnú položku s daným názvom, referencia na objekt prototypu uložená v cache je totožná s objektom getPrototype() prijímajúceho objektu a hodnota PropertyMap::$version prototypu sa stále zhoduje so zachytenou verziou.

Invalidácia cache využíva PropertyMap::$version. Štrukturálne mutácie (nastavenie / odstránenie) ho vždy zvýšia. Hodnoty dátových položiek sa zvýšia iba vtedy, keď PropertyMap::$isUsedAsProto platí — príznak bariéry zápisu sa nastaví pri prvom zistení, že objekt slúži ako [[Prototype]] iného objektu. Inštančné objekty, ktoré nikdy neslúžia ako prototypy, neplatia za infraštruktúru IC pri každom obj.x = v.

Prístupové metódy getter sa nikdy neukladajú do cache (pri každom čítaní sa musia spustiť znova).

PropertyMap obsahuje druhé počítadlo $mutationVersion, ktoré sa zvýši vždy pri každom zápise (bez bariéry zápisu). Cache JSON.stringify si zaznamenáva toto počítadlo pri každom navštívenom vnorenom objekte alebo poli, takže mutácia obmedzená na inštanciu, ako napríklad o.a = 99, správne zneplatní uložený reťazec aj vtedy, keď by sa pri IC kvôli $version počítadlo nezvýšilo.

Cache JSON.stringify

JsObject obsahuje $jsonCacheString + $jsonCacheVersions (zoznam dvojíc [obj, mutationVersion] pre každý vnorený objekt navštívený pri priechode na vytvorenie jednoduchého reťazca). Pri ďalšom volaní JSON.stringify(o) bez náhrady a medzier rýchla cesta pre jednoduchý prípad overí, či sa nezmenila žiadna zaznamenaná verzia, a vráti uložený reťazec bez opätovného prechádzania alebo kódovania. Vzorové operácie na hlboké klonovanie, ako napríklad JSON.parse(JSON.stringify(o)), zrýchľujú časť stringify približne 2×.

Pri neúspešnom overení (keď sa líši niektorá zaznamenaná verzia) sa cache potichu zahodí a naplní pri ďalšom priechode. Objekty s toJSON, getrami, prístupovými metódami, symbolmi alebo položkami [[PrimitiveValue]] prejdú do serializátora podľa špecifikácie a nikdy nenaplnia cache — uložená cesta preto spracúva iba štrukturálne jednotné údaje.

Rýchle cesty vstavaných funkcií

Najväčšie zrýchlenia prišli po doplnení podpory konkrétnych vstavaných funkcií do VM:

  • Atomics.load / store / compareExchange s kooperatívnym plánovaním loadSpinHook / storeNotifyHook — obnovili sa 4 testy spin-loop Atomics.
  • Date.prototype.getTimezoneOffset s DST cache založenou priamo na hashmap — zrýchlenie o 45 % pri záťažových testoch DST SpiderMonkey.
  • String.fromCharCode s rýchlou cestou pre ASCII — pri typickom prechode sa ušetrí približne 1 milión dispečingov.
  • decodeURI / decodeURIComponent s mimoriadne rýchlou cestou %XX%XX%XX%XX pre 12-znakové hodnoty, ktorá využíva hromadné dekódovanie hex2bin.
  • Object.keys / Object.values / Object.entries s priamym prístupom k mape vlastností.
  • Array.prototype.map / filter / forEach / reduce s natívnou iteráciou nad podkladovým poľom PHP.

Každá rýchla cesta je podmienená „kontrolou tvaru“ — ak prijímajúci objekt nemá očakávaný tvar (e.g. pohľad TypedArray, ktorý nie je podložený obyčajným PHP string), VM prejde na pomalú cestu, ktorá správne dodržiava špecifikáciu.

Čo táto funkcia nerobí

VM s bajtkódom nie je JIT v zmysle V8. Negeneruje natívny strojový kód pre každú funkciu a nešpekuluje na základe pozorovaných typov nad rámec monomorfnej kontroly identity prototypu v IC. Vkladanie na hranici funkcií približuje vlastný zásobník volaní (bez ďalšieho rámca PHP pri každom volaní JS), nie preklad tela volanej funkcie do bajtkódu volajúcej funkcie.

Transpilátor JsToPhp sa najviac približuje JIT kompilátoru: generuje zdrojový kód PHP pre telá horúcich funkcií, ktoré trasovací JIT nástroja PHP 8.5 následne skompiluje do natívneho kódu. JsToPhp používa eval(), preto sa platí jednorazová cena za kompiláciu a trasovací JIT musí funkciu objaviť, akoby išlo o používateľský kód. Vynechaním kroku eval() a priamym generovaním opkódov opcache by sa rozdiel ešte zmenšil — pozrite si Benchmarky § Plán vývoja.

Benchmark

bench/microbench.js spúšťa vyše tucta drobných benchmarkov (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 času behu:

Shell
php bench/run.php

Aktuálne výsledky sa po každom spustení workflow bench ukladajú do BENCH.md. Matice workflow bench a test262 sa obe spúšťajú manuálne.