JIT-компилятор Python: наконец-то в версии 3.15
Python 3.15 получает JIT-компилятор copy-and-patch. Разбираем, как он работает, какого ускорения ждать и почему CPython шёл к этому так долго.

Python считается «слишком медленным» с тех пор, как он вообще существует. Стандартный ответ сообщества, «для горячих циклов используйте C-расширения», всегда был признанием того, что модель исполнения языка по умолчанию фундаментально ограничена. CPython интерпретирует байткод по одной инструкции за раз, и каждая инструкция диспетчеризуется через оператор switch. Это просто, переносимо и легко отлаживается. Но на вычислительно тяжёлых задачах он примерно в 100 раз медленнее скомпилированного C.
Python 3.15 это меняет. После нескольких лет экспериментов copy-and-patch JIT-компилятор выходит как функция, включённая по умолчанию. Python он не сделает таким же быстрым, как C, да и ничто не сделает, кроме статической компиляции. Но ранние бенчмарки показывают ускорение на 15–30% на реальном коде, а отдельные паттерны ускоряются значительно сильнее. Для языка, у которого история производительности тридцать лет звучит как «перепиши горячий участок на C», JIT, который заметно ускоряет чистый Python, — настоящая веха.
Почему у CPython никогда не было JIT
Не потому, что никто не пробовал. У PyPy JIT есть больше десяти лет, и он регулярно запускает Python-код в 5–10 раз быстрее CPython. Но PyPy — отдельная реализация со своим рантаймом, и она так и не получила доли рынка, сравнимой с CPython, потому что экосистема C-расширений (NumPy, pandas, scikit-learn, всё то, что делает Python языком для data science) завязана на C API CPython.
Встроить JIT прямо в CPython обсуждали и пробовали не раз. Проблемы хорошо задокументированы. Архитектура CPython затрудняет JIT-компиляцию: байткод динамически типизирован (JIT нужна информация о типах, чтобы генерировать эффективный код, а Python её статически не предоставляет), C API позволяет C-коду напрямую манипулировать объектами Python так, что это ломает предположения JIT, а счётчик ссылок в сборщике мусора создаёт накладные расходы на бухгалтерию, которые JIT не может просто убрать.
Предыдущие попытки, Unladen Swallow (Google, 2009) и Pyston (Dropbox, 2014), пытались прикрутить к CPython JIT на базе LLVM. В обоих случаях выяснилось, что накладные расходы LLVM на компиляцию слишком велики для типичных нагрузок Python. LLVM создан для ahead-of-time компиляции больших кодовых баз, и использование его для JIT-компиляции коротких Python-функций добавляет миллисекунды времени компиляции на микросекунды исполнения. Компиляция стоила дороже, чем давало ускорение.
Copy-and-patch: JIT другого типа
Техника copy-and-patch, описанная в исследовательской статье 2021 года, подходит к JIT принципиально иначе. Вместо того чтобы переводить байткод в промежуточное представление и гонять его через проходы оптимизации (как в подходе с LLVM), copy-and-patch работает с заранее скомпилированными шаблонами кода.
Идея такая: для каждой инструкции байткода (LOAD_FAST, BINARY_ADD, CALL_FUNCTION и т. д.) компилятор заранее превращает C-реализацию в машинный код с плейсхолдерами («дырками») под данные, специфичные для конкретного места: номера регистров, значения констант, смещения в памяти. Во время выполнения JIT-компиляция сводится к тому, чтобы скопировать готовый шаблон и заполнить дырки конкретными значениями для этой функции. Никаких проходов оптимизации, никаких алгоритмов распределения регистров, никакого выбора инструкций. Просто memcpy и patch.
Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)
Цена за это — качество кода. LLVM генерирует сильно оптимизированный машинный код. Copy-and-patch даёт код, который по сути является скомпилированной версией цикла интерпретатора: каждая инструкция байткода по-прежнему отдельный шаблон, а оптимизации между инструкциями минимальны. Сгенерированный код лучше интерпретации (нет накладных расходов на диспетчеризацию, нет switch, лучше предсказание ветвлений), но хуже того, что выдал бы полноценный оптимизирующий компилятор.
Для Python этот компромисс отличный. Python-функции обычно короткие, вызываются много раз и по отдельности занимают микросекунды. JIT, который компилируется за микросекунды и ускоряет выполнение на 20–30%, полезнее того, который компилируется за миллисекунды и ускоряет в 3 раза, потому что накладные расходы на компиляцию в первом случае окупаются почти сразу.
Что станет быстрее
JIT не ускоряет весь Python-код одинаково и волшебным образом. Чтобы понять, что выиграет больше всего, нужно понимать, на что интерпретатор тратит время.
Накладные расходы на диспетчеризацию байткода. В интерпретаторе каждая инструкция требует: выборки следующего опкода, декодирования и перехода к обработчику через switch. На тесных циклах эти накладные расходы могут составлять 30–50% общего времени исполнения. JIT убирает их полностью: инструкции компилируются в последовательный машинный код с прямыми переходами.
Специализированные под типы операции. Python 3.11 представил специализирующий адаптивный интерпретатор, который заменяет обобщённые операции на типоспецифичные, наблюдая за реально используемыми типами. BINARY_ADD превращается в BINARY_ADD_INT, когда видит два целых числа. JIT компилирует эти специализированные инструкции в эффективный машинный код: сложение целых становится одной инструкцией add, а не вызовом функции.
Предсказание ветвлений. Центральный цикл диспетчеризации интерпретатора, switch с сотнями веток, — кошмар для предсказателя ветвлений процессора. JIT заменяет его прямым управлением потоком, которое процессор предсказывает точно. На современных CPU, где промах предсказания стоит 15–20 тактов, это одно уже даёт заметную часть ускорения.
Что не ускорится: вызовы C-расширений (NumPy, pandas), операции ввода-вывода (сеть, диск), операции, в которых доминирует выделение памяти (создание миллионов мелких объектов). Если ваша программа на Python 95% времени проводит в C-расширениях и 5% в чистом Python, JIT ускорит эти 5%. Эффект измеримый, но не переворачивает картину.
Конвейер специализации
JIT не работает в одиночку. Он — финальный этап конвейера производительности, который начался со специализирующего интерпретатора Python 3.11 и продолжился инкрементальными улучшениями в 3.12–3.14.
- Уровень 0: интерпретатор. Весь код начинается здесь. Стандартная интерпретация байткода с адаптивной специализацией. После нескольких вызовов функции горячие инструкции заменяются типоспецифичными версиями.
- Уровень 1: JIT-скомпилированный байткод. Copy-and-patch JIT компилирует специализированный байткод в машинный код. Это устраняет накладные расходы на диспетчеризацию и включает базовые оптимизации, такие как свёртка констант и удаление мёртвого кода внутри скомпилированных шаблонов.
- Уровень 2 (в будущем): трассирующая оптимизация. Запись трасс выполнения по горячим путям кода и компиляция целых трасс, в том числе через границы функций, в оптимизированный машинный код. Это запланировано, но пока не выпущено.
Этот многоуровневый подход похож на тот, что используют YJIT для Ruby и другие современные рантаймы языков. Начинаем с быстрой интерпретации, переходим к быстрой компиляции, когда код становится горячим, а дорогую оптимизацию приберегаем для самых горячих участков. Идея та же: большую часть кода оптимизировать не стоит, поэтому бюджет на компиляцию тратим на то, что выполняется чаще всего.
Влияние на память и запуск
JIT-компиляторы занимают память под скомпилированный код. Накладные расходы памяти у copy-and-patch JIT скромные: скомпилированный код больше байткода, но меньше того, что выдают JIT на базе LLVM (из-за отсутствия раздувания от оптимизаций). Текущая реализация использует примерно в 1,5–3 раза больше памяти, чем заменяемый ею байткод, и компилирует только те функции, которые вызываются достаточно часто, чтобы выиграть от этого.
Время запуска важно для короткоживущих Python-скриптов. JIT добавляет накладные расходы на загрузку библиотеки шаблонов и настройку инфраструктуры компиляции. Для скриптов, которые работают меньше секунды, накладные расходы JIT могут превысить выигрыш. CPython решает это, компилируя функции только после того, как их вызвали определённое пороговое число раз: короткие скрипты остаются в интерпретаторе и не платят налог на JIT.
Это настраивается. Флаг -X jit управляет поведением JIT, а переменные окружения позволяют подстроить порог компиляции. Для serverless-функций и CLI-утилит, где важен старт, можно поднять порог или вовсе отключить JIT. Для долгоживущих серверов и скриптов обработки данных, где важна производительность в установившемся режиме, значения по умолчанию работают хорошо.
Что это значит для экосистемы Python
JIT не меняет место Python в иерархии производительности: C, Rust, Go и Java по-прежнему заметно быстрее на вычислительно тяжёлых задачах. Меняется порог, при котором разработчикам Python нужно тянуться к этим альтернативам.
Ускорение на 20–30% для чистого Python означает, что некоторые нагрузки, которые раньше требовали C-расширений или переписывания, теперь работают достаточно быстро и на чистом Python. Скрипты обработки данных, которые шли 10 минут, идут 7. Веб-серверы, обрабатывавшие 1000 запросов в секунду, обрабатывают 1300. Цифры не революционные, но именно между фразами «Python достаточно быстрый» и «надо переписать это на Go» и проходит разница.
Важнее другое: JIT-инфраструктура закладывает фундамент для будущей оптимизации. Подход copy-and-patch можно развивать за счёт лучших шаблонов, большей специализации и, в конце концов, трассирующей компиляции. Каждая версия Python сможет выпускать лучшие шаблоны, не меняя фундаментальной архитектуры JIT. Ускорение на 20–30% в 3.15 — это пол, а не потолок.
Три десятилетия Python был одним из самых популярных и одновременно самых медленных языков, и теперь CPython всерьёз вкладывается в производительность. JIT не удовлетворит тех, кто говорит «Python слишком медленный», — ничто не удовлетворит, потому что для некоторых задач Python действительно слишком медленный, с JIT или без. Но для подавляющего большинства Python-кода, где скорость была «нормально, но могло бы быть лучше», JIT двигает его ближе к «действительно хорошо». Это важнее, чем кажется.


