JIT-компиляция: профилирование, горячие пути, деоптимизация
В прошлой статье мы построили виртуальную машину: байткод, диспетчер, стек кадров. Она работает, она портируема, она отлаживается — и она в десятки раз медленнее машинного кода, который умеет генерировать наш же бэкенд. Возникает естественный вопрос: зачем вообще интерпретировать, если компилировать мы уже умеем?
Ответ, который кажется очевидным — «компилировать заранее» — оказывается неполным. Настоящий сюжет этой статьи в другом. JIT-компилятор быстрее не потому, что он компилирует, а потому, что он знает то, чего статический компилятор знать не может. Он видел, какие типы реально приходили в эту функцию. Он видел, что вон та ветка не выполнилась ни разу за миллион итераций. Он знает, что у виртуального вызова на практике всегда один получатель. Статический компилятор про всё это может только гадать — и, будучи обязанным быть корректным на всех входах (мы разбирали, почему он обязан быть пессимистом), гадает консервативно.
JIT не гадает. Он измеряет, а потом делает ставку — генерирует код, корректный только при определённых условиях, и вставляет проверки этих условий. Когда ставка не сыграла, происходит самая интересная операция в этой статье: деоптимизация — машинный код выбрасывается, а исполнение продолжается с той же точки в интерпретаторе, как будто оптимизирующего компилятора никогда не существовало.
Сколько на самом деле стоит интерпретация
Прежде чем чинить, померим. Возьмём цикл на Mini из статьи про IR:
let s = 0;
let i = 0;
while i < n { s = s + i; i = i + 1; }
return s;
В нашей стековой ВМ тело цикла — восемь байткодов. Что происходит при исполнении каждого из них:
- Загрузка опкода из массива байткода — обращение в память (обычно попадает в L1).
- Диспетчеризация — косвенный переход по таблице или
switch. Это переход, который процессор должен предсказать, а предсказать его тяжело: у одного и того же места в коде десятки разных назначений. Промах предсказания — 15–20 тактов на современном x86 (как это работает в конвейере). - Работа с операндным стеком — push/pop, то есть чтения-записи в память вместо регистров.
- Собственно операция — одна инструкция
add, ради которой всё затевалось.
Полезной работы — один такт. Накладных расходов — десятки. Отсюда типичное соотношение: наивный байткод-интерпретатор в 20–50 раз медленнее оптимизированного нативного кода, шитый код (computed goto) — в 10–20 раз, потому что у каждой инструкции своя точка косвенного перехода и предсказатель начинает угадывать переходы по контексту.
Плюс два расхода, специфичных для динамических языков: боксинг (каждое число — объект в куче,
значит выделение, разыменование и работа для сборщика мусора)
и мегаморфная диспетчеризация (каждое a + b — поиск подходящей реализации по типам во время
исполнения).
Убрать это можно только одним способом — сгенерировав код, в котором ничего этого нет.
Экономика: когда компиляция окупается
JIT — это инвестиция. Мы тратим время процессора сейчас, чтобы сэкономить его потом. Пусть $t_{\text{инт}}$ — время одного исполнения куска кода в интерпретаторе, $t_{\text{комп}}$ — время исполнения скомпилированной версии, $C$ — стоимость компиляции. Компиляция окупится после $N$ исполнений, если
$$N \cdot (t_{\text{инт}} - t_{\text{комп}}) > C \quad \Longleftrightarrow \quad N > \frac{C}{t_{\text{инт}} - t_{\text{комп}}}$$
Из этой формулы следует всё устройство современных JIT-систем:
- Компилировать всё подряд невыгодно. Большая часть кода выполняется единицы раз: инициализация, обработка ошибок, конфигурация. Для них $N$ мало, и компиляция — чистый убыток. Эмпирика, устойчивая с 1980-х: 80–90% времени тратится в 10% кода, а часто и в 1%.
- Порог зависит от $C$. Дешёвый компилятор (быстрый, но глупый) окупается на сотнях исполнений, дорогой оптимизирующий — на десятках тысяч. Значит, порог должен быть не один.
- $C$ можно спрятать. Если компилировать в фоновом потоке, основной поток не платит за это ничего, кроме конкуренции за ядро.
Отсюда — многоуровневая компиляция (tiered compilation): несколько исполнителей одного и того же кода с разным соотношением «цена компиляции / качество кода», и автоматический переход между ними по мере роста счётчиков.
Классическая конфигурация HotSpot — пять уровней: интерпретатор (0), C1 без профилирования (1),
C1 с базовым профилем (2), C1 с полным профилем (3), C2 (4). Практический маршрут метода —
0 → 3 → 4; уровни 1 и 2 нужны, когда очередь C2 переполнена или метод тривиален. Пороги по
умолчанию: Tier3CompileThreshold = 2000, Tier4CompileThreshold = 15000. У V8 сегодня четыре
уровня: интерпретатор Ignition → однопроходный Sparkplug → среднеуровневый Maglev → оптимизирующий
TurboFan. У .NET два уровня плюс OSR. У SpiderMonkey — Baseline Interpreter → Baseline JIT →
WarpMonkey.
и на каждом обратном переходе Интерпретация --> ОчередьBaseline: счётчик > порога 1 ОчередьBaseline --> Baseline: фоновая компиляция готова Baseline --> Baseline: собирает профиль типов и ветвей Baseline --> ОчередьOpt: счётчик > порога 2 ОчередьOpt --> Optimized: фоновая компиляция готова Optimized --> Optimized: пиковая скорость Optimized --> Деоптимизация: guard провалился Деоптимизация --> Интерпретация: кадр реконструирован,
код помечен невалидным Интерпретация --> ОчередьOpt: разогрелся снова
(без сломанной спекуляции) Optimized --> Инвалидация: загружен новый класс /
изменена зависимость Инвалидация --> Интерпретация Optimized --> [*]: процесс завершился
Профилирование: как найти горячее
Есть два способа узнать, где программа проводит время, и JIT-системы используют оба.
Инструментирование — счётчики прямо в исполняемом коде. Точно, детерминированно, но замедляет код, который измеряет. Поэтому счётчики ставят на уровнях, которые и так медленные (интерпретатор, baseline), и убирают на оптимизирующем уровне.
Сэмплирование — таймер прерывает поток N раз в секунду и записывает, где был указатель
инструкции. Почти бесплатно, но даёт статистическую оценку с шумом
(доверительные интервалы здесь не
формальность: на редких методах сэмплер врёт). Так работал ранний HotSpot, так работают
профайлеры вроде perf и async-profiler.
Считать нужно две вещи, и это принципиально:
- Счётчик вызовов (invocation counter) — сколько раз функция была вызвана. Ловит горячие функции.
- Счётчик обратных переходов (backedge counter) — сколько раз исполнение прыгнуло назад, то есть
сколько итераций цикла прошло. Ловит горячие циклы внутри функции, которая вызвана всего один
раз. Без него
void main() { for (long i = 0; i < 1e10; i++) ... }никогда бы не скомпилировался.
Добавим оба в наш интерпретатор Mini:
# ---- байткод (из статьи про ВМ): (op, arg) ----
# const k | load slot | store slot | add | lt | jz pc | jmp pc | ret
PROG = [
("const", 0), ("store", 0), # s = 0
("const", 0), ("store", 1), # i = 0
("load", 1), ("load", 2), ("lt",), ("jz", 17), # while i < n
("load", 0), ("load", 1), ("add",), ("store", 0), # s = s + i
("load", 1), ("const", 1), ("add",), ("store", 1), # i = i + 1
("jmp", 4),
("load", 0), ("ret",),
]
HOT_LOOP = 2000 # порог для обратных переходов
def interp(prog, n, jit_cache):
v = [0, 0, n] # слоты: s, i, n
stack, pc = [], 0
hotness = {} # цель обратного перехода -> счётчик
while True:
if pc in jit_cache: # вход в скомпилированный код
pc = jit_cache[pc](v) # вернёт pc бокового выхода
continue
op = prog[pc]; pc += 1; k = op[0]
if k == "const": stack.append(op[1])
elif k == "load": stack.append(v[op[1]])
elif k == "store": v[op[1]] = stack.pop()
elif k == "add": b = stack.pop(); stack.append(stack.pop() + b)
elif k == "lt": b = stack.pop(); stack.append(stack.pop() < b)
elif k == "jz":
if not stack.pop(): pc = op[1]
elif k == "jmp":
target = op[1]
if target <= pc - 1: # обратный переход: считаем
hotness[target] = hotness.get(target, 0) + 1
if hotness[target] == HOT_LOOP:
trigger_compile(prog, target, jit_cache)
pc = target
elif k == "ret": return stack.pop()
Три инженерных нюанса, которые видно только на практике.
Где хранить счётчики. В байткоде? Тогда они портят кэш инструкций. В отдельной хеш-таблице?
Тогда каждый обратный переход — поиск. Промышленное решение: счётчики живут в структуре метода
(MethodData в HotSpot, FeedbackVector в V8), рядом с профилем типов, и адрес этой структуры
компилятор прошивает в код константой.
Затухание. Метод, вызванный 2000 раз за час, — не горячий. HotSpot периодически делит счётчики
пополам (-XX:+UseCounterDecay), чтобы «горячий» значило «горячий сейчас».
Порог — это не одно число. В HotSpot решение принимается по формуле, где участвуют счётчик вызовов $i$, счётчик обратных переходов $b$ и текущая длина очереди компиляции: чем длиннее очередь, тем выше порог. Иначе при старте приложения тысячи методов одновременно объявляются горячими и компилятор захлёбывается.
Baseline JIT: убираем диспетчеризацию
Первый уровень компиляции решает ровно одну задачу — убрать интерпретаторный оверхед, не тратя времени на оптимизации. Каждый байткод превращается в заранее заготовленный шаблон машинных инструкций (потому такой компилятор и называют шаблонным, template JIT). Никакого IR, никакого анализа, один линейный проход по байткоду. V8-овский Sparkplug компилирует примерно мегабайт исходника в секунду на ядро и не строит вообще никакого промежуточного представления.
Напишем настоящий baseline-компилятор для нашего байткода. Целевой «машинный код» — исходник Python,
который мы exec-нем: принцип ровно тот же (генерация кода на лету), а читать проще, чем байты.
Компилятор разбивает байткод на базовые блоки, ведёт символьный стек во время компиляции (а не
во время исполнения!) и порождает выражения:
BIN = {"add": "+", "lt": "<"}
def leaders_of(prog):
"""Начала базовых блоков: точка входа и всё, куда/откуда прыгают."""
ls = {0}
for pc, op in enumerate(prog):
if op[0] in ("jmp", "jz"):
ls.add(op[1]); ls.add(pc + 1)
return sorted(l for l in ls if l < len(prog))
def compile_func(prog, nslots=3):
ls = leaders_of(prog)
ends = {l: (ls[i + 1] if i + 1 < len(ls) else len(prog)) for i, l in enumerate(ls)}
out = ["def jitted(n):", f" v = [0] * {nslots}", " v[2] = n",
" b = 0", " while True:"]
first = True
for l in ls:
out.append(f" {'if' if first else 'elif'} b == {l}:"); first = False
st, body, ntmp, pc = [], [], 0, l # st — СИМВОЛЬНЫЙ стек времени компиляции
while pc < ends[l]:
op = prog[pc]; k = op[0]; pc += 1
if k == "const": st.append(repr(op[1]))
elif k == "load": st.append(f"v[{op[1]}]")
elif k == "store": body.append(f"v[{op[1]}] = {st.pop()}")
elif k in BIN:
r, lf = st.pop(), st.pop()
t = f"t{ntmp}"; ntmp += 1
body.append(f"{t} = {lf} {BIN[k]} {r}"); st.append(t)
elif k == "jz": body.append(f"b = {op[1]} if not {st.pop()} else {pc}")
elif k == "jmp": body.append(f"b = {op[1]}")
elif k == "ret": body.append(f"return {st.pop()}")
if not body or not body[-1].startswith(("b =", "return")):
body.append(f"b = {ends[l]}")
out += [" " + s for s in body]
src = "\n".join(out)
ns = {}; exec(src, ns)
return ns["jitted"], src
Результат компиляции нашего цикла (это реальный вывод, а не иллюстрация):
def jitted(n):
v = [0] * 3
v[2] = n
b = 0
while True:
if b == 0:
v[0] = 0
v[1] = 0
b = 4
elif b == 4:
t0 = v[1] < v[2]
b = 17 if not t0 else 8
elif b == 8:
t0 = v[0] + v[1]
v[0] = t0
t1 = v[1] + 1
v[1] = t1
b = 4
elif b == 17:
return v[0]
Замер на n = 300 000: интерпретатор — 0.31 с, скомпилированная версия — 0.04 с, ускорение в 7.8
раза. Ни одной оптимизации мы не сделали: операндный стек исчез (он был разрешён на этапе
компиляции), диспетчеризация по опкодам исчезла (осталась только по блокам), обращения к байткоду
исчезли. Это и есть весь эффект baseline-уровня, и он даётся почти даром.
Сложность компиляции — $O(\lvert \text{байткод} \rvert)$ по времени и памяти, один проход. Именно поэтому baseline можно запускать хоть при первом вызове.
Что такое машинный код на самом деле
Генерация Python-исходника — честная аналогия, но настоящий JIT пишет байты и передаёт на них
управление. Это не магия, а три системных вызова. Покажем на живом примере: скомпилируем
f(n) = n * n + k в машинный код x86-64 и вызовем его из Python.
import ctypes, mmap, struct
def emit(k: int) -> bytes:
"""f(n) = n*n + k. Соглашение System V AMD64: аргумент в RDI, результат в RAX."""
return (b"\x48\x89\xF8" # mov rax, rdi
b"\x48\x0F\xAF\xC0" # imul rax, rax
b"\x48\x05" + struct.pack("<i", k) + # add rax, imm32
b"\xC3") # ret
def install(code: bytes):
"""Кладём байты в анонимную страницу и переводим её из W в X."""
buf = mmap.mmap(-1, len(code), prot=mmap.PROT_READ | mmap.PROT_WRITE)
buf.write(code)
addr = ctypes.addressof(ctypes.c_char.from_buffer(buf))
libc = ctypes.CDLL(None, use_errno=True)
PROT_READ, PROT_EXEC, PAGE = 0x1, 0x4, 4096
page = addr & ~(PAGE - 1)
if libc.mprotect(ctypes.c_void_p(page),
ctypes.c_size_t(len(code) + addr - page),
PROT_READ | PROT_EXEC) != 0:
raise OSError(ctypes.get_errno(), "mprotect")
fn = ctypes.cast(addr, ctypes.CFUNCTYPE(ctypes.c_int64, ctypes.c_int64))
fn._page = buf # держим страницу живой, иначе GC её отберёт
return fn
f = install(emit(3))
print([f(n) for n in range(6)]) # [3, 4, 7, 12, 19, 28]
Четырнадцать байт — и у нас есть функция, которой не было при старте процесса. Обратите внимание на три детали, которые в промышленном JIT занимают заметную часть кода.
W^X. Страница не бывает одновременно записываемой и исполняемой — это требование безопасности
(см. память в ОС). Мы пишем в
RW, потом переключаем в RX. На Apple Silicon переключение делается через
pthread_jit_write_protect_np пофайлово для потока, на OpenBSD — обязательный mmap с MAP_STACK
и mimmutable. JIT, который просит RWX, — подарок атакующему: техника JIT spraying (Дион
Блазакис, 2010) заполняет кучу «безобидными» константами, чьи байты при сдвиге на единицу образуют
шелл-код.
Сброс кэша инструкций. На x86 кэши когерентны и ничего делать не надо. На ARM/RISC-V —
обязательно, иначе процессор исполнит то, что лежало по этим адресам раньше. Забытый
__builtin___clear_cache — классический «баг, который воспроизводится только на телефоне».
Соглашение о вызовах. Мы обязаны положить результат в RAX, не испортить callee-saved регистры
и выровнять стек — ровно тот ABI, который бэкенд соблюдает
при статической компиляции. JIT ничем не отличается: он просто пишет то же самое в память вместо
файла.
Обратная связь по типам: главное преимущество
Всё вышеперечисленное статический компилятор умеет и без нас. Настоящее преимущество JIT начинается здесь.
Возьмём динамический язык. Что такое a.x? В общем случае — поиск свойства по имени в
хеш-таблице объекта, с обходом цепочки прототипов. Десятки тактов. Но на практике в конкретной
точке программы объекты почти всегда одной и той же формы. Это наблюдение, сделанное Дойчем и
Шиффманом для Smalltalk-80 в 1984 году, лежит в основе всех быстрых динамических рантаймов.
Реализуется оно двумя механизмами.
Скрытые классы (hidden classes, shapes, maps): объекты с одинаковым набором полей в одинаковом
порядке разделяют одно описание, в котором имя поля отображается в смещение. Тогда a.x
превращается в «проверить форму + прочитать по фиксированному смещению».
Инлайн-кэш: в самой точке вызова запоминается результат прошлого поиска.
class Shape:
"""Скрытый класс: имя поля -> смещение. Переходы кэшируются и разделяются."""
def __init__(self, fields=()):
self.fields = tuple(fields)
self.offsets = {f: i for i, f in enumerate(self.fields)}
self.transitions = {}
def add(self, name):
s = self.transitions.get(name)
if s is None:
s = Shape(self.fields + (name,))
self.transitions[name] = s # тот же порядок полей -> та же форма
return s
ROOT = Shape()
class Obj:
__slots__ = ("shape", "vals")
def __init__(self, **kw):
self.shape, self.vals = ROOT, []
for k, v in kw.items():
self.shape = self.shape.add(k); self.vals.append(v)
class InlineCache:
"""Кэш живёт в конкретной точке программы, а не в объекте."""
def __init__(self, name, limit=4):
self.name, self.limit = name, limit
self.entries = [] # [(shape, offset)]
self.state = "uninitialized"
self.hits = self.misses = 0
def get(self, obj):
sh = obj.shape
for s, off in self.entries: # 1..limit сравнений указателей
if s is sh:
self.hits += 1
return obj.vals[off] # быстрый путь: чтение по смещению
self.misses += 1
off = sh.offsets[self.name] # медленный путь: настоящий поиск
if self.state != "megamorphic":
self.entries.append((sh, off))
if len(self.entries) == 1: self.state = "monomorphic"
elif len(self.entries) <= self.limit: self.state = "polymorphic"
else: self.entries.clear(); self.state = "megamorphic"
return obj.vals[off]
Проверка:
a, b = Obj(x=1, y=2), Obj(x=3, y=4) # одна форма
c = Obj(y=9, x=7) # другой порядок полей -> ДРУГАЯ форма
ic = InlineCache("x")
[ic.get(o) for o in (a, b, a, b)] # -> [1, 3, 1, 3], state = monomorphic, 3 попадания
ic.get(c) # -> 7, state = polymorphic
for o in (Obj(**{f"f{k}": k for k in range(i)}, x=i) for i in range(10)):
ic.get(o) # -> state = megamorphic, кэш сдался
Три состояния инлайн-кэша — центральное понятие всей темы:
| Состояние | Сколько форм видел сайт | Что делает JIT |
|---|---|---|
| мономорфный | 1 | одна проверка формы + чтение по константному смещению; вызов можно девиртуализовать и заинлайнить |
| полиморфный | 2–4 | цепочка проверок; инлайнинг всех веток, если они маленькие |
| мегаморфный | больше лимита | кэш выключается, остаётся общий медленный поиск; инлайнинг невозможен |
Отсюда практическое правило, которое стоит знать любому, кто пишет на JS/Python/Ruby/Java: однотипность горячего кода — не эстетика, а производительность. Массив, где лежат объекты пяти разных форм, превращает каждый доступ к полю в мегаморфный сайт, а каждый вызов метода — в невозможность инлайнинга. Инициализируйте все поля в конструкторе и в одном порядке; не добавляйте поля потом; не смешивайте типы в горячих коллекциях.
Полиморфные инлайн-кэши описаны Хёльцле, Чемберсом и Унгаром в 1991 году для языка Self (статья); всё, что делают V8 и SpiderMonkey сегодня, — прямое развитие той работы.
Спекуляция и guard’ы
Профиль превращается в скорость через один приём. JIT берёт наблюдение («здесь всегда были целые числа») и генерирует код, корректный только при этом условии, — а само условие проверяет одной дешёвой инструкцией. Такая проверка называется guard (страж, охранник).
; до спекуляции: a + b в динамическом языке
call generic_add ; ~50 тактов: разбор типов, боксинг, вызов
; после: профиль сказал "оба операнда — int"
test rax, 1 ; guard: младший бит тега
jnz deopt_0x2f4 ; не int -> выйти из скомпилированного кода
test rbx, 1
jnz deopt_0x2f4
add rax, rbx ; 1 такт
jo deopt_0x2f4 ; guard на переполнение
Что здесь важно:
- Guard не «обрабатывает ошибку». Он выходит из скомпилированного кода. Отдельного медленного пути в этой функции нет — есть возврат в интерпретатор. Это радикально упрощает генерируемый код: компилятор компилирует только один сценарий.
- Guard почти бесплатен. Предсказатель переходов процессора видит, что этот переход не берётся никогда, и стоимость стремится к нулю. Дорого не проверить — дорого ошибиться.
- Guard’ы двигают. Проверку из тела цикла выносят наружу (
loop-invariant guard motion), а несколько проверок одного факта схлопывают в одну. Проверку границ массива вfor (i = 0; i < a.length; i++)компилятор доказывает один раз для всего цикла — это и есть bounds-check elimination.
На чём спекулируют реальные движки:
| Спекуляция | Откуда факт | Что открывает |
|---|---|---|
| тип значения | инлайн-кэш | нативная арифметика без боксинга |
| форма объекта | скрытые классы | доступ к полю по смещению |
| единственный получатель вызова | профиль + анализ иерархии классов (CHA) | девиртуализация и инлайнинг |
| ветка не выполняется | счётчик ветвей = 0 | целая ветка выкидывается из кода |
значение не null |
профиль | нет проверок на каждом обращении |
| индекс в границах | анализ диапазонов | нет bounds check |
| объект не убегает | escape-анализ | scalar replacement — объект вообще не выделяется |
| поле статически финально | загруженные классы | константа вместо чтения из памяти |
Ключевой момент про CHA: пока в JVM загружен ровно один подкласс интерфейса, вызов через интерфейс можно считать прямым и инлайнить. Если позже загрузится второй — весь скомпилированный код, опиравшийся на это, обязан быть инвалидирован. Это «guard без инструкции»: проверки в коде нет, её роль играет запись в таблице зависимостей рантайма.
Экономику спекуляции удобно записать через вероятность промаха $p$:
$$E\lbrack t \rbrack = (1 - p) \cdot t_{\text{быстр}} + p \cdot (t_{\text{деопт}} + t_{\text{медл}})$$
При $t_{\text{деопт}}$ порядка микросекунд и $t_{\text{быстр}}$ порядка наносекунд спекуляция выгодна, пока $p$ мало — примерно до $10^{-3}$. Если промахи чаще, спекуляцию надо отключать, и рантайм обязан это уметь (см. ниже про циклы деоптимизации).
Деоптимизация: как отменить оптимизацию на лету
Вот мы стоим посреди оптимизированного кода. Guard провалился. Что теперь?
Наивный ответ «выбросить исключение» не работает, потому что состояния программы, к которому можно
вернуться, физически не существует. Компилятор заинлайнил три функции в одну, удалил объект,
который никогда не убегал, разложил переменные по регистрам, а часть вообще выкинул как мёртвые.
Кадра g(), в который надо вернуться, нет — он был растворён.
Значит, его надо воссоздать. Это и есть деоптимизация (Хёльцле, Чемберс, Унгар, PLDI 1992 — «Debugging Optimized Code with Dynamic Deoptimization»; статья, между прочим, про отладчики, а не про скорость).
Механика такая. При компиляции в каждой точке, где может произойти деоптимизация, компилятор записывает дескриптор: для каждого виртуального кадра (то есть для каждой заинлайненной функции) — номер байткода, на котором мы «находимся», и для каждой локальной переменной — где её текущее значение (регистр, spill-слот, константа или «объект удалён, вот рецепт сборки»). Эти таблицы лежат рядом с кодом и в горячем пути не стоят ничего: их читают только при деоптимизации.
escape-анализом (scalar replacement) Heap-->>RT: адреса созданных объектов RT->>Int: построить кадры f, g, h снизу вверх RT->>Opt: пометить код невалидным RT-->>Int: передать управление на bci = 14 Int->>Int: продолжить, как будто JIT не было Int->>RT: записать причину: "add:x — не int" RT->>RT: перекомпилировать без этой спекуляции
Тонкости, которые делают тему по-настоящему сложной.
Safepoint’ы. Деоптимизировать можно не в любой точке, а только там, где состояние согласовано и описано. Это те же safepoint’ы, на которых останавливается сборщик мусора: у них общая инфраструктура и общие карты значений (какие регистры содержат указатели). Отсюда неприятный практический эффект: цикл без safepoint’а не даёт ни деоптимизировать, ни собрать мусор — легендарные «длинные паузы на счётном цикле» в JVM.
Материализация виртуальных объектов. Escape-анализ удалил new Point(x, y), разложив его на два
регистра. Но интерпретатор, в который мы возвращаемся, ожидает настоящую ссылку. Значит, при
деоптимизации объект приходится создать — со всеми последствиями: аллокация, возможный запуск GC,
возможный OutOfMemoryError в момент, когда его никто не ждал.
Ленивая и жадная деоптимизация. Если инвалидировали код, в чьих кадрах прямо сейчас исполняются другие потоки, выбрасывать его нельзя. Помечают «не входить», а кадры чинят по мере возврата (lazy deopt). Жадная деоптимизация (сразу переписать кадры всех потоков) нужна редко — например, когда отладчик поменял значение переменной.
Рост стека. Один физический кадр разворачивается в N интерпретаторных, каждый больше исходного.
Деоптимизация глубоко заинлайненного кода способна вызвать StackOverflowError в месте, где в
исходнике нет никакой рекурсии.
Продолжим наш пример: полный цикл «спекуляция → guard → деопт → перекомпиляция» в тридцати строках.
class Deopt(Exception):
def __init__(self, site, state): self.site, self.state = site, state
class Function:
THRESHOLD = 50
def __init__(self, name, body):
self.name, self.interp = name, body
self.calls, self.types, self.failed = 0, {}, set()
self.code, self.version = None, 0
def record_type(self, site, t): # это делает baseline-уровень
self.types.setdefault(site, {}).setdefault(t, 0)
self.types[site][t] += 1
def monomorphic(self, site):
d = self.types.get(site, {})
return next(iter(d)) if len(d) == 1 and site not in self.failed else None
def __call__(self, *args):
self.calls += 1
if self.code is None and self.calls > self.THRESHOLD:
self.code = self.compile()
if self.code is not None:
try:
return self.code(*args)
except Deopt as d:
self.failed.add(d.site) # спекуляция мертва навсегда
self.code = None # машинный код выброшен
self.version += 1
self.calls = 0 # разогреваемся заново
return self.interp(self, *args) # доигрываем в интерпретаторе
return self.interp(self, *args)
def compile(self):
if self.monomorphic("add:x") is int:
def fast(x, y):
if type(x) is not int: # <-- guard
raise Deopt("add:x", (x, y)) # <-- выход, а не обработка
return x + y + 1 # спекулятивно быстрый путь
return fast
def generic(x, y):
return x + y + 1 # без спекуляции
return generic
def body(fn, x, y):
fn.record_type("add:x", type(x))
return x + y + 1
f = Function("addone", body)
for i in range(100): f(i, 1)
# код = fast, версия 0
f(1.5, 1) # guard провалился -> Deopt -> интерпретатор, версия 1
for i in range(100): f(i, 1)
# код = generic, версия 1 — перекомпилировали БЕЗ сломанной спекуляции
Последняя строка — самое важное во всей конструкции. Деоптимизация обязана оставлять след.
Иначе рантайм скомпилирует ту же спекуляцию снова, снова провалится, снова деоптимизируется — и
приложение войдёт в цикл деоптимизации: время уходит целиком на компиляцию, скорость падает ниже
интерпретатора. HotSpot ведёт PerBytecodeTrapLimit и PerMethodTrapLimit; после нескольких
провалов конкретная спекуляция в конкретном месте объявляется запрещённой, а после многих — метод
получает пожизненный запрет на этот вид оптимизации. Диагностика:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation покажет строки с made not entrant и
made zombie, в Node — node --trace-deopt.
OSR: подмена кадра прямо во время исполнения
Метод скомпилирован — прекрасно, при следующем вызове войдём в новый код. А если метод один и вызывается один раз, а внутри цикл на десять миллиардов итераций? Ждать следующего вызова некого.
On-Stack Replacement — перенос исполнения из кадра одной версии кода в кадр другой версии посреди работы. Технически это деоптимизация наоборот: рантайм читает состояние интерпретаторного кадра, раскладывает переменные по регистрам согласно тому, что ожидает скомпилированный код, и прыгает в его середину. Компилятор для этого генерирует специальную точку входа — цикл, скомпилированный так, что в него можно войти не с начала функции.
OSR — причина, по которой микробенчмарк вида «один метод, один длинный цикл» ведёт себя не как реальный код: OSR-версия компилируется с искажённым профилем (пролог функции исполнялся один раз) и часто медленнее обычной. В .NET OSR появился только в 7.0 — до этого включённая многоуровневая компиляция могла навсегда оставить длинный цикл в неоптимизированном tier-0.
Трассирующий JIT: другая единица компиляции
Всё вышеописанное — method JIT: единица компиляции — функция. Существует альтернатива, где единица компиляции — трасса: линейная последовательность инструкций, реально исполненная во время работы, сквозь границы функций и итераций цикла.
Идея: когда счётчик обратного перехода перевалил порог, интерпретатор переключается в режим записи и запоминает всё, что исполняет, пока не вернётся в исходную точку. Получается прямая линия без ветвлений — потому что каждая развилка записана в том направлении, в котором пошла, а альтернатива превращена в guard с боковым выходом (side exit).
> порога?} B -->|нет| A B -->|да| C[Начать запись трассы
с этой точки] C --> D[Интерпретировать и записывать
каждую операцию] D --> E{вернулись
в начало?} E -->|нет, ветвление| F[Записать guard
+ боковой выход] F --> D E -->|нет, слишком длинно| G[Прервать запись:
трасса не замкнулась] G --> A E -->|да| H[Оптимизировать линейный код:
константы, LICM, устранение guard'ов] H --> I[Скомпилировать и повесить
на точку входа] I --> J[Исполнять трассу] J -->|guard провалился| K{боковой выход
стал горячим?} K -->|нет| A K -->|да| L[Записать ветку от бокового выхода:
дерево трасс] L --> J
Почему это выгодно: линейный код оптимизировать несравнимо проще. Нет φ-функций, нет слияний потока управления, вся классика из статьи про оптимизации — свёртка констант, нумерация значений, вынос инвариантов из цикла — работает на прямой цепочке почти тривиально. Трасса сама по себе является результатом агрессивного инлайнинга: если внутри цикла был вызов, он просто оказался записан в трассу.
Добавим трассирующий JIT в наш интерпретатор. Ключевая часть — компиляция записанной трассы, где каждый условный переход становится guard’ом:
def compile_trace(ops, anchor):
"""ops — список (pc, инструкция, направление_перехода); anchor — начало цикла."""
st, body, ntmp = [], [], 0
for pc, op, taken in ops:
k = op[0]
if k == "const": st.append(repr(op[1]))
elif k == "load": st.append(f"v[{op[1]}]")
elif k == "store": body.append(f"v[{op[1]}] = {st.pop()}")
elif k in BIN:
r, lf = st.pop(), st.pop()
t = f"t{ntmp}"; ntmp += 1
body.append(f"{t} = {lf} {BIN[k]} {r}"); st.append(t)
elif k == "jz":
c = st.pop()
# трасса пошла в направлении `taken`; guard срабатывает на другом
exit_pc = (pc + 1) if taken else op[1]
body.append(f"if {'' if taken else 'not '}{c}: return {exit_pc} # боковой выход")
src = "def trace(v):\n while True:\n" + "\n".join(" " + s for s in body)
ns = {}; exec(src, ns)
f = ns["trace"]; f.src = src
return f
Записав наш цикл после двух итераций, компилятор выдаёт (снова — настоящий вывод):
def trace(v):
while True:
t0 = v[1] < v[2]
if not t0: return 17 # боковой выход обратно в интерпретатор, pc = 17
t1 = v[0] + v[1]
v[0] = t1
t2 = v[1] + 1
v[1] = t2
Восемь байткодов с диспетчеризацией превратились в шесть строк прямого кода с одной проверкой.
while True в начале — это то самое замыкание трассы на себя: выход из неё возможен только через
guard.
Дальше — деревья трасс. Если боковой выход сам стал горячим (например, if в теле цикла иногда
всё-таки берётся), с него записывается новая трасса и пришивается к существующей. Так LuaJIT
(пороги по умолчанию: hotloop = 56 итераций, hotexit = 10 срабатываний бокового выхода)
достигает производительности, сравнимой с C, на численном коде.
Слабое место подхода — код с непредсказуемым ветвлением: дерево трасс разрастается, каждая ветка компилируется отдельно, взрыв кода съедает выигрыш. Именно поэтому Mozilla, начавшая с трассирующего TraceMonkey (2008), к 2011 году вернулась к методному IonMonkey. Трассировка выжила там, где циклы простые и численные: LuaJIT и PyPy.
PyPy заслуживает отдельного упоминания, потому что он трассирует не программу пользователя, а интерпретатор. Это мета-трассировка: вы пишете интерпретатор своего языка на RPython, а PyPy генерирует из него JIT. Трасса записывается через цикл диспетчеризации интерпретатора, а константы вроде «текущего опкода» становятся compile-time-константами и исчезают. Это первая проекция Футамуры (1971) в промышленном исполнении: специализация интерпретатора по программе даёт компилятор. Ту же идею с другой стороны реализует Truffle/Graal — там интерпретатор пишут как дерево узлов на Java, а частичное вычисление превращает его в машинный код. Связь с частичным применением и стратегиями вычисления здесь не метафорическая, а буквальная: это тот же приём специализации, что и каррирование, только применённый к интерпретатору.
Где JIT проигрывает и что с этим делают
Честный список слабостей.
Разогрев. Первые секунды приложение работает в разы медленнее. Для сервера, живущего месяцами, —
неважно. Для CLI-утилиты, живущей 40 мс, — катастрофа. Для serverless с холодным стартом — деньги.
Ответы индустрии: AppCDS и архивы профилей в JVM (проект Leyden), ReadyToRun и Native AOT в
.NET, снапшоты кучи в V8 (--snapshot-blob), кэш байткода в браузерах.
Память и код-кэш. Скомпилированный код надо где-то держать. В HotSpot код-кэш по умолчанию
240 МБ; при переполнении JIT отключается целиком и приложение продолжает работать в
интерпретаторе — с падением скорости в десятки раз и строчкой CodeCache is full в логе, которую
никто не читает. Профилактика: -XX:ReservedCodeCacheSize, -XX:+UseCodeCacheFlushing, мониторинг
java.lang:type=MemoryPool,name=CodeHeap*.
Недетерминированность. Одна и та же программа на одних и тех же данных может исполняться разным кодом в зависимости от того, в каком порядке прогрелись методы. Это делает бенчмаркинг нетривиальным, а воспроизведение багов производительности — мучительным.
Невозможность на закрытых платформах. iOS запрещает RWX-страницы обычным приложениям, поэтому
JavaScriptCore внутри стороннего приложения работает интерпретатором, а .NET на iOS — только AOT.
Безопасность. Генерация исполняемого кода на лету — принципиально более широкая поверхность атаки. Ошибка в JIT-компиляторе (неверно выведенный тип, пропущенный guard) — это готовый примитив записи в память. Значительная доля эксплойтов браузеров последних лет — баги TurboFan и Ion; ответ V8 — «песочница» с проверками целостности и отдельный процесс.
Как это выглядит в реальных движках
| Движок | Уровни | Единица | Особенность |
|---|---|---|---|
| HotSpot (JVM) | интерпретатор → C1 (3 режима) → C2 | метод | CHA-девиртуализация, escape-анализ, -XX:+PrintCompilation |
| GraalVM | те же, но C2 заменён на Graal | метод | компилятор написан на Java, Truffle-интерпретаторы, Native Image как альтернатива |
| V8 | Ignition → Sparkplug → Maglev → TurboFan | метод | скрытые классы, FeedbackVector, --trace-opt --trace-deopt |
| SpiderMonkey | Baseline Interp → Baseline JIT → Warp/Ion | метод | CacheIR — единое описание инлайн-кэшей для всех уровней |
| JavaScriptCore | LLInt → Baseline → DFG → FTL | метод | четыре уровня, FTL использует собственный B3 (раньше LLVM) |
| LuaJIT | интерпретатор на ассемблере → трассы | трасса | hotloop=56, деревья трасс, NaN-boxing |
| PyPy | интерпретатор → мета-трассировка | трасса | JIT генерируется из интерпретатора на RPython |
| CPython 3.13+ | интерпретатор → tier-2 (micro-ops) → copy-and-patch JIT | суперблок | специализирующий интерпретатор (PEP 659) как источник профиля |
| .NET | tier-0 → tier-1 (+ OSR, R2R) | метод | TieredPGO собирает профиль на tier-0, guarded devirtualization |
| Ядро Linux | — | программа | eBPF-JIT: верификатор доказывает безопасность до компиляции |
Обратите внимание на CPython: специализирующий адаптивный интерпретатор (PEP 659) — это JIT-мышление
без генерации машинного кода. Байткод переписывается на лету: BINARY_OP заменяется на
BINARY_OP_ADD_INT с guard’ом и «квикенингом» — и это даёт заметное ускорение при нулевой
инфраструктуре кодогенерации. Хороший урок: главное в JIT — не эмиссия байтов, а спекуляция.
Как измерять и не обмануться
JIT ломает наивные измерения сильнее, чем что-либо ещё в рантайме. Правила выживания:
- Разогрев обязателен. Первые сотни итераций измеряют компилятор, а не код. В JVM пользуйтесь JMH, в JS — прогревом вручную с несколькими фазами.
- Пустой цикл может исчезнуть. Если результат не используется, DCE удалит вычисление целиком, и
вы измерите скорость
nop. JMH решает это черезBlackhole. - Мономорфность бенчмарка врёт. Вы прогнали бенчмарк с одной реализацией интерфейса — JIT заинлайнил её. В проде реализаций три, сайт полиморфный, инлайнинга нет, и ваши числа не значат ничего. Прогоняйте с реалистичным набором типов.
- Смотрите, что скомпилировалось.
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInliningв JVM, JITWatch как GUI поверх логов,node --trace-opt --trace-deopt --trace-icв Node,perf+-XX:+PreserveFramePointerдля профиля с нативными кадрами. - Ищите деоптимизации в логах. Повторяющиеся
made not entrantдля одного метода — почти всегда признак цикла деоптимизации и потерянных процентов.
Типичные ошибки
- Компилировать слишком рано. Порог, подобранный под бенчмарк, обычно означает, что на реальном приложении компилятор жжёт CPU на коде, который выполнится трижды.
- Спекуляция без деоптимизации. Спекулятивный код, из которого невозможно выйти, — это не JIT, а генератор ошибок. Инфраструктуру деоптимизации проектируют до первой спекуляции, а не после.
- Забыть записать причину деопта. Прямая дорога в цикл «скомпилировал → провалился → скомпилировал».
- Guard, вынесенный без доказательства. Перенос проверки из цикла наружу корректен, только если доказано, что внутри цикла проверяемое не меняется. Ошибка здесь даёт исполнение спекулятивного кода на данных, для которых он неверен, — то есть уязвимость.
- Дескриптор деоптимизации, не обновлённый после оптимизации. Проход переставил инструкции, таблица «где лежит переменная» устарела — и деоптимизация восстановит мусор. Это самый неприятный класс багов в JIT: воспроизводится раз в неделю на чужой нагрузке.
- Игнорирование safepoint’ов в циклах. Счётный цикл без safepoint’а блокирует и деоптимизацию, и сборку мусора, и остановку потока.
- Мегаморфный горячий код. Со стороны прикладного программиста — самая частая ошибка: разнотипные коллекции, поля, добавляемые после конструктора, «универсальные» объекты-мешки.
RWX-страницы «чтобы проще». Работает, пока не приходит security-аудит.
Мини-итог
JIT — это не «компилятор, запущенный попозже». Это система принятия решений в условиях неопределённости, у которой есть три части. Профиль отвечает на вопрос «что здесь происходит на самом деле»: счётчики вызовов и обратных переходов находят горячее, инлайн-кэши и скрытые классы находят типы. Спекуляция превращает наблюдение в код: компилируется один сценарий из многих, а все остальные заменяются guard’ами — дешёвыми проверками, которые процессор предсказывает и почти не замечает. Деоптимизация делает спекуляцию безопасной: по метаданным, записанным при компиляции, рантайм воссоздаёт кадры интерпретатора, материализует удалённые объекты и продолжает исполнение так, будто оптимизирующего компилятора не существовало. Уберите любую из трёх частей — и система развалится: без профиля спекулировать не на чем, без guard’ов спекуляция некорректна, без деоптимизации guard’у некуда выходить.
Наш baseline-компилятор дал 7.8× просто убрав диспетчеризацию, трассирующий JIT свернул тело цикла в
шесть строк прямого кода, а четырнадцать байтов, записанных в mmap-страницу, оказались настоящей
функцией. Всё это — те же фазы, что мы строили весь трек (IR,
оптимизации, кодоген),
только запущенные внутри работающей программы и вооружённые знанием, которого у статического
компилятора нет и быть не может.
Источники
- John Aycock, «A Brief History of Just-In-Time», ACM Computing Surveys, 2003 — лучшая обзорная работа по теме.
- Hölzle, Chambers, Ungar, «Debugging Optimized Code with Dynamic Deoptimization», PLDI 1992 — статья, с которой началась деоптимизация.
- Hölzle, Chambers, Ungar, «Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches», ECOOP 1991.
- Deutsch, Schiffman, «Efficient Implementation of the Smalltalk-80 System», POPL 1984 — инлайн-кэши.
- Gal и др., «Trace-based Just-in-Time Type Specialization for Dynamic Languages», PLDI 2009 — TraceMonkey и трассирующая модель.
- Bolz и др., «Tracing the Meta-Level: PyPy’s Tracing JIT Compiler», ICOOOLPS 2009 — мета-трассировка.
- Würthinger и др., «One VM to Rule Them All», Onward! 2013 — Truffle, частичное вычисление, проекции Футамуры на практике.
- Documentation: HotSpot Compiler и исходники
hotspot/share/compiler— пороги, очереди, трапы. - V8 blog: Sparkplug, Maglev, TurboFan docs — самая доступная документация современного движка.
- LuaJIT wiki: Optimizations и рассылка проекта — практика трассирующего JIT из первых рук.
- .NET: Tiered compilation и design doc по OSR.
- PEP 659 — Specializing Adaptive Interpreter — спекуляция без генерации машинного кода.
- Chris Seaton, «Understanding How Graal Works» — лучшее объяснение частичного вычисления «на пальцах».
- Dion Blazakis, «Interpreter Exploitation: Pointer Inference and JIT Spraying», Black Hat DC 2010 — почему W^X не роскошь.
- Robert Nystrom, Crafting Interpreters — если сначала хочется довести до ума интерпретатор, а JIT оставить на потом.
Что дальше
Мы прошли путь от текста программы до машинного кода, который рантайм генерирует и выбрасывает на лету. Все технические решения приняты — и теперь возвращается вопрос, с которого всё начиналось, но уже с другой стороны: а каким должен быть сам язык? Какой синтаксис читается, а какой только кажется читаемым; почему сообщение об ошибке важнее, чем половина оптимизаций; как спроектировать маленький предметный язык так, чтобы им хотелось пользоваться, и когда правильный ответ — не делать язык вовсе. Инженерия компилятора закончилась, начинается проектирование.