JIT-компиляция

Метапрограммирование (ветка «методы») | ← 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 уже готовы дать дешёвый ключ для проверок:

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


Метапрограммирование (ветка «методы») | ← String (ветка «память»)