Как JIT-компиляторы ускоряют динамические языки
Как JIT-компиляторы ускоряют Ruby, Python и JavaScript, убирая лишние операции. Примеры из YJIT и ZJIT.

У Ruby репутация медленного языка. У Python тоже. У JavaScript была, пока V8 не сделал его достаточно быстрым для серверных задач. История того, как динамические языки становятся быстрыми, это история JIT-компиляции (Just-In-Time), и одна из самых увлекательных областей практической информатики. Последняя глава: ZJIT в Ruby убирает избыточные загрузки и сохранения объектов на уровне промежуточного представления, то есть тем же классом оптимизаций, который сделал TurboFan в V8 таким эффективным.
Если вам когда-нибудь было интересно, почему ваш код на Ruby работает в разы медленнее C или как JavaScript стал достаточно быстрым, чтобы работать в VS Code, ответ кроется в том, что на самом деле делают JIT-компиляторы и почему оптимизировать динамические языки принципиально сложнее, чем статические.
Основная проблема динамических языков
Когда компилятор C видит a + b, он знает типы a и b во время компиляции. Если оба целые, он генерирует одну инструкцию ADD. Если это числа с плавающей точкой, генерируется сложение с плавающей точкой. Процессор выполняет эту инструкцию за один такт. Никакой неопределённости и никаких решений во время выполнения.
Когда интерпретатор Ruby видит a + b, он почти ничего не знает. a может быть целым числом, числом с плавающей точкой, строкой, массивом или любым объектом, который определяет метод +. Интерпретатору нужно проверить тип a, найти метод + для этого типа, проверить тип b, при необходимости привести типы, обработать крайние случаи (переполнение, замороженные объекты) и только потом выполнить операцию. Один-единственный + может потребовать десятков инструкций, обращений к памяти и ветвлений.
# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+) # Hash lookup
raise NoMethodError unless method # Branch
if a.is_a?(Integer) && b.is_a?(Integer) # Type checks
result = integer_add(a.value, b.value) # Actual math
if overflow?(result) # Overflow check
result = promote_to_bignum(result) # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b]) # Generic dispatch
end
result
end
Эти накладные расходы (проверка типов, поиск метода, диспетчеризация) и есть налог за динамичность. Возможность написать a + b и получить корректный результат для целых, чисел с плавающей точкой, строк и собственных объектов стоит дорого во время выполнения.
Как JIT-компиляция с этим борется
Задача JIT-компилятора в том, чтобы наблюдать, что программа реально делает во время выполнения, и на основе этих наблюдений генерировать оптимизированный машинный код. Ключевая идея: хотя код на Ruby может работать с любыми типами, на практике в каждой точке вызова почти всегда встречаются одни и те же типы.
Если sum(a, b) вызывали 10 000 раз и a с b всегда были целыми, JIT может сгенерировать специализированный машинный код, который считает, что они и дальше будут целыми. Он выдаёт одну инструкцию целочисленного сложения с «охранной» проверкой: быстрый тест типа, который переводит выполнение на медленный путь, если предположение когда-нибудь нарушится.
Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result
Это процесс из 14 шагов, сокращённый до 5, где шаги 1, 2 и 4 это одиночные инструкции сравнения. Само вычисление, ADD, занимает один такт процессора. Именно так JavaScript из «слишком медленного для всего серьёзного» превратился в «достаточно быстрый, чтобы запустить полноценную IDE».
Путь Ruby от MJIT к YJIT и ZJIT
История JIT-компиляции в Ruby хорошо показывает, насколько эта задача трудна. Ruby прошёл через несколько реализаций JIT, и каждая использовала свой подход.
MJIT (Ruby 2.6, 2018) переводил байткод Ruby в код на C, а затем вызывал GCC или Clang для компиляции. Получавшийся код был хорошо оптимизирован, но разогрев занимал ужасно много времени: компиляция C занимает секунды, а не миллисекунды. Когда скомпилированный JIT-код был готов, программа могла уже завершиться.
YJIT (Ruby 3.1, 2022) был вкладом Shopify, сначала написанным на C, а позже переписанным на Rust. YJIT использует технику под названием «ленивое версионирование базовых блоков» (lazy basic block versioning): он компилирует код по одному базовому блоку и только тогда, когда этот код действительно выполняется, создавая специализированные версии на основе наблюдаемых типов. Это даёт быстрый разогрев (миллисекунды, а не секунды) и хорошую пиковую производительность. YJIT обычно повышает производительность Ruby на 15–30% в реальных нагрузках, например в приложениях на Rails.
ZJIT это следующая эволюция, и здесь начинается по-настоящему интересное. ZJIT вводит промежуточное представление (IR), то есть структурированное представление программы между байткодом и машинным кодом. Это IR позволяет применять классические оптимизации компиляторов, которые подход YJIT с прямой трансляцией байткода в машинный код не мог реализовать так же легко.
Устранение избыточных загрузок и сохранений
Конкретная оптимизация, которую ZJIT недавно внедрил, то есть удаление избыточных загрузок и сохранений объектов, звучит заумно, но влияние огромное. Вот почему.
Объекты Ruby хранят переменные экземпляра в таблице свойств. Каждый раз, когда вы читаете @name, интерпретатор загружает значение из таблицы свойств объекта в памяти. Каждый раз, когда вы пишете @name = value, он сохраняет значение в эту таблицу. В методе, который несколько раз обращается к одной и той же переменной экземпляра, интерпретатор загружает её из памяти каждый раз, потому что в общем случае что-то могло изменить значение между чтениями.
class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor # Load @width, multiply, store @width
@height = @height * factor # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }
Имея IR, ZJIT может выполнить устранение загрузок: если @width уже был загружен и с тех пор ничего его не меняло, значение берётся из регистра, а не загружается из памяти повторно. Также он может выполнить устранение сохранений: если вы дважды подряд записываете в @width и никто не читает значение между записями, первое сохранение можно убрать.
Эти оптимизации давно стандарт для компиляторов статических языков: GCC и LLVM делают это уже десятилетия. Но для динамического языка это гораздо сложнее из-за алиасинга. В Ruby вызов любого метода потенциально может изменить переменные экземпляра любого объекта (через instance_variable_set, method_missing или trace-хуки). JIT должен доказать, что между двумя чтениями @width ничто не могло его изменить, а для этого нужно проанализировать, что может делать каждая промежуточная операция.
Преимущество IR
Причина, по которой ZJIT ввёл IR (и почему TurboFan в V8, DFG/FTL в JavaScriptCore и C2 в HotSpot тоже используют IR), в том, что это делает оптимизации компонуемыми. IR по сути представляет собой граф операций, к которому оптимизации применяются как преобразования графа.
- Свёртка констант: если оба операнда сложения известны как константы, операция заменяется результатом.
2 + 3становится5на этапе компиляции. - Устранение мёртвого кода: если результат операции нигде не используется, её можно полностью удалить.
- Устранение общих подвыражений: если одно и то же вычисление встречается дважды, его выполняют один раз и переиспользуют результат.
- Устранение загрузок и сохранений: удаление избыточных обращений к памяти, как описано выше.
- Escape-анализ: если объект создаётся и не покидает текущий метод, его можно разместить на стеке, а не в куче (или вообще убрать выделение памяти).
- Инлайнинг: вызов метода заменяется телом метода, что открывает больше возможностей для остальных оптимизаций.
Эти оптимизации усиливают друг друга. Инлайнинг метода выносит его операции в контекст вызывающего кода, что может выявить константные значения, которые позволяют свернуть константы, из-за чего часть кода становится мёртвой и удаляется. Одно решение об инлайнинге может повлечь за собой удаление десятков операций.
Страховка в виде деоптимизации
Всё, что делает JIT-компилятор, носит спекулятивный характер. Он предполагает, что типы не изменятся, методы не будут переопределены, а monkey-patching не сломает оптимизированный код. Когда эти предположения нарушаются, JIT должен выполнить «деоптимизацию»: отбросить оптимизированный код и вернуться к интерпретатору.
Деоптимизация одна из самых сложных частей проектирования JIT. Оптимизированный код мог убрать локальные переменные, переупорядочить операции или заинлайнить глубоко вложенные вызовы. Чтобы вернуться к интерпретатору, JIT должен восстановить состояние интерпретатора, то есть все локальные переменные, стек вызовов и счётчик команд, на основе того, что доступно в оптимизированном коде. Для этого нужны метаданные (их называют «on-stack replacement» или карты OSR), которые связывают состояния оптимизированного кода с состояниями интерпретатора.
Если деоптимизация происходит часто, возникает так называемый «deopt thrashing», и производительность может стать хуже, чем у чистой интерпретации. JIT тратит время на компиляцию оптимизированного кода, запускает его ненадолго, деоптимизирует и повторяет цикл. V8 решает это, отслеживая количество деоптимизаций и в итоге отказываясь от оптимизации конкретной функции. YJIT использует более простой подход: он генерирует несколько версий каждого пути выполнения для разных комбинаций типов, что уменьшает необходимость в деоптимизации ценой большего объёма сгенерированного кода.
Почему это важно за пределами Ruby
Путь Ruby к JIT повторяет то, что происходит во всех динамических языках. Copy-and-patch JIT в Python (появился в CPython 3.13) это первый шаг к полноценной JIT-компиляции в Python. LuaJIT годами остаётся невероятно быстрым благодаря агрессивному трассирующему JIT. JIT в PHP 8.0+ использует LLVM для оптимизаций на основе IR.
Закономерность одна и та же: начинаем с интерпретатора, добавляем профилирование, чтобы понять поведение во время выполнения, компилируем горячие участки кода со специализацией по типам, вводим IR для классических оптимизаций и постоянно улучшаем. Все языки сталкиваются с одними и теми же проблемами (динамическая диспетчеризация, изменяемые объекты, eval, monkey-patching) и приходят к похожим решениям.
Для разработчиков, которые используют эти языки, практический вывод такой: разрыв в производительности между динамическими и статическими языками сокращается. Полностью он никогда не исчезнет, ведь проверки типов и охранные условия всё равно стоят ресурсов, а деоптимизация неизбежно добавляет накладные расходы. Но хорошо оптимизированный JIT может приблизиться к эквивалентному C-коду в вычислительных нагрузках на расстояние 2–5x, и этого достаточно для подавляющего большинства приложений.
Вывод потоньше: пишите простой код. JIT-компиляторы оптимизируют предсказуемые шаблоны. Мономорфные точки вызова (где метод всегда получает одни и те же типы) оптимизируются хорошо. Полиморфные (где типы меняются) сложнее. Мегаморфные (десятки типов) могут так и не получить оптимизацию. Код, который человеку просто понять, обычно и JIT оптимизирует просто, и это приятное совпадение интересов.


