JIT-компиляция
Предпосылки
Компиляция — ISeq, YARV-байткод,
iseq_encoded. Исполнение — фреймы (rb_control_frame_t), PC, SP, CFP, цикл VM. Диспетчеризация методов — инлайн-кеш, callable method entry (rb_callable_method_entry_t), инвалидация кеша. Формы —shape_id, инлайн-кеш переменных. Процессор — fetch-decode-execute, pipeline, branch prediction, IPC.
← Метапрограммирование (ветка «методы») | ← String (ветка «память»)
Цикл интерпретатора YARV универсален: прочитать инструкцию, декодировать, выполнить, передвинуть PC. Но у этой универсальности есть цена — непрямые ветвления, проверки типов, обслуживание стека значений. Пока код вызывается редко, цена почти не видна; на горячем пути она начинает конкурировать с полезной работой самого метода.
Машинный код поверх VM
На уровне общей базы по программированию JIT уже знаком как дополнительная компиляция поверх виртуальной машины. JIT расшифровывается как just-in-time: программа начинает работу через VM, а горячие участки потом переводятся в машинный код прямо во время выполнения.
Для CRuby это не альтернатива YARV-байткоду, а дополнительный слой поверх него. Когда некоторый ISeq исполняется часто, CRuby переводит его в машинный код процессора. Следующий вызов идёт уже не через общий цикл интерпретатора, а напрямую в сгенерированный код. Компилятор по-прежнему создаёт ISeq, а JIT ускоряет только самые горячие участки.
Почему отсюда появляется прогрев
У JIT есть стартовая цена: код нужно сначала несколько раз выполнить в интерпретаторе, заметить, что он стал горячим, и только потом тратить время и память на компиляцию. Поэтому ускорение приходит не в самый первый момент запуска, а после прогрева.
Компиляция в машинный код тоже стоит времени и памяти. Поэтому JIT выгоден не всегда:
- короткоживущий код выгоднее просто интерпретировать;
- инициализационные пути, которые выполняются один раз, редко окупают компиляцию;
- горячие методы, циклы и блоки, которые вызываются тысячами и миллионами раз, обычно окупают её быстро.
Отсюда ключевая идея: JIT работает не со всем кодом подряд, а только с горячим кодом. Интерпретатор сначала собирает достаточно сигналов, что этот ISeq действительно стоит ускорять, и только потом компилирует.
Предположение, guard и безопасный выход
У динамического языка нет роскоши предполагать, что мир не меняется. Один и тот же вызов может сегодня получать String, завтра Symbol, а послезавтра метод будет переопределён через define_method. Поэтому JIT в Ruby почти никогда не означает «убрать все проверки». Он означает «сделать быстрый путь для типичного случая и оставить безопасный выход для остальных».
Эта схема обычно состоит из трёх частей:
- Предположение. Например: у объектов на этом call site одна форма ([[ruby/internal/object-model/shapes|
shape_id]]), а вызываемый метод по-прежнему тот же. - Guard. Машинная проверка, что предположение всё ещё верно.
- Exit / deopt. Если guard не прошёл, управление возвращается интерпретатору, который умеет корректно продолжить исполнение без предположений.
Именно поэтому JIT опирается на структуры, которые в CRuby уже готовы дать дешёвый ключ для проверок:
- формы объектов дают дешёвый ключ для guards на ivar-доступ;
- диспетчеризация методов уже имеет кеши и точки инвалидации;
- метапрограммирование объясняет, почему JIT должен уметь быстро отказываться от старых предположений.
JIT в CRuby: YJIT и ZJIT
Общий принцип JIT один и тот же, но в CRuby он реализован двумя разными стратегиями. YJIT старается рано дать ускорение за счёт быстрой компиляции небольших участков. ZJIT идёт в сторону более тяжёлой компиляции метода целиком после сбора профиля.
YJIT: быстрый старт и постепенное покрытие
YJIT появился в Ruby 3.1. Его идея — Basic Block Versioning (BBV): компилировать не весь метод сразу, а маленькие прямолинейные участки байткода по мере исполнения.
Практический эффект такой:
- компиляция начинается быстро;
- ускорение появляется рано, без длинного прогрева;
- один и тот же участок кода может иметь несколько версий под разные наблюдаемые типы и формы;
- если вариантов становится слишком много, YJIT предпочитает более общий, менее оптимальный путь, а не бесконечное размножение специализированных версий.
Это делает YJIT хорошим компромиссом для реальных серверных приложений: код начинает ускоряться рано, а сама стратегия не требует долгого профилирования перед первым выигрышем.
ZJIT: метод целиком после профилирования
ZJIT появился как экспериментальный JIT в Ruby 4.0. В отличие от YJIT, он method-based: сначала собирает профиль, а затем компилирует метод целиком.
У этой стратегии другой компромисс:
- прогрев длиннее, потому что сначала нужен профиль;
- сама компиляция тяжелее;
- зато у компилятора больше цельной информации о методе и больше пространства для оптимизаций на уровне всего метода, а не одного базового блока.
В release notes Ruby 4.0 ZJIT описан как экспериментальный: он уже быстрее интерпретатора, но ещё не догнал YJIT и пока не рекомендуется как основной вариант для production. Это важное отличие от YJIT: ZJIT полезно понимать как направление развития CRuby, но рассчитывать на него как на «основной JIT Ruby» пока рано.
Триггеры деоптимизации
Быстрый JIT-путь держится на стабильности формы данных и вызовов. Поэтому в Ruby особенно важны несколько типов изменений:
- объект получает другую форму, потому что часть экземпляров инициализируется иначе;
- метод или константа переопределяются во время работы программы;
include,prepend,alias,define_method,eval,TracePoint,bindingменяют видимость или структуру кода;- горячий участок встречает слишком много разных типов входных данных.
Во всех этих случаях JIT либо выходит обратно в интерпретатор, либо инвалидирует часть скомпилированного кода и строит её заново. Это не ошибка JIT, а нормальная цена динамического языка: если программа сохраняет стабильные shapes и типы на горячем пути, JIT ускоряет её сильнее; если горячий путь постоянно «плывёт», JIT тратит больше времени на side exit’ы и инвалидацию.
Память и наблюдаемость
JIT ускоряет исполнение ценой дополнительной памяти. CRuby хранит сгенерированный код в памяти процесса, поэтому рост скорости всегда связан с ростом code cache и служебных структур.
Раз сгенерированный код накапливается, у него должен быть потолок. YJIT держит мягкий лимит на эту память (по умолчанию 128 MiB); когда лимит достигнут, у CRuby два варианта поведения. С Ruby 3.3 code GC в YJIT по умолчанию выключен: вместо того чтобы внезапно сбросить весь кеш кода, CRuby просто перестаёт компилировать новый код после достижения лимита — горячие методы остаются ускоренными, а новые идут через интерпретатор. Освобождать память кеша по требованию (code GC) приходится включать отдельным флагом --yjit-code-gc.
Чтобы увидеть, как этот лимит расходуется на конкретной нагрузке, YJIT отдаёт runtime-статистику через RubyVM::YJIT.runtime_stats. Базовые числа (сколько кода скомпилировано) доступны всегда; подробные — сколько случилось side exit’ов и какая доля инструкций исполняется внутри YJIT, а не в интерпретаторе — собираются, только если Ruby запущен с флагом --yjit-stats. По этим числам видно, упирается ли горячий путь в потолок памяти или «плывёт» из-за слишком частых выходов обратно.
У ZJIT та же причинная линия — сгенерированный код живёт в памяти процесса, — но меньше зрелых production-практик; интроспекция собрана под RubyVM::ZJIT, по форме близко к YJIT.
Один общий потолок применимости: и RubyVM::YJIT, и RubyVM::ZJIT существуют только в MRI/CRuby. Для JRuby и TruffleRuby этот API не переносим — у них свои JIT-реализации.
Sources
- YJIT documentation (Ruby 3.4): https://docs.ruby-lang.org/en/3.4/yjit/yjit_md.html
- Ruby 4.0.0 NEWS — ZJIT and JIT updates: https://docs.ruby-lang.org/en/master/NEWS/NEWS-4_0_0_md.html
- ZJIT documentation: https://docs.ruby-lang.org/en/master/ZJIT.html
- Maxime Chevalier-Boisvert, Noah Gibbs, Jean Boussier, Si Xing (Alan) Wu, Aaron Patterson, Kevin Newton, John Hawthorn, 2023, YJIT: A Basic Block Versioning JIT Compiler for CRuby — MPLR 2023.
← Метапрограммирование (ветка «методы») | ← String (ветка «память»)