Към съдържанието

Виртуална машина за байткод

Пътят за изпълнение по подразбиране е обхождащ дърво: посещава всеки AST възел и връща стойност. При горещи функции, извиквани милиони пъти, тесният участък е допълнителната цена на извикванията за всеки възел. Phasis добавя спекулативен компилатор от байткод до затваряне PHP, за да намали тази цена.

Кога се активира VM

Всяка JsFunction има брояч за горещи извиквания. Когато премине прага (по подразбиране 100), енджинът опитва да компилира тялото на функцията до затваряне PHP чрез src/Bytecode/JsToPhp.php.

Компилацията успява, когато тялото на функцията отговаря на определен набор от шаблони:

  • Присвоявания на локални променливи с предвидими типове (число, обект, функция, масив, низ).
  • Аритметични операции с числа.
  • Четене на членове от локални променливи с обектен тип И от this (this.v, obj.x).
  • Извиквания на методи на локални променливи с обектен тип (obj.method(args)), като получателят се предава като $thisValue.
  • Извиквания на конструктор new Ctor(args) — повишава типа на локалната променлива отляво до object и защитава срещу връщане на необектна стойност чрез Bailout.
  • Цикли for / while с прости шаблони за условия.
  • return с изчислим израз.
  • if / else върху типизирани условия.

Компилацията е неуспешна (и функцията остава при обхождащото дърво), когато тялото съдържа:

  • Изрази with.
  • try / catch / finally (обработва се от обхождащото дърво за правилна семантика на завършване).
  • Извиквания eval().
  • Директна промяна на arguments.
  • Всичко, което компилаторът все още не може да преобразува.

Когато компилацията успее, енджинът насочва пътя за извикване на функцията към компилираното затваряне. Последващите извиквания изпълняват собствен PHP с една допълнителна цена за извикване на функция, а не по една за всеки AST възел.

Откази

Компилираният бърз път прави допускания (че числата остават числа, а свойствата остават в същата форма). Когато някое допускане бъде нарушено по време на изпълнение, затварянето генерира Bailout и изпълнението се рестартира в обхождащото дърво за това извикване.

Тук коректността спрямо спецификацията е най-важна. Обхождащото дърво трябва да може да изпълни отново извикването, довело до отказ, отначало, без да наблюдава странични ефекти от неуспешния бърз път. Phasis се справя с това по два начина:

  1. Чисти префикси — компилаторът проверява за странични ефекти преди всяко спекулативно изпълнение. Ако функцията извърши входно-изходна операция (console.log, хост функции) преди точката на спекулация, бързият път не я пропуска, а я потвърждава и продължава.
  2. Отказ при компилация — при шаблони, които биха изисквали връщане назад на наблюдаеми странични ефекти (нечислова let r = fn(), при която fn() променя състоянието), компилаторът изобщо отказва да компилира. Функцията остава при обхождащото дърво.

Показателният случай: нечислово присвояване изпълнява fn() по бързия път, генерира Bailout, след което се изпълнява отново при обхождане на дървото — така fn() се изпълнява два пъти с два набора странични ефекти. Отказът при компилация не позволява на fn() изобщо да се изпълни по бързия път, така че обхождащото дърво наблюдава единственото извикване.

Изпълнение във VM (втори слой)

Някои функции се компилират до затваряния PHP; останалите преминават към още по-опростен интерпретатор на байткод в src/Bytecode/VM.php. Байткодът е плосък масив от опкодове (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), изпълнявани чрез switch за диспечиране.

VM е предназначена за функции, които не си струва да се компилират до затваряне PHP, но са достатъчно горещи, за да спечелят от пропускането на обхождането на AST. Извикванията на методи върху вградени обекти (Array.prototype.map, String.prototype.split, …) използват бързи пътища във VM, които заобикалят общия трамплин callFunction.

Персонализиран стек за извиквания

Op::CALL, Op::CALL_METHOD и Op::NEW_CALL използват път за вградени извиквания, който държи целия стек за извиквания JS в VM::execute — без нова рамка PHP при всяко извикване на JS. При влизане състоянието на извикващия (pc, поток от код, стек на операндите, локални променливи, env, this, флаг за строг режим) се записва в пул savedFrames за всеки VM, съдържащ екземпляри SavedFrame, а цикълът на изпълнение пренастройва локалните си променливи към извиквания обект. Op::RET изважда запазената рамка и възстановява състоянието. Изключенията се разгръщат през запазените рамки, преди изпълнението да се върне към обхождащото дърво.

Правото на използване се запомня в JsFunction: vmInlineCallCache за обикновени извиквания (тяло, компилирано до BC, не е обвързано с bind() и не е нативно), vmInlineConstructCache за изрази new (допълнително isConstructable, не е производен клас, няма инициализатори на полета на екземпляра или частни методи). Всичко останало продължава да минава през каноничния трамплин callFunction / vmNewExpression.

Допълнителна архитектурна полза: таванът на стека PHP вече не ограничава дълбочината на рекурсията JS. Единственото ограничение вече е CallStack::maxDepth — зададено на 4 096, значително над необходимото за всеки легитимен случай от test262 (повечето изискват под 2 хиляди рамки), но достатъчно ниско, за да се провалят патологичните тестове с безкрайна рекурсия в рамките на времето за изчакване на изпълнителя. Пътят на транспилираните затваряния (JsToPhp) има отделен предпазител от 8 рамки 192 за резервния вариант без вграждане.

Генератори със снимка на рамката

Телата на простите генератори — без yield*, без yield в try/catch/finally и без await — се компилират до байткод. Компилаторът генерира нов опкод Op::YIELD на мястото, където тялото се преустановява; Op::YIELD записва състоянието на изпълнение (cf, pc+1, стек, sp, локални променливи, env, thisValue, strict) в обект GeneratorSnapshot в купа памет и връща маркер YieldResult от VM::execute. Управляващият JsGenerator съхранява снимката и я подава обратно на VM::execute при следващото next() / throw(), като заменя стойността за възобновяване в горната част на стека на операндите.

Това заменя пътя през Fiber на PHP за поддържаното подмножество. Драйверът, базиран на Fiber (executeGeneratorBody), остава за шаблоните, които компилаторът отхвърля — делегиране на yield*, генератори, които изпълняват yield вътре в try блок, асинхронни генератори и асинхронни функции с await. JsGenerator поддържа два режима: всяко next() / throw() / return() проверява дали екземплярът има снимка или Fiber и избира съответния път за изпълнение.

Няма параметър за настройка на бенчмарка; допустимостта се решава по време на компилация и се запомня в JsFunction. Печалбата е с едно превключване на контекста PHP по-малко при всяко yield в пътя със снимка; пътят с Fiber остава непроменен за всички останали случаи.

Кешове за вграждане

Op::LOAD_MEMBER има вграден кеш за всеки PC, индексиран по идентичността на прототипа, който се използва при шаблона за метод на прототип върху инстанция (c.sum(...), arr.push(...), всяко извикване на метод на клас). Условие за попадение: получателят няма собствен слот за името, кешираната препратка към прототипа е идентична с тази на получателя getPrototype(), а PropertyMap::$version на прототипа все още съвпада със заснетата версия.

Невалидирането на кеша се основава на PropertyMap::$version. Структурните промени (задаване / изтриване) го увеличават безусловно. Стойностите на слотовете с данни се увеличават само когато PropertyMap::$isUsedAsProto е true — флаг за бариера при запис, който се превключва за първи път, когато обектът бъде наблюдаван като [[Prototype]] на друг обект. Инстанциите, които никога не служат като прототипи, не плащат нищо за инфраструктурата на IC при всяко obj.x = v.

Аксесорите getter никога не се кешират (трябва да се изпълняват отново при всяко четене).

PropertyMap има и втори брояч, $mutationVersion, който се увеличава безусловно при всеки запис (без бариера при запис). Кешът JSON.stringify фиксира този брояч във всеки посетен вложен обект/масив, така че промяна само в инстанцията като o.a = 99 правилно да инвалидира кеширания низ дори когато $version на IC би пропуснал увеличението.

Кеш JSON.stringify

JsObject има $jsonCacheString + $jsonCacheVersions (списък от двойки [obj, mutationVersion] за всеки вложен обект, посетен по време на обхождането за тривиално преобразуване в низ). При следващото извикване на JSON.stringify(o) без replacer / space бързият път за тривиални случаи проверява дали всяка фиксирана версия е непроменена и връща кеширания низ, без повторно обхождане или кодиране. Често срещани шаблони с дълбоко клониране като JSON.parse(JSON.stringify(o)) ускоряват приблизително 2 пъти преобразуването в низ.

При пропуск кешът се изхвърля безшумно (ако някоя фиксирана версия се различава) и се попълва отново при следващото обхождане. Обекти с toJSON, getter-и, аксесори, символи или слотове [[PrimitiveValue]] преминават към сериализатора според спецификацията и никога не попълват кеша — затова кешираният път вижда само структурно еднородни данни.

Вградени бързи пътища

Най-големите подобрения в производителността дойдоха от обучаването на VM за конкретни вградени функции:

  • Atomics.load / store / compareExchange с кооперативно планиране чрез loadSpinHook / storeNotifyHook — възстановени са 4 теста за spin-loop на Atomics.
  • Date.prototype.getTimezoneOffset с DST кеш, реализиран чрез директна хеш-таблица — ускорение с 45 % при DST стрес теста SpiderMonkey.
  • String.fromCharCode с вграден бърз път за ASCII — спестява ~1 M извиквания на диспечера при типично обхождане.
  • decodeURI / decodeURIComponent с ултрабърз път за 12 символа %XX%XX%XX%XX, използващ групово декодиране hex2bin.
  • Object.keys / Object.values / Object.entries с директен достъп до картата на свойствата.
  • Array.prototype.map / filter / forEach / reduce с нативна итерация върху базовия масив PHP.

Всеки бърз път се активира след „проверка на формата“ — ако получателят не съответства на очакваната форма (e.g, например TypedArray изглед, който не е базиран на обикновен PHP string), VM преминава към бавния път, съответстващ на спецификацията.

Какво това не прави

Байткод VM не е JIT в смисъла на V8. Тя не генерира нативен машинен код за всяка функция и не прави предположения въз основа на наблюдаваните типове отвъд мономорфната проверка на идентичността на прототипа от IC. Вграждането на границите между функциите се апроксимира чрез персонализирания стек от извиквания (без допълнителен кадър PHP при всяко извикване на JS), а не чрез включване на тялото на извиканата функция в байткода на извикващата.

Транспилаторът JsToPhp е най-близкото до JIT в енджина: той генерира изходен код PHP за горещи тела на функции, който проследяващият JIT на PHP 8.5 след това компилира до нативен код. JsToPhp преминава през eval(), затова плаща еднократна цена за компилация, а проследяващият JIT трябва да открие функцията, сякаш е потребителски код. Пропускането на стъпката eval() чрез директно генериране на opcache опкодове би намалило разликата — вижте Пътна карта § Бенчмаркове.

Бенчмаркове

bench/microbench.js изпълнява около дузина кратки бенчмаркове (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). Средно време за изпълнение:

Обвивка
php bench/run.php

Текущите резултати се записват в BENCH.md след всяко изпълнение на работния поток bench. Работните потоци bench и test262 матрицата се стартират ръчно.