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

Метапрограммирование | String

В заметке об исполнении мы видели цикл интерпретатора: прочитать инструкцию YARV, декодировать её, выполнить, передвинуть PC. Этот цикл универсален, но у него есть цена: непрямые ветвления, проверки типов, обслуживание стека значений. Пока код вызывается редко, эта цена почти не видна. Но на горячем пути она начинает конкурировать с полезной работой самого метода.

Что такое JIT

На уровне общей базы по программированию JIT уже знаком как дополнительная компиляция поверх виртуальной машины. JIT расшифровывается как just-in-time: программа начинает работу через VM, а горячие участки потом переводятся в машинный код прямо во время выполнения.

Для CRuby это не альтернатива YARV-байткоду, а дополнительный слой поверх него. Когда некоторый ISeq исполняется часто, CRuby переводит его в машинный код процессора. Следующий вызов идёт уже не через общий цикл интерпретатора, а напрямую в сгенерированный код. Компилятор по-прежнему создаёт ISeq, а JIT ускоряет только самые горячие участки.

Почему отсюда появляется прогрев

У JIT есть стартовая цена: код нужно сначала несколько раз выполнить в интерпретаторе, заметить, что он стал горячим, и только потом тратить время и память на компиляцию. Поэтому ускорение приходит не в самый первый момент запуска, а после прогрева.

Компиляция в машинный код тоже стоит времени и памяти. Поэтому JIT выгоден не всегда:

  • короткоживущий код выгоднее просто интерпретировать;
  • инициализационные пути, которые выполняются один раз, редко окупают компиляцию;
  • горячие методы, циклы и блоки, которые вызываются тысячами и миллионами раз, обычно окупают её быстро.

Отсюда ключевая идея: JIT работает не со всем кодом подряд, а только с горячим кодом. Интерпретатор сначала собирает достаточно сигналов, что этот ISeq действительно стоит ускорять, и только потом компилирует.

Что JIT добавляет поверх байткода

У динамического языка нет роскоши предполагать, что мир не меняется. Один и тот же вызов может сегодня получать String, завтра Symbol, а послезавтра метод будет переопределён через define_method. Поэтому JIT в Ruby почти никогда не означает «убрать все проверки». Он означает «сделать быстрый путь для типичного случая и оставить безопасный выход для остальных».

Эта схема обычно состоит из трёх частей:

  • Предположение. Например: у объектов на этом call site одна форма (shape_id), а вызываемый метод по-прежнему тот же.
  • Guard. Машинная проверка, что предположение всё ещё верно.
  • Exit / deopt. Если guard не прошёл, управление возвращается интерпретатору, который умеет корректно продолжить исполнение без предположений.

Именно поэтому 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 это особенно хорошо видно на runtime-метриках:

  • --yjit включает JIT;
  • --yjit-mem-size задаёт мягкий лимит на память YJIT (по умолчанию 128 MiB);
  • --yjit-exec-mem-size ограничивает именно исполняемый код;
  • --yjit-stats и RubyVM::YJIT.runtime_stats показывают, сколько кода скомпилировано, сколько было side exit’ов и сколько инструкций исполняется внутри YJIT;
  • RubyVM::YJIT.log и RubyVM::YJIT.code_gc помогают разбирать компиляции и управлять code GC вручную.

С Ruby 3.3 code GC в YJIT по умолчанию выключен и включается отдельно через --yjit-code-gc: по умолчанию CRuby предпочитает не инвалидировать весь кеш кода внезапно, а просто перестать компилировать новый код после достижения лимита.

У ZJIT сейчас меньше зрелых production-практик, но направление похожее:

  • --zjit или RubyVM::ZJIT.enable включают его;
  • RubyVM::ZJIT даёт API для интроспекции;
  • трассировку точек выхода можно сохранить через RubyVM::ZJIT.dump_exit_locations(...), если ZJIT запущен с --zjit-trace-exits.

Важно помнить последнее ограничение: RubyVM::YJIT и RubyVM::ZJIT существуют только в MRI/CRuby. Для JRuby и TruffleRuby этот API не переносим.

Sources


Метапрограммирование | String