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

Внутри цикла интерпретатора

Ignition исполняет байт-код циклом диспетчеризации: выбрать опкод, перейти к его обработчику, сделать работу, продвинуть указатель байт-кода, диспетчеризовать следующий. Аккумулятор протягивает значения между обработчиками

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

У вас есть байт-код. Теперь что-то должно его исполнить — одну инструкцию за другой, миллионы раз в секунду, попутно тихо ведя учёт того, насколько горячей становится каждая функция. Магии здесь нет: это цикл. Выбрать следующий опкод, перейти к коду, который его обрабатывает, сделать работу, продвинуться, повторить. Понимание этого цикла объясняет и почему интерпретируемый JavaScript предсказуем-но-не-быстр, и как движок узнаёт точный момент передать функцию JIT.

Цикл диспетчеризации

Предыдущий урок оставил нас с байт-кодом функции и набором общих обработчиков (по одному на опкод, написанных на Torque). Исполнение — это акт запуска этих обработчиков по очереди. Концептуально:

loop:
  opcode  = bytecode[pc]        // выбрать текущий опкод
  handler = handlerTable[opcode] // найти его обработчик
  jump handler                   // сделать работу; обработчик продвигает pc, затем диспетчеризует следующий

Каждая итерация выбирает опкод по текущему указателю байт-кода (pc), ищет обработчик этого опкода в таблице и переходит к нему. Обработчик читает свои операнды (из регистрового файла и аккумулятора), выполняет операцию, пишет результат (обычно обратно в аккумулятор), продвигает pc за операнды этой инструкции, а затем диспетчеризует следующую инструкцию. Гоняйте этот цикл до Return — и вы исполнили функцию.

Как переход делается быстрым: threaded-диспетчеризация

Наивный интерпретатор написал бы это как гигантский switch (opcode) внутри while-цикла. Это работает, но у этого есть скрытая цена: после каждого обработчика поток управления возвращается к началу цикла и заново входит в switch, и предсказатель переходов CPU видит один-единственный косвенный переход, общий для всех опкодов — поэтому он предсказывает плохо и тормозит.

Вместо этого Ignition использует threaded-диспетчеризацию: каждый обработчик завершается, напрямую диспетчеризуя следующий обработчик — исторически через вычисляемый goto (один косвенный переход на обработчик), а в Ignition конкретно через tail call из машинного кода одного обработчика прямо в следующий. Выигрыш — для предсказания переходов: точка диспетчеризации каждого опкода — это её собственный косвенный переход, поэтому предсказатель может выучить частого преемника каждого опкода (например, за сравнением обычно идёт ветвление), вместо того чтобы путаться в одном общем переходе. Меньше промахов предсказания — более плотный, быстрый цикл интерпретатора.

Аккумулятор — это то, что делает передачу между обработчиками чистой: вместо явной передачи операндов большинство обработчиков читают и пишут один общий регистр-аккумулятор, так что значение, произведённое одним байт-кодом, лежит ровно там, где его ждёт следующий байт-код. Цикл диспетчеризации и аккумулятор — две половины одного дизайна.

Профилирование во время интерпретации: обратная связь заполняется

Цикл не просто исполняет — он учится. Вспомните, что каждый чувствительный к типам байт-код несёт слот feedback vector. По мере того как эти обработчики работают, они заполняют слоты map (hidden classes) и типами, которые реально наблюдают: этот LdaNamedProperty увидел объект с map M; этот Add увидел два Smi; этот Call всегда нацеливался на функцию F. К тому моменту, как функция поработала какое-то время, её feedback vector — компактный профиль того, как она реально используется — ровно та информация, что нужна оптимизатору. Интерпретатор готовит JIT как побочный эффект исполнения программы.

Interrupt budget: как цикл решает повысить ярус

Как V8 узнаёт, что функция достаточно горячая для компиляции? Он не считает буквально каждую инструкцию. Каждая функция несёт interrupt budget (бюджет прерываний — счётчик обратных рёбер и вызовов, по достижении нуля запускающий компиляцию). Цикл уменьшает этот бюджет на событиях, коррелирующих с нагревом:

  • Обратные рёбра — каждый раз, когда управление прыгает назад к началу цикла (одна итерация for/while). Плотный цикл прожигает бюджет быстро.
  • Вызовы функций / инвокации — повторный вход в функцию.

Когда бюджет достигает нуля, цикл поднимает запрос на повышение яруса: скомпилировать эту функцию следующим ярусом (Sparkplug, затем Maglev, затем TurboFan — юнит 04). Для цикла, который всё ещё работает, когда он становится горячим, V8 может даже подменить работающий код прямо посреди цикла через on-stack replacement (OSR) (замена на стеке — механизм подмены интерпретируемого фрейма на скомпилированный без остановки исполнения), перепрыгнув из интерпретатора в свежескомпилированный код, не дожидаясь окончания цикла. Обратные рёбра — ключевой сигнал: долго работающий цикл — каноничный триггер «это горячо, оптимизируй сейчас».

Движущиеся части цикла интерпретатора
Стиль диспетчеризации в Ignition
tail-call / threaded
Значение между обработчиками через
аккумулятор
Бюджет уменьшается на
обратных рёбрах + вызовах
Бюджет достиг нуля ⇒
запрос на повышение яруса
Горячий цикл подменяется на лету через
OSR
Интерпретация vs оптимизация: скорость
медленнее, но предсказуемо

Почему интерпретация сначала — безопасный дефолт

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

Викторина

Почему Ignition использует threaded (tail-call) диспетчеризацию вместо одного большого switch по опкоду?

Викторина

Функция содержит плотный цикл, выполняющийся миллионы итераций. Что в интерпретаторе триггерит V8 скомпилировать её и через какое событие?

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

Расставьте по порядку одну итерацию цикла диспетчеризации Ignition для одного байт-кода.

  1. 1 Выбрать опкод по текущему указателю байт-кода (pc)
  2. 2 Найти обработчик этого опкода в таблице обработчиков
  3. 3 Перейти к обработчику; он читает операнды и обновляет аккумулятор
  4. 4 Обработчик продвигает pc и диспетчеризует следующий байт-код
Почему это работает

Почему деопт всегда приземляется обратно в интерпретатор, а не на более низкий ярус JIT? Потому что интерпретатор — единственный ярус, не делающий никаких спекулятивных предположений — он обрабатывает каждый тип, каждую форму, каждый краевой случай, определённый спецификацией, просто медленно. Когда оптимизированный код обнаруживает, что его ставка была неверна (значение, которое он считал Smi, оказалось HeapNumber), единственное место, гарантированно работающее корректно с этого точного смещения байт-кода, — это Ignition. Так что цикл диспетчеризации — универсальный запасной вариант: медленный, но всегда правильный. JIT может переоптимизировать позже, как только обратная связь стабилизируется.

Вспомните перед уходом
  1. 01
    Пройдите одну итерацию цикла диспетчеризации Ignition и назовите роль аккумулятора.
  2. 02
    Что такое threaded-диспетчеризация и почему она быстрее интерпретатора на switch?
  3. 03
    Как цикл интерпретатора решает, что функция горячая, и что происходит дальше?
Итог

Исполнение байт-кода — это цикл диспетчеризации: выбрать опкод по указателю байт-кода, найти его обработчик в общей таблице обработчиков, перейти к обработчику (который читает операнды из регистрового файла и аккумулятора, вычисляет и пишет результат обратно в аккумулятор), продвинуть pc и диспетчеризовать следующий — повторяя до Return. Ignition не использует наивный switch; он использует threaded, tail-call диспетчеризацию, так что у каждого опкода своя точка косвенного перехода, которую предсказатель переходов CPU учит куда лучше, чем один общий переход, сокращая промахи. Аккумулятор — это чистая передача между обработчиками, вторая половина дизайна диспетчеризации. Во время интерпретации цикл также заполняет слоты feedback vector hidden classes и типами, которые реально наблюдает, профилируя программу бесплатно, чтобы JIT мог позже специализироваться. Чтобы решить, когда оптимизировать, каждая функция несёт interrupt budget, уменьшаемый на обратных рёбрах и вызовах; когда он достигает нуля, цикл поднимает запрос на повышение яруса (а всё ещё работающий горячий цикл может быть подменён через on-stack replacement). Интерпретация медленнее на инструкцию, но предсказуема — нет паузы на компиляцию и нет деопта — и это универсальный запасной вариант, в который деопт всегда возвращается, потому что интерпретатор не делает спекулятивных предположений о типах. Теперь, когда видишь функцию, дольше обычного застрявшую в интерпретируемом режиме, знаешь причину: не хватило обратных рёбер, чтобы исчерпать interrupt budget и запустить JIT.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.