Мы часто слышим такие термины как «горячий код», особенно когда говорим про JIT-компиляцию. Но что стоит за этой концепцией с точки зрения устройства компиляторов, в частности, внутри V8?
В этой статье я разберу, как именно V8 решает, что код «горячий», по каким правилам переводит функцию с одного компилятора на другой, и какие конкретные коэффициенты и флаги за это отвечают.
Пайплайн движка V8
Сейчас внутри V8 существует четыре уровня выполнения и оптимизаций JavaScript. Каждый следующий генерирует более быстрый код, но тратит больше времени на саму компиляцию.
Ignition — интерпретатор. Весь JavaScript сначала компилируется в байткод, который исполняется здесь. Это даёт быстрый старт, но медленное исполнение.
Sparkplug — baseline-компилятор. Он транслирует байткод напрямую в машинный код без оптимизаций — компилирует практически мгновенно и даёт ускорение. Sparkplug не использует никакую собранную информацию о типах во время исполнения и сложные оптимизации, а просто механически переводит каждый байткод в соответствующий машинный код.
Maglev — mid-tier оптимизирующий компилятор. Использует граф промежуточного предстваления и собранную информацию о типах для спекулятивных оптимизаций. Компилирует медленнее Sparkplug, но генерирует значительно более быстрый код.
TurboFan — top-tier оптимизирующий компилятор. Делает максимально агрессивные оптимизации, но компилирует значительно медленнее Maglev.
Подробнее про пайплайн V8 и его отдельные стадии можно узнать в моих статье и докладе.
Так как же V8 решает, когда переключить функцию на следующий уровень?
Interrupt Budget
В основе всех решений о переключении между компиляторами лежит механизм interrupt budget. Это числовой счётчик, который хранится для каждой функции.
Начальный бюджет — это произведение коэффициента перехода на длину байткода функции в байтах:
budget = K × bytecode_length
Коэффициент K зависит от того, на какой переход мы «копим». Актуальные значения из flag-definitions.h:
Сбор FeedbackVector (→ Sparkplug): invocation_count_for_feedback_allocation = 8
→ Maglev: invocation_count_for_maglev = 400
→ TurboFan: invocation_count_for_turbofan = 3000
OSR → Maglev: invocation_count_for_maglev_osr = 100
OSR → TurboFan: invocation_count_for_osr = 500
Это значит, что у длинных функций бюджет больше — им нужно больше «работы», чтобы доказать свою горячесть. Логично: если функция длиной 100 байт байткода, одна итерация цикла в ней «списывает» больше бюджета, чем одна итерация в функции длиной 10 байт.
Бюджет уменьшается не на каждом байткоде, а только в двух местах:
- На jump — конец тела цикла, инструкция
JumpLoop. Списывается значение, пропорциональное размеру прыжка назад в байтах. - На return — при выходе из функции. Списывается значение, связанное с текущим смещением (offset) в байткоде.
Посмотрим на конкретном примере. Вот функция:
function f() {
let s = 0;
for (let i = 0; i < 100000; i++) {
s += i / 2 + 1;
}
return s;
}
f();
Её байткод (получаем через v8 --print-bytecode --print-bytecode-filter=f example.js):
Bytecode length: 42
@ 0 : LdaZero
@ 1 : Star0
@ 2 : LdaZero
@ 3 : Star1
@ 4 : LdaSmi.ExtraWide [100000]
@ 10 : TestLessThan r1, FBV[0]
@ 13 : JumpIfFalse [26] → @ 39
@ 15 : Ldar r1
@ 17 : DivSmi [2], FBV[3]
@ 20 : AddSmi [1], FBV[2]
@ 23 : Add r0, FBV[1]
@ 26 : Mov r0, r2
@ 29 : Star0
@ 30 : Ldar r1
@ 32 : Inc FBV[4]
@ 34 : Star1
@ 35 : JumpLoop [31], [0], FBV[5] → @ 4
@ 39 : Ldar r0
@ 41 : Return
Здесь JumpLoop [31] — переход на 31 байт. Каждая итерация цикла проходит байткод от offset 4 до offset 35, и на JumpLoop бюджет уменьшается. Когда бюджет достигает нуля — V8 прерывает исполнение, чтобы перейти на следующий уровень оптимизации.
На Return (offset 41) бюджет тоже уменьшается — это покрывает случаи, когда функция вызывается много раз, но без длинных циклов внутри.
Весь линейный код между этими точками (арифметика, загрузка переменных, сравнения) — бюджет не трогает. V8 не считает каждый байткод, он считает только «значимые» точки — циклы и возвраты.
Для функции с горячим циклом (как в примере выше) бюджет расходуется в основном на jump. Чем короче тело цикла в байтах — тем больше итераций нужно, чтобы исчерпать бюджет. Чем длиннее — тем меньше.
Для функции, которая вызывается много раз без циклов (например, маленький хелпер) — бюджет расходуется на return. Нужно N вызовов, чтобы суммарно исчерпать бюджет.
Можно грубо прикинуть количество итераций до перехода в виде (K × bytecode_length) / стоимость_одной_итерации, но точная формула зависит от деталей реализации каждого тира (как именно считается «стоимость» прыжка, есть ли дополнительные коррекции). В целом, чем больше K и чем длиннее байткод — тем больше нужно работы для перехода.
Давай проследим полный путь функции от первого вызова до TurboFan.
Фаза 1: Ignition → Sparkplug (K = 8)
При самом первом вызове функции V8 вычисляет бюджет прерывания как 8 × bytecode_length. Этот бюджет очень маленький — он расходуется за считанные итерации цикла или несколько вызовов. Когда он уходит в ноль, V8 создаёт FeedbackVector — структуру, в которую будет записываться информация о типах значений, формах объектов и паттернах вызовов. Одновременно функция компилируется Sparkplug в baseline-код.
С этого момента начинается сбор type feedback — фундамент для всех последующих оптимизаций.
Фаза 2: Sparkplug → Maglev (K = 400)
После первого перехода бюджет поднимается до 400 × bytecode_length. Теперь функция исполняется в Sparkplug-коде и собирает feedback. Когда бюджет снова исчерпан, V8 проверяет, достаточно ли стабилен feedback, и если да — маркирует функцию для компиляции Maglev.
Посмотрим это в действии. Запускаем с флагом --trace-opt:
v8 --trace-opt --no-concurrent-recompilation example.js
[marking <JSFunction f> for optimization to MAGLEV,
ConcurrencyMode::kConcurrent, reason: hot and stable]
[compiling method <JSFunction f> (target MAGLEV),
mode: ConcurrencyMode::kSynchronous]
[completed compiling <JSFunction f> (target MAGLEV)
- took 0.000, 6.445, 0.001 ms]
Обратим внимание на reason: hot and stable — V8 прямо говорит, что функция и горячая (бюджет исчерпан), и стабильная (feedback не менялся).
Фаза 3: Maglev → TurboFan (K = 3000)
После компиляции Maglev бюджет поднимается до 3000 × bytecode_length. Функция теперь исполняется в оптимизированном Maglev-коде. Когда бюджет снова исчерпан — V8 маркирует функцию для TurboFan.
Но есть важный нюанс. Когда функция работает в Maglev-коде, бюджет списывается медленнее. При генерации кода для JumpLoop V8 умножает размер jump на коэффициент osr_from_maglev_interrupt_scale_factor (по дефолту 0.8):
Зачем? Maglev-код исполняется быстрее, чем интерпретатор — и если списывать бюджет с той же скоростью в байтах, прерывания будут срабатывать слишком часто по реальному времени. Коэффициент 0.8 компенсирует эту разницу.
При этом на Return масштабирование не применяется — там списывается полный offset.
OSR — когда функция горячая внутри
Есть один сценарий, который не покрывается обычным переходом: функция вызвана один раз, но внутри неё — очень горячий цикл. Количество выполнений самой функции не растёт, но бюджет всё равно тратится на jumps внутри цикла. В этом случае V8 использует On-Stack Replacement (OSR) — замену кода прямо во время исполнения, без выхода из функции.
Для OSR есть отдельные пороги: invocation_count_for_maglev_osr = 100 (для Maglev) и invocation_count_for_osr = 500 (для TurboFan). Они значительно ниже обычных порогов, потому что если функция застряла в длинном цикле — ждать 400 × L бюджета нет смысла, нужно оптимизировать как можно скорее.
Важный момент: флаг osr_from_maglev (OSR из Maglev-кода в TurboFan) по умолчанию выключен. Это значит, что функция, которая вошла в Maglev через OSR и никогда не «вернулась» из цикла — останется в Maglev. Для перехода в TurboFan обычно нужен многократный вызов функции, чтобы переход сработал через стандартный путь.
Стабильность feedback как скрытое условие
Бюджет — необходимое, но не достаточное условие для перехода. V8 также требует, чтобы type feedback был стабильным.
Что это значит? Каждый раз, когда V8 встречает property access (o.x), вызов функции или бинарную операцию — он записывает в FeedbackVector информацию о типах и формах объектов. Если при следующем исполнении того же места тип оказывается другим — это IC update (Inline Cache update), и V8 считает, что feedback ещё не стабилизировался.
В flag-definitions.h есть флаг minimum_invocations_after_ic_update = 500. Это означает: после последнего изменения IC нужно ещё 500 «единиц бюджета» (фактически — ещё один полный цикл бюджета с K = 500), прежде чем V8 согласится оптимизировать функцию дальше.
Это объясняет разницу в поведении:
- Функция с чистой Smi-арифметикой (
s += i / 2 + 1) — никаких IC updates, feedback стабилен сразу. Порог определяется значениемinvocation_count_for_maglev= 400. - Функция с property access, вызовами
Math.random()и доступом по индексу — IC updates при первых вызовах, и V8 ждёт стабилизации. Эффективный порог выше.
По итогу, если функция горячая, но feedback нестабилен — маркировки не будет, несмотря на исчерпанный бюджет.
Деоптимизация и обратный путь
Оптимизация — не дорога в один конец. Если предположения, на которых построен оптимизированный код, оказываются неверными — V8 выполняет деоптимизацию: отбрасывает оптимизированный код и возвращается на более низкий тир.
Когда это происходит? Например, функция была оптимизирована под предположение, что аргумент всегда число. На 10 001-м вызове передали строку — проверка в оптимизированном коде не проходит, и V8 деоптимизирует: восстанавливает состояние интерпретатора и продолжает исполнение оттуда.
После деоптимизации V8 не отказывается от оптимизации навсегда. Бюджет прерываний продолжает считаться, и через некоторое время функция снова будет рассмотрена для оптимизации — уже с обновлённым feedback, который учитывает новые типы.
Profile-Guided Tiering
В 2025 году Intel совместно с командой V8 разработала Profile-Guided Tiering — механизм, который запоминает поведение функций между запусками Chrome.
Идея: если функция в прошлый раз дошла до TurboFan и не деоптимизировалась — значит, в следующий раз её можно оптимизировать раньше и агрессивнее, возможно сразу в TurboFan, минуя обычные пороги. И наоборот: если функция стабильно деоптимизируется после перехода в Maglev — ей нужно больше времени на сбор feedback.
Результат: около +5% на Speedometer 3.
Итого
«Горячий код» в V8 — это результат работы системы с interrupt budget, которая учитывает длину байткода функции, частоту циклов и вызовов, стабильность type feedback и историю деоптимизаций.
Конкретные числа (8, 400, 3000 и т.д.) не стоит заучивать — они подбираются командой V8 вручную, периодически меняются и зависят от платформы (на Android, например, пороги другие). Важнее понимать порядок величин и саму механику: переход в Sparkplug происходит почти мгновенно, в Maglev — после сотен единиц бюджета, в TurboFan — после тысяч. И что бюджет — это не счётчик вызовов, а произведение коэффициента на длину байткода, которое списывается на обратных переходах и возвратах.
Все актуальные пороги и коэффициенты всегда можно найти в src/flags/flag-definitions.h. Любой из флагов можно переопределить через командную строку v8 или Node.js и посмотреть, как изменится поведение — и это, пожалуй, самое ценное: не запоминать числа, а уметь заглянуть в --trace-opt и увидеть, что V8 делает с конкретным кодом прямо сейчас.