Компиляторы
JIT-компиляция: основы
JavaScript запускается быстрее C++ при прогреве - звучит абсурдно, но это реальный benchmark. V8 наблюдает как выполняется код, собирает типы аргументов, ветви переходов, частоту вызовов - и генерирует нативный x86/ARM код точно под этот паттерн. Это Just-In-Time компиляция: компилятор знает то, чего не знает статический компилятор - как программа реально работает.
- **V8 в Node.js** компилирует горячие Express.js middleware через Turbofan: типичный JSON-парсинг ускоряется в 5-10x после прогрева ~1000 запросов
- **HotSpot JVM** в производительном Java-сервере (например, Kafka broker) достигает пиковой производительности через 30-60 секунд после старта - именно столько нужно C2 compiler, чтобы оптимизировать все hot paths
- **LuaJIT** применяется в игровых движках (Roblox, WoW addons) и nginx-lua: tracing JIT даёт производительность в 10-50x быстрее чистого Lua для игровой логики с числовыми циклами
Tracing JIT
Tracing JIT записывает путь выполнения программы - trace - как линейную последовательность инструкций, пересекающую границы функций и ветки. Когда trace достигает порога (hotness threshold), он компилируется в нативный код. LuaJIT 2.x - классический пример: для trace-цикла он генерирует плотный нативный код без function call overhead. Mike Pall создал LuaJIT с таким подходом и добился производительности, конкурентоспособной с Cи.
PyPy использует RPython-based tracing JIT: код Python-программ, интенсивно исполняющих числовые циклы, ускоряется в 5-50x по сравнению с CPython. Firefox SpiderMonkey использовал tracing JIT (TraceMonkey) до 2012, затем перешёл на method JIT (IonMonkey) - tracing даёт отличный пик производительности, но неустойчив при полиморфных вызовах.
Что такое 'trace' в контексте tracing JIT?
Method JIT
Method JIT компилирует целые методы (функции) как единицу компиляции. Это подход HotSpot JVM, V8 Turbofan, SpiderMonkey IonMonkey. Компилятор строит SSA-форму (Static Single Assignment) метода, применяет оптимизации (inlining, loop unrolling, escape analysis) и генерирует нативный код. Граница компиляции - граница метода, поэтому проще рассуждать об оптимизациях.
V8 использует Sparkplug (быстрый baseline JIT без оптимизаций) + Maglev (средний уровень) + Turbofan (оптимизирующий JIT). Turbofan применяет ~50 оптимизационных проходов включая inlining, dead code elimination, range analysis. Время компиляции Turbofan: ~1-10ms на метод, поэтому нельзя компилировать всё подряд.
Почему method JIT использует SSA (Static Single Assignment) форму?
JIT Profiling
JIT-компилятор не может позволить себе компилировать всё - это медленно. Вместо этого рантайм собирает профиль: сколько раз вызвана функция, какие типы реально передаются аргументами, какие ветки чаще берутся. На основе этих данных JIT принимает решения об inline, специализации типов и вероятностных оптимизациях.
HotSpot JVM использует sample-based profiling: каждые 10ms JVM делает sample call stack и отмечает горячие методы. V8 использует event-based: считает каждый вызов функции и каждый back-edge в цикле. PyPy собирает тип объекта на каждой инструкции LOAD_ATTR. Точность профиля напрямую влияет на качество JIT-оптимизаций.
Что такое Inline Cache (IC) в контексте JIT-профилирования?
Tiered Compilation
Tiered compilation решает противоречие JIT: быстрый старт vs максимальная производительность. Программа начинает выполняться в интерпретаторе или быстром baseline-компиляторе. Горячий код переходит на следующий уровень с более агрессивными оптимизациями. V8 имеет 4 уровня: Ignition (интерпретатор) -> Sparkplug (baseline JIT) -> Maglev -> Turbofan. JVM HotSpot имеет 5 уровней (C1/C2).
Node.js 18+ использует всю цепочку V8 включая Maglev. Время прогрева (warmup time) - критичный параметр для serverless: AWS Lambda с Node.js ~200ms cold start включает время до того, как Turbofan оптимизирует первые запросы. GraalVM Native Image решает это иначе: AOT-компиляция всего приложения устраняет прогрев.
JIT-компилятор всегда быстрее интерпретатора
JIT выгоден только для горячего кода; для редко выполняемых функций накладные расходы компиляции перевешивают выигрыш
Если функция вызывается 3 раза за весь lifecycle приложения, Turbofan потратит 10ms на её компиляцию ради нескольких микросекунд экономии. Tiered compilation решает это: редкий код остаётся в интерпретаторе
Почему Turbofan (tier 4 в V8) не компилирует весь код сразу при старте?
Итоги
- Tracing JIT (LuaJIT, PyPy) записывает конкретный путь выполнения и компилирует его; хорош для числовых циклов, нестабилен при полиморфизме
- Method JIT (V8 Turbofan, HotSpot C2) компилирует целые функции с полным оптимизационным пipelining на основе SSA
- Tiered compilation (V8: Ignition->Sparkplug->Maglev->Turbofan) балансирует cold start latency и peak throughput: интерпретатор стартует мгновенно, оптимизирующий JIT подключается по мере прогрева
Связанные темы
JIT-компиляция строится на компиляторных техниках и связана с runtime-системами:
- Спекулятивные оптимизации — Tiered JIT использует спекулятивные предположения о типах; при нарушении происходит деоптимизация
- GraalVM — GraalVM - это JIT написанный на Java (Graal compiler), плюс AOT через Native Image
- LLVM — Некоторые JIT (MCJIT, ORC JIT в LLVM) используют LLVM как backend для кодогенерации
Вопросы для размышления
- Tracing JIT хорош для числовых циклов, но плохо справляется с полиморфными вызовами. Почему? Что происходит, когда trace встречает новый тип?
- Node.js serverless-функции страдают от JIT warmup: первые запросы медленнее. Какие стратегии помогают - кроме GraalVM Native Image?
- V8 Maglev (2023) добавили между Sparkplug и Turbofan. Зачем нужен средний уровень? Что он выигрывает по сравнению с прямым переходом к Turbofan?