open atlas
↑ К треку
Внутренности движка JavaScript JSE · 01 · 03

Ignition и байт-код

Ignition — это регистровый интерпретатор байт-кода в V8. Байт-код намного компактнее машинного кода — его изначальной мотивацией была память, он урезал память под код в V8 примерно вдвое на мобильных. Один аккумулятор плюс регистровый файл

JSE Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Когда V8 выпустил Ignition в 2017, заголовком была не скорость — а память. На дешёвом Android-телефоне машинный код, который V8 генерировал для каждой функции, съедал десятки мегабайт RAM, и большая часть — для кода, выполнявшегося считанные разы. Решением было перестать генерировать машинный код заранее и вместо этого исполнять компактный байт-код, урезав память под код в V8 примерно вдвое. Этот байт-код и регистровая машина, которая его исполняет, — это то, что стоит между вашим распарсенным AST и быстрым путём.

Байт-код: компактен по замыслу, и причина — память

После того как парсер строит AST (и только когда функция реально вызвана — вспомните ленивый парсинг), генератор байт-кода понижает это дерево в байт-код Ignition: плоскую последовательность мелких инструкций для виртуальной машины. Байт-код стоит между исходником и машинным кодом. Он медленнее в исполнении, чем нативный машинный код, но драматически компактнее — один байт-код обычно один-два байта против десятков байт машинного кода на ту же операцию.

Эта компактность и была смыслом. До Ignition базовый компилятор V8 жадно генерировал машинный код для каждой функции, и на стеснённых по памяти мобильных устройствах накопленный код пробивал бюджет RAM. Ignition заменил жадный машинный код байт-кодом, который V8 интерпретирует, урезав память под код в V8 примерно вдвое, сохраняя при этом быстрый старт. Скорость пришла позже — от ярусов JIT, компилирующих горячий байт-код; основополагающей мотивацией был объём.

Зачем байт-код и чего он стоит
Изначальная мотивация Ignition (2016)
память
Сокращение памяти под код в V8
~50%
Типичный размер байт-кода
1-2 байта / оп
Стиль VM в Ignition
регистровая
Стиль VM в JVM / CPython
стековая
Флаг для просмотра байт-кода
--print-bytecode

Регистровая машина, а не стековая

Когда читаешь вывод d8 --print-bytecode и видишь незнакомые операнды, регистровая модель — это ключ к их расшифровке. Есть два классических способа построить VM байт-кода. Стековая машина (JVM, CPython) держит операнды на неявном стеке операндов: PUSH a; PUSH b; ADD снимает два, кладёт сумму. Регистровая машина держит операнды в именованных регистрах, и операции ссылаются на них напрямую: ADD r1, r2. Ignition — это регистровая машина.

Регистровый байт-код чуть труднее генерировать, но даёт меньше, более плотных инструкций — нет постоянной возни с push/pop на каждый операнд — что означает меньше диспетчеризаций в цикле интерпретатора на единицу работы. Эта плотность — часть того, почему регистровый дизайн подходит движку с JIT: меньше байт-кодов интерпретировать в холодном состоянии и более чистый поток для компиляции в горячем.

Регистровая модель Ignition — это конкретно аккумулятор + регистровый файл:

  • Аккумулятор — единственный неявный регистр, из которого большинство байт-кодов читают и в который пишут. Он протягивает промежуточные значения от одной инструкции к следующей, так что не всем операндам нужно явное имя. Многие операции берут один явный операнд и неявно используют аккумулятор как другой вход и как выход.
  • Регистровый файл — набор именованных локальных регистров — r0, r1, r2, … — хранящих локальные переменные функции и временные значения, аллоцированный на каждый фрейм функции.

Читаем настоящий байт-код

Возьмите простейшую функцию и посмотрите, что исполняет Ignition. Запустите d8 --print-bytecode add.js на:

function add(a, b) { return a + b; }
add(1, 2);

Вы получите что-то близкое к этому (операнды и индексы слотов сокращены):

Ldar a1          // загрузить аргумент b в аккумулятор
Add a0, [0]      // аккумулятор = a0 (a) + аккумулятор (b); слот обратной связи 0
Return           // вернуть аккумулятор

Заметьте аккумулятор-центричную форму: Ldar (Load Accumulator from Register) кладёт b в аккумулятор; Add a0, [0] прибавляет регистр a0 (аргумент a) к аккумулятору и оставляет сумму в аккумуляторе; Return возвращает аккумулятор. Нет Push/Pop — аккумулятор и есть общая рабочая область.

[slot] — это место, где JIT получает информацию о типах

Посмотрите снова на Add a0, [0]. Этот [0]индекс слота feedback vector (вектор обратной связи — массив, куда интерпретатор записывает наблюдённые типы). Каждый байт-код, чьё поведение зависит от типов времени исполнения — арифметика, загрузки/записи свойств, вызовы, сравнения — несёт индекс слота в feedback vector функции, массив на каждую функцию, аллоцированный рядом с байт-кодом. По мере того как интерпретатор исполняет этот байт-код, он записывает что реально увидел в этот слот: операнды Add были Smi, или объект при загрузке свойства имел определённый hidden class, или эта точка вызова всегда попадала в одну функцию.

Это и есть мост к оптимизатору. Интерпретатор, по сути, профилирует программу бесплатно, пока её исполняет. Когда функция становится горячей, JIT (Maglev, TurboFan — разбираются в юните 04) читает feedback vector, чтобы делать спекулятивные предположения: «этот Add видел только целые, значит, выпускаем целочисленное сложение». Обратная связь о типах собирается здесь, в Ignition, по слоту на чувствительный к типам байт-код — поэтому этот урок стоит прямо выше JIT и обратной связи о типах по потоку.

Общие обработчики, написанные на Torque

Можно ожидать, что каждый байт-код — это написанный вручную ассемблер под каждый CPU. Вместо этого V8 пишет обработчик каждого байт-кода — процедуру, выполняющую работу этого опкода — один раз, на переносимом низкоуровневом DSL: изначально CodeStubAssembler (CSA), теперь в основном Torque, который компилируется в CSA. Эти обработчики генерируются в машинный код на этапе сборки и разделяются всеми функциями: есть один обработчик Add, один обработчик LdaNamedProperty и так далее. Интерпретатор по сути — это таблица этих общих обработчиков, и исполнение функции означает диспетчеризацию через них — тема следующего урока, цикла интерпретатора.

Викторина

Ignition заменил жадно генерируемый машинный код интерпретируемым байт-кодом в 2017. Какой была главная мотивация?

Викторина

В байт-коде `Add a0, [0]` что такое `[0]`?

Расставь шаги по порядку

Расставьте по порядку байт-коды, которые Ignition исполняет для `function add(a, b) { return a + b; }`, при условии что a0 хранит a, а a1 хранит b.

  1. 1 Ldar a1 — загрузить аргумент b в аккумулятор
  2. 2 Add a0, [0] — прибавить регистр a0 к аккумулятору; записать типы в слот обратной связи 0
  3. 3 Return — вернуть значение в аккумуляторе
Почему это работает

Зачем вообще аккумулятор, а не всегда именовать оба операнда, как Add r1, r2, r3? Аккумулятор позволяет большинству байт-кодов нести на один операнд меньше — результат и один вход неявны — что делает поток байт-кода короче, а цикл диспетчеризации плотнее. Это классический компромисс интерпретатора: чуть больше трафика Ldar/Star (загрузка/сохранение аккумулятора) в обмен на более плотные, быстрее диспетчеризуемые частые операции. Замеры V8 показали, что дизайн с аккумулятором — чистый выигрыш по пропускной способности интерпретатора и размеру кода.

Вспомните перед уходом
  1. 01
    Зачем Ignition вообще использует байт-код и какой была изначальная мотивация?
  2. 02
    Опишите регистровую модель Ignition и противопоставьте её стековой машине.
  3. 03
    Что такое [slot] в байт-коде вроде `Add a0, [0]` и почему он важен для производительности?
Итог

Как только функция впервые вызвана, генератор байт-кода V8 понижает её AST в байт-код Ignition — плоскую последовательность мелких (1-2 байта) инструкций для виртуальной машины. Байт-код существует прежде всего ради памяти: замена жадно генерируемого машинного кода интерпретируемым байт-кодом урезала память под код в V8 примерно вдвое на мобильных, что было основополагающей мотивацией Ignition в 2016-2017; скорость исполнения медленнее машинного кода и восстанавливается позже ярусами JIT. Ignition — регистровая машина, а не стековая (JVM/CPython), и конкретно использует аккумулятор плюс регистровый файл: единственный аккумулятор протягивает значения от одного байт-кода к следующему, тогда как именованные регистры r0, r1, … хранят локальные фрейма. Функция настолько простая, как add(a, b), компилируется примерно в Ldar a1; Add a0, [0]; Return, показывая аккумулятор-центричную форму и отсутствие push/pop. [0] — это индекс слота feedback vector: каждый чувствительный к типам байт-код несёт один, и интерпретатор записывает наблюдаемые типы туда по мере исполнения, профилируя программу бесплатно, чтобы JIT мог позже специализироваться. Обработчики байт-кода написаны один раз на Torque/CSA и общие для всех функций. Теперь, когда запустишь d8 --print-bytecode на медленной функции, сможешь прочитать то, что движок реально исполняет — и увидеть, правильно ли заполняются слоты обратной связи.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.