Виртуальная машина байткода
По умолчанию код выполняется обходчиком дерева: он посещает каждый узел AST и возвращает значение. Для горячих функций, вызываемых миллионы раз, узким местом становится накладная стоимость диспетчеризации для каждого узла. Сверху Phasis добавляет спекулятивный компилятор байткода в замыкания PHP, чтобы сократить эти издержки.
Когда запускается виртуальная машина
У каждой 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 решает эту задачу двумя способами:
- Чистые префиксы — компилятор проверяет, были ли побочные эффекты до точки спекуляции. Если функция выполняет операции ввода-вывода (console.log, хост-функции) до этой точки, быстрый путь не пропускает их, а выполняет и продолжает работу.
- Отказ на этапе компиляции — компилятор отказывается от компиляции форм, для которых пришлось бы отменять наблюдаемые побочные эффекты (например, при присваивании нечислового значения
let r = fn(), когдаfn()меняет состояние). Функция остается на обходчике дерева.
Причина для такого решения: при присваивании нечислового значения быстрый путь выполняет fn(), выбрасывает Bailout, после чего обходчик дерева запускает операцию заново — и выполняет fn() дважды, вызывая побочные эффекты в обоих случаях. Благодаря отказу при компиляции fn() вообще не запускается на быстром пути, и обходчик дерева выполняет вызов только один раз.
Диспетчеризация виртуальной машины (второй уровень)
Некоторые функции компилируются в замыкания PHP, а остальные выполняются более простым интерпретатором байткода в src/Bytecode/VM.php. Байткод представляет собой плоский массив кодов операций (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), который обрабатывается диспетчеризацией switch.
Виртуальная машина нужна для функций, компиляция которых в замыкание PHP не оправдана, но которые вызываются достаточно часто, чтобы выиграть от пропуска обхода AST. Вызовы методов встроенных объектов (Array.prototype.map, String.prototype.split, …) проходят по быстрым путям виртуальной машины, обходящим общий механизм вызовов callFunction.
Пользовательский стек вызовов
Op::CALL, Op::CALL_METHOD и Op::NEW_CALL используют встроенный путь вызовов, при котором весь стек вызовов JS остается внутри VM::execute; для каждого вызова JS не создается новый кадр PHP. При входе состояние вызывающего кода (pc, поток кода, стек операндов, локальные переменные, env, this, флаг строгого режима) сохраняется в пуле экземпляров SavedFrame для конкретной виртуальной машины savedFrames, а цикл диспетчеризации перенастраивает локальные переменные на вызываемую функцию. Затем 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, stack, sp, locals, env, thisValue, strict) в объекте кучи GeneratorSnapshot и возвращает из VM::execute маркер YieldResult. Управляющий JsGenerator сохраняет снимок и передаёт его обратно в VM::execute при следующем next() / throw(), заменяя значение возобновления на вершине стека операндов.
Для поддерживаемого подмножества это заменяет путь на PHP-Fiber. Драйвер на основе Fiber (executeGeneratorBody) по-прежнему используется для форм, которые компилятор отклоняет: делегирования yield*, генераторов с yield внутри блока try, асинхронных генераторов и асинхронных функций с await. JsGenerator работает в двух режимах: при каждом next() / throw() / return() проверяет, есть ли у экземпляра снимок или Fiber, и выбирает соответствующий путь.
Настраиваемого для бенчмарков параметра нет: возможность использования определяется во время компиляции и кэшируется в JsFunction. В пути со снимками выигрыш составляет одно переключение контекста PHP на каждую приостановку; путь Fiber для всего остального не изменился.
Встроенные кэши
Op::LOAD_MEMBER содержит встроенный кэш для каждого PC, ключом которого служит идентичность прототипа. Он используется для шаблона «метод прототипа у экземпляра» (c.sum(...), arr.push(...) и каждого вызова метода класса). Условие попадания: у получателя нет собственного слота с этим именем, кэшированная ссылка на прототип идентична getPrototype() получателя, а PropertyMap::$version прототипа по-прежнему соответствует сохранённой версии.
Инвалидация кэша основана на PropertyMap::$version. Структурные изменения (set / delete) всегда увеличивают его значение. Значения слотов данных увеличивают его только при PropertyMap::$isUsedAsProto, флаге барьера записи, который включается при первом обнаружении объекта в роли [[Prototype]] другого объекта. Экземпляры, которые никогда не служат прототипами, не несут затрат на инфраструктуру IC при каждом obj.x = v.
Геттеры никогда не кэшируются: их нужно повторно вызывать при каждом чтении.
PropertyMap содержит второй счётчик, $mutationVersion, который увеличивается при каждой записи безусловно (без барьера записи). Кэш JSON.stringify сохраняет этот счётчик для каждого посещённого вложенного объекта или массива, чтобы изменение только экземпляра, например o.a = 99, корректно инвалидировало кэшированную строку, даже если $version IC пропустил бы увеличение счётчика.
Кэш JSON.stringify
JsObject хранит $jsonCacheString + $jsonCacheVersions (список пар [obj, mutationVersion] для каждого вложенного объекта, посещённого при обходе для тривиальной сериализации в строку). При следующем вызове JSON.stringify(o) без функции-замены и отступов быстрый тривиальный путь проверяет, что версии всех сохранённых объектов не изменились, и возвращает кэшированную строку без повторного обхода и кодирования. Для часто используемых шаблонов глубокого клонирования, например JSON.parse(JSON.stringify(o)), сериализация в строку ускоряется примерно в 2 раза.
При промахе кэш молча сбрасывается (если изменилась любая сохранённая версия) и заполняется при следующем обходе. Объекты с toJSON, геттерами, аксессорами, символами или слотами [[PrimitiveValue]] передаются стандартному сериализатору по спецификации и никогда не попадают в кэш. Поэтому кэшированный путь обрабатывает только структурно однородные данные.
Быстрые пути для встроенных функций
Самый большой прирост производительности дали специализированные улучшения VM для отдельных встроенных функций:
Atomics.load/store/compareExchangeс кооперативным планированиемloadSpinHook/storeNotifyHook— восстановлена работоспособность 4 тестов с циклом ожидания Atomics.Date.prototype.getTimezoneOffsetс кэшем DST на прямой хеш-таблице — ускорение на 45 % в DST-стресс-тесте SpiderMonkey.String.fromCharCodeсо встроенным быстрым путём для ASCII — экономит около 1 млн вызовов диспетчеризации за типичный проход.decodeURI/decodeURIComponentс ультрабыстрым путём для%XX%XX%XX%XXдлиной 12 символов, использующим массовое декодирование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 запускаются вручную.