Компиляторы и языки JIT-компиляция: профилирование, горячие пути, деоптимизация
0%

JIT-компиляция: профилирование, горячие пути, деоптимизация

JIT-компиляция: профилирование, горячие пути, деоптимизация

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

Ответ, который кажется очевидным — «компилировать заранее» — оказывается неполным. Настоящий сюжет этой статьи в другом. JIT-компилятор быстрее не потому, что он компилирует, а потому, что он знает то, чего статический компилятор знать не может. Он видел, какие типы реально приходили в эту функцию. Он видел, что вон та ветка не выполнилась ни разу за миллион итераций. Он знает, что у виртуального вызова на практике всегда один получатель. Статический компилятор про всё это может только гадать — и, будучи обязанным быть корректным на всех входах (мы разбирали, почему он обязан быть пессимистом), гадает консервативно.

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

Сколько на самом деле стоит интерпретация

Прежде чем чинить, померим. Возьмём цикл на Mini из статьи про IR:

let s = 0;
let i = 0;
while i < n { s = s + i; i = i + 1; }
return s;

В нашей стековой ВМ тело цикла — восемь байткодов. Что происходит при исполнении каждого из них:

  1. Загрузка опкода из массива байткода — обращение в память (обычно попадает в L1).
  2. Диспетчеризация — косвенный переход по таблице или switch. Это переход, который процессор должен предсказать, а предсказать его тяжело: у одного и того же места в коде десятки разных назначений. Промах предсказания — 15–20 тактов на современном x86 (как это работает в конвейере).
  3. Работа с операндным стеком — push/pop, то есть чтения-записи в память вместо регистров.
  4. Собственно операция — одна инструкция 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.

Профилирование: как найти горячее

Есть два способа узнать, где программа проводит время, и 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-слот, константа или «объект удалён, вот рецепт сборки»). Эти таблицы лежат рядом с кодом и в горячем пути не стоят ничего: их читают только при деоптимизации.

Тонкости, которые делают тему по-настоящему сложной.

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).

Почему это выгодно: линейный код оптимизировать несравнимо проще. Нет φ-функций, нет слияний потока управления, вся классика из статьи про оптимизации — свёртка констант, нумерация значений, вынос инвариантов из цикла — работает на прямой цепочке почти тривиально. Трасса сама по себе является результатом агрессивного инлайнинга: если внутри цикла был вызов, он просто оказался записан в трассу.

Добавим трассирующий 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, оптимизации, кодоген), только запущенные внутри работающей программы и вооружённые знанием, которого у статического компилятора нет и быть не может.

Источники

Что дальше

Мы прошли путь от текста программы до машинного кода, который рантайм генерирует и выбрасывает на лету. Все технические решения приняты — и теперь возвращается вопрос, с которого всё начиналось, но уже с другой стороны: а каким должен быть сам язык? Какой синтаксис читается, а какой только кажется читаемым; почему сообщение об ошибке важнее, чем половина оптимизаций; как спроектировать маленький предметный язык так, чтобы им хотелось пользоваться, и когда правильный ответ — не делать язык вовсе. Инженерия компилятора закончилась, начинается проектирование.

Проектирование языков и DSL: синтаксис, ошибки, эргономика

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов