Мы часто слышим такие термины как «горячий код», особенно когда говорим про 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 байт.

Бюджет уменьшается не на каждом байткоде, а только в двух местах:

  1. На jump — конец тела цикла, инструкция JumpLoop. Списывается значение, пропорциональное размеру прыжка назад в байтах.
  2. На 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 делает с конкретным кодом прямо сейчас.