Віртуальна машина байткоду
Типовий шлях виконання — це інтерпретатор дерева: обхід кожного вузла AST із поверненням значення. Для гарячих функцій, які викликаються мільйони разів, вузьким місцем стають накладні витрати диспетчеризації для кожного вузла. Phasis додає поверх цього спекулятивний компілятор байткоду в замикання PHP, щоб зменшити ці витрати.
Коли запускається віртуальна машина
Для кожної JsFunction ведеться лічильник гарячих викликів. Коли він перевищує поріг (типово 100), рушій пробує скомпілювати тіло функції в замикання PHP за допомогою src/Bytecode/JsToPhp.php.
Компіляція вдається, якщо тіло функції відповідає одній із відомих форм:
- Присвоєння локальним змінним із передбачуваними типами (число, об’єкт, функція, масив, рядок).
- Арифметичні операції над числами.
- Читання властивостей об’єктів у локальних змінних типу об’єкта, а також у
this(this.v,obj.x). - Виклики методів для локальних змінних типу об’єкта (
obj.method(args)); об’єкт-приймач передається як$thisValue. - Виклики конструктора
new Ctor(args)— присвоєння змінній LHS типуobjectі захист від оптимізації у разі повернення не об’єкта. - Цикли
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 для звичайних викликів (скомпільоване в байткод тіло, не прив’язане до bind(), не нативне), vmInlineConstructCache для виразів new (додатково isConstructable, не похідні, без ініціалізаторів полів екземпляра чи приватних методів). Усе інше проходить через канонічний трамплін callFunction / vmNewExpression.
Архітектурна перевага: обмеження глибини стеку PHP більше не обмежує глибину рекурсії JS. Тепер єдине обмеження — CallStack::maxDepth, встановлене на 4 096: цього з запасом вистачає для будь-якого легітимного випадку test262 (у більшості потрібно менш ніж 2 k кадрів), але достатньо мало, щоб тести з патологічно нескінченною рекурсією швидко завершувалися в межах часу очікування виконавчого середовища. Для шляху транспільованих замикань (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(), замінюючи значення відновлення на вершині стека операндів.
Для підтримуваної підмножини це замінює шлях через PHP-Fiber. Драйвер на базі Fiber (executeGeneratorBody) залишається для форм, які компілятор відхиляє: yield* делегування, генератори з yield усередині блоку try, асинхронні генератори, асинхронні функції з await. JsGenerator працює у двох режимах: кожен next() / throw() / return() перевіряє, чи має екземпляр знімок або Fiber, і відповідно запускає обробку.
Параметра для налаштування тестів продуктивності немає; придатність визначається під час компіляції та запам’ятовується в JsFunction. У шляху зі знімком кожен yield усуває одне перемикання контексту PHP; для всього іншого шлях через Fiber не змінюється.
Вбудовані кеші
Op::LOAD_MEMBER має вбудований кеш для кожного PC, ключем якого є ідентичність прототипу. Він використовується для випадків, коли метод прототипу викликається для екземпляра (c.sum(...), arr.push(...), усі виклики методів класу). Умова влучання в кеш: у приймача немає власного слота з таким ім’ям, кешоване посилання на прототип збігається з getPrototype() приймача, а PropertyMap::$version прототипу все ще відповідає збереженій версії.
Інвалідація кешу спирається на PropertyMap::$version. Структурні зміни (встановлення / видалення) завжди збільшують його значення. Значення слотів даних збільшує його лише тоді, коли PropertyMap::$isUsedAsProto дорівнює true — це прапорець бар’єра запису, який вмикається під час першого виявлення об’єкта як [[Prototype]] іншого об’єкта. Екземпляри, які ніколи не є прототипами, не несуть витрат на інфраструктуру IC під час кожного obj.x = v.
Геттери ніколи не кешуються (їх потрібно повторно запускати під час кожного читання).
PropertyMap має другий лічильник, $mutationVersion, який збільшується безумовно під час кожного запису (без бар’єра запису). Кеш JSON.stringify закріплює цей лічильник для кожного вкладеного відвіданого об’єкта/масиву, тож зміна лише екземпляра, як-от o.a = 99, правильно анулює кешований рядок, навіть якщо $version IC пропустив би збільшення.
Кеш JSON.stringify
JsObject зберігає $jsonCacheString + $jsonCacheVersions (список пар [obj, mutationVersion] для кожного вкладеного об’єкта, відвіданого під час обходу trivial-stringify). Під час наступного виклику JSON.stringify(o) без replacer / space швидкий шлях trivial перевіряє, що версія кожного закріпленого об’єкта не змінилася, і повертає кешований рядок без повторного обходу чи кодування. Типові шаблони глибокого клонування, як-от JSON.parse(JSON.stringify(o)), приблизно вдвічі пришвидшують етап stringify.
Кеш непомітно скидається в разі промаху (якщо відрізняється будь-яка закріплена версія) і заповнюється під час наступного обходу. Об’єкти з toJSON, геттерами, аксесорами, символами або слотами [[PrimitiveValue]] переходять до серіалізатора, що відповідає специфікації, і ніколи не заповнюють кеш — тож кешований шлях працює лише зі структурно однорідними даними.
Вбудовані швидкі шляхи
Найбільший приріст продуктивності дали спеціальні оптимізації VM для вбудованих функцій:
Atomics.load/store/compareExchangeіз кооперативним плануваннямloadSpinHook/storeNotifyHook— відновлено проходження 4 тестів циклів очікування Atomics.Date.prototype.getTimezoneOffsetіз кешем DST на основі прямого hashmap — прискорення на 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 запускаються вручну.