Подробные статьи о технологиях, определяющих будущее.

Как JIT-компиляторы ускоряют динамические языки

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

Гем Ruby влетает в светящуюся машину и выходит кометой из света

У 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 оптимизирует просто, и это приятное совпадение интересов.