Погружение в V8. Часть 2. Из чего состоит движок.
Anastasia KotovaПредыдущие части
Погружение в V8. Часть 1. История.
Введение
В первой части мы рассмотрели историю создания и развития движка V8, а также его отличия от других JavaScript-движков. Мы говорили, что V8 состоит из нескольких компонентов, и в этой статье разберём каждый из них подробнее. Посмотрим, как они устроены, в каком порядке работают и какие идеи лежат в основе их архитектуры.
Full-codegen и первые шаги V8
Изначально V8 был представлен в официальном блоге Chromium как движок, компилирующий JavaScript напрямую в машинный код. Вместе с этим он внедрил систему скрытых классов, которая используется до сих пор. Первоначальный компилятор был достаточно простым, не поддерживал серьёзных оптимизаций и работал со всем кодом одинаково. Позже он получил название Full-codegen.
Crankshaft и адаптивные оптимизации
Следующим этапом стал компилятор Crankshaft, название которого переводится как «коленчатый вал» — отсылка к автомобильной теме, проходящей через всё в V8. Анонс Crankshaft сопровождался заметным улучшением производительности.
Crankshaft впервые внедрил стратегию адаптивной компиляции: деление на «горячий» код, который исполняется часто и подлежит глубокой оптимизации, и «холодный» код, который можно оставить без изменений. Это объясняет, почему тесты без «прогрева» не показывали существенного прироста производительности: оптимизации применялись только со временем.
Важнейшие техники, применяемые Crankshaft:
- SSA (Static Single Assignment form) — представление кода, в котором каждой переменной присваивается значение только один раз. Это упрощает анализ зависимостей и позволяет применять другие оптимизации. Например:
let x = 1; x = x + 2; x = x * 3;
В SSA это выглядит так:
let x1 = 1; let x2 = x1 + 2; let x3 = x2 * 3;
- Loop-invariant code motion — перенос выражений, не изменяющихся внутри цикла, за его пределы. Например, arr.length не нужно пересчитывать на каждой итерации.
- Linear-scan register allocation — распределение переменных между регистрами и памятью с целью держать как можно больше активных значений в регистрах процессора, откуда доступ к ним осуществляется быстрее.
- Inlining — встраивание тел небольших функций вместо их вызовов, что уменьшает накладные расходы на «перепрыгивания».
- Типовые предположения. JavaScript — динамический язык, и типы заранее неизвестны. Поэтому движок собирает статистику: например, переменная чаще всего — число. Это позволяет применять более эффективные оптимизации.
TurboFan: многоступенчатая оптимизация
Позже Crankshaft был заменён TurboFan, который используется в V8 до сих пор. Главное отличие — это более мощный и универсальный подход к построению оптимизированного машинного кода.
- TurboFan использует граф зависимостей (sea of nodes), промежуточное представление, где операции представлены как узлы, а зависимости между ними — как рёбра. Это даёт гибкость при реорганизации кода и поиске неиспользуемых кусков.
- Компилятор построен как многоступенчатый pipeline: код проходит через серию трансформаций, на каждом этапе применяются определённые правила и анализы.
- Вместо сложных алгоритмов используются локальные правила переписывания: x * 1 → x, x + 0 → x, if (true) → убрать ветвление.
- TurboFan умеет делать анализ диапазонов значений, что позволяет применять ещё более точные оптимизации.
- Так как код в виде графа не привязан к строгому порядку, TurboFan может свободно переносить узлы: вынести лишние вычисления из циклов, переместить их в менее часто выполняемые пути, а часто выполняемые оставить максимально «чистыми».
- TurboFan генерирует код, ориентируясь на конкретную архитектуру процессора: учитывает SIMD-инструкции, особенности регистров и команд и т.д.
Ignition: регистровый интерпретатор и байткод
Несмотря на эффективность JIT-компиляторов, какими являлись и Crankshaft, и TurboFan, для некоторых сценариев — особенно на мобильных устройствах — важнее были скорость запуска и низкое потребление памяти. Поэтому в V8 появился интерпретатор Ignition, который используется до сих пор.
Ignition работает как регистровая машина (register machine).
При подходе stack machine (например, у JVM) каждая операция работает со стеком: «взять два значения со стека → сложить → результат обратно на стек». Это выглядит проще и компактнее в итоговом байткоде, но добавляет много накладных расходов при исполнении.
В случае же с register machine каждая инструкция говорит прямо, с какими виртуальными регистрами работать. Это даёт выигрыш в скорости и упрощает дальнейшую JIT-компиляцию.
Также у Ignition есть специальный аккумулятор: он используется как «неявный» регистр для большинства временных значений. Например, выражение a + b * c может хранить промежуточные результаты прямо в аккумуляторе, не делая кучу лишних загрузок/выгрузок.
Кроме того, при генерации байткода сразу применяются простые оптимизации: замена паттернов, устранение лишних операций, минимизация движений между регистрами. В результате байткод получается не только компактным, но и быстрым в исполнении.
Отказ от Full-codegen и Crankshaft
До появления Ignition и TurboFan в V8 использовалась связка Full-codegen и Crankshaft. Full-codegen обеспечивал базовое исполнение кода, а Crankshaft подключался при повторяющихся вызовах, чтобы применить оптимизации. Но со временем выяснилось, что такая архитектура имеет серьёзные недостатки: Full-codegen генерировал слишком много машинного кода, даже для кода, исполняемого всего один раз, а Crankshaft был слишком сложен и плохо поддерживал новые возможности JavaScript.
Ignition и TurboFan позволили отказаться от этой связки. В 2017 году команда V8 официально удалила Full-codegen и Crankshaft. С этого момента код в V8 стал жить по двум сценариям:
- Если код «холодный», он остаётся в виде байткода и исполняется интерпретатором Ignition.
- Если код становится «горячим», его байткод профилируется и передаётся TurboFan, где он компилируется в высокооптимизированный машинный код.
Такой подход улучшил стартовое время загрузки скриптов и сократил объём потребляемой памяти.
Sparkplug: baseline-компилятор
Несмотря на эффективность Ignition и TurboFan, в реальных приложениях есть большой слой кода, который исполняется нечасто, но всё же достаточно активно, чтобы оправдать переход от интерпретации к компиляции. Однако TurboFan — тяжёлый инструмент, и использовать его на всё подряд нецелесообразно. Поэтому был создан Sparkplug — лёгкий baseline-компилятор, который дополняет существующую цепочку.
Sparkplug компилирует функции не из исходного JavaScript, а из уже готового байткода. Это значит, что работа по разбору синтаксиса, разрешению переменных и деструктуризации уже выполнена. Sparkplug не создаёт промежуточных представлений вроде графа зависимостей, как TurboFan. Вместо этого он проходит по байткоду линейно и генерирует машинный код сразу. Весь компилятор по сути представляет собой большой switch внутри for, где на каждую инструкцию байткода приходится кусок готовой логики генерации.
Он почти не делает оптимизаций, за исключением локальных (например, убирает x + 0). Но это не проблема, потому что он не предназначен для достижения пиковой производительности. Его задача — избавить интерпретатор от накладных расходов: декодирования инструкций, предсказания ветвлений, извлечения операндов из памяти. Вместо этого Sparkplug просто «сериализует» выполнение интерпретатора. Он также активно использует встроенные фрагменты машинного кода (code stubs), общие с Ignition, чтобы не дублировать сложную реализацию JavaScript-операций. Благодаря этому компиляция быстрая, а память используется экономно.
Maglev: быстрые оптимизации между Sparkplug и TurboFan
В 2023 году команда V8 представила Maglev — ещё один JIT-компилятор, который занял промежуточное положение между Sparkplug и TurboFan. Он появился потому, что разрыв в производительности между ними оказался слишком большим: Sparkplug компилирует быстро, но почти не оптимизирует, а TurboFan оптимизирует глубоко, но компилирует долго.
Maglev строит граф зависимостей и использует SSA-подобное представление (когда каждой переменной значение присваивается только один раз), упрощённое по сравнению с TurboFan. Это позволяет ему выполнять базовые оптимизации: удаление мёртвого кода, упрощение выражений, перестройку ветвлений и перемещение инструкций. Также Maglev использует данные профилирования и делает предположения о типах (например, «эта переменная всегда число»), что позволяет вставлять быстрые версии операций без дополнительных проверок.
Ключевая особенность Maglev — баланс между скоростью компиляции и качеством кода. Он компилируется в 10–20 раз быстрее TurboFan, но при этом даёт сопоставимую производительность в большинстве распространённых сценариев. Это позволяет применять его чаще, не рискуя перегрузить систему по времени или памяти.
Общая схема исполнения кода
Сегодня в V8 работает следующий многоуровневый pipeline:
JS ↓ Ignition (байткод + интерпретация для холодных операций) ↓ Sparkplug (на основе байткода компилирует baseline машинный код) ↓ Maglev (на основе байткода компилирует средне-оптимизированный машинный код) ↓ TurboFan (на основе байткода компилирует сильно оптимизированный машинный код)
Этот подход позволяет эффективно запускать код, не тратя ресурсы на редко используемые участки, и при этом достигать высокой производительности там, где это действительно важно.
Однако, это далеко не все составляющие большой системы движка V8. О других её частях мы поговорим в следующих статьях.
Следующие части
Погружение в v8. Часть 3. Парсинг, AST и анализ кода.
Погружение в v8. Часть 4. Управление памятью и сборка мусора.
Погружение в v8. Часть 5. Скрытые оптимизации.
Погружение в v8. Часть 6. От среды к среде.