
# Виртуальная машина байткода

По умолчанию код выполняется обходчиком дерева: он посещает каждый узел 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 решает эту задачу двумя способами:

1. **Чистые префиксы** — компилятор проверяет, были ли побочные эффекты до точки спекуляции. Если функция выполняет операции ввода-вывода (console.log, хост-функции) до этой точки, быстрый путь не пропускает их, а выполняет и продолжает работу.
2. **Отказ на этапе компиляции** — компилятор отказывается от компиляции форм, для которых пришлось бы отменять наблюдаемые побочные эффекты (например, при присваивании нечислового значения `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 напрямую, разрыв сократится ещё сильнее — см. [Тесты производительности § Дорожная карта](https://phasis.inline0.dev/ru/docs/advanced/benchmarks#roadmap).

## Бенчмарки

`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). Медианное время выполнения:

```bash
php bench/run.php
```

Текущие результаты фиксируются в `BENCH.md` после каждого запуска рабочего процесса `bench`. Рабочие процессы матриц `bench` и test262 запускаются вручную.
