PyPy: JIT-компилятор Python и когда он выигрывает
Тот же язык, другой движок: PyPy компилирует горячие участки прямо во время работы. Разбираем разогрев, ускорение в разы, совместимость с C-расширениями и случаи, когда CPython остаётся правильным выбором.
Редакция Питоники
Урок 16 поставил PyPy в таблицу реализаций со сноской про JIT. Теперь разворачим строку целиком: как JIT-компиляция устроена изнутри, почему она даёт ускорение в разы именно на чистом Python, чего стоит разогрев и где границы совместимости, из-за которых PyPy до сих пор не стал интерпретатором по умолчанию.
Два движка: пошаговый интерпретатор и JIT
CPython исполняет байткод по одной инструкции: прочитал, выполнил, пошёл дальше. У строки кода, которая выполняется миллион раз, каждая итерация проходит этот цикл заново. JIT — just-in-time компиляция — работает иначе: движок замечает горячие участки, которые выполняются снова и снова, компилирует их в машинный код процессора — и дальнейшие итерации идут мимо интерпретатора напрямую. Проверим сценарий двух прогонов одной и той же функции:
def total_price(prices):
total = 0
for p in prices:
total = total + p
return total
prices = [120, 90, 240, 60, 310, 45, 500]
print('первый прогон:', total_price(prices))
print('второй прогон:', total_price(prices))
print('третий прогон:', total_price(prices))
первый прогон: 1365 второй прогон: 1365 третий прогон: 1365
Результаты одинаковы — язык гарантирует поведение, а различие прячется во времени. На CPython все три прогона заняли бы почти одно и то же время. На PyPy первый прогон был бы заметно медленнее остальных: он тратит время не только на работу, но и на наблюдение и компиляцию, зато разогретые прогоны идут в машинном коде в разы быстрее. В песочнице разницы во времени почти нет — и это честно: интерпретатор здесь CPython, а не PyPy. Конкретные времена мы в печать не выносим сознательно: они зависят от машины и версии, а вот результаты — переносимая величина.
| Среда | Как исполняется код |
|---|---|
| CPython | Байткод по одной инструкции на виртуальной машине |
| PyPy | Интерпретация + компиляция горячих участков в машинный код прямо во время работы |
| Java, C# | Тот же JIT-принцип: разогрев, горячие методы, машинный код |
| Nuitka из урока 12 | Заранее в C и машинный код, без разогрева, но и без наблюдения за реальным профилем |
Сравнение с Nuitka из урока 12 полезное: оба ускоряют Python, но в противоположных направлениях. Nuitka компилирует всё заранее, до запуска; PyPy наблюдает за настоящей работой и вкладывается только в то, что реально горячее. Урок 15 показывал, что байткод CPython — текст для его виртуальной машины; PyPy читает тот же язык, но строит собственные внутренние представления.
Типичная задача PyPy: долгий чистый цикл
Классический профиль, где PyPy обгоняет CPython сильнее всего: длинный цикл на чистом Python, без библиотек с машинным кодом внутри. Достаточно большой, чтобы разогрев окупился, и достаточно честный — вся работа в языковых конструкциях:
total = 0
for i in range(1, 200001):
if i % 3 == 0 or i % 5 == 0:
total += i
print('сумма кратных 3 или 5 от 1 до 200000:', total)
сумма кратных 3 или 5 от 1 до 200000: 9333366668
Двести тысяч итераций, и ни одна строка не выходит за пределы языка. На CPython цикл отрабатывает за секунды — интерпретатор честно проживает каждую инструкцию. На PyPy после разогрева тот же цикл ускоряется в разы: компилятор переводит условие и сложение в машинный код и убирает всю обвязку интерпретации. Насколько именно в разы — зависит от машины и версии, поэтому в уроке печатается только результат: он одинаковый на обоих движках, и в этом суть — PyPy ускоряет исполнение, не меняя семантику.
Код узнаёт PyPy по sys.implementation
Ветвление из урока 16 работает и для PyPy: имя реализации попадает в сравнение, и код может выбирать путь под движок:
import sys
print('мы на PyPy?', sys.implementation.name == 'pypy')
print('реализация:', sys.implementation.name)
мы на PyPy? False реализация: cpython
В песочнице и локально ответ False — среда CPython, и это не ошибка примера, а его содержание: у вас нет причин притворяться, что движок другой. На настоящем PyPy первая строка напечатала бы True. Нужны такие проверки редко: обычному коду всё равно, кто его исполняет, — ветвления по реализации живут на границах, например, чтобы включить особый путь оптимизации или обойти несовместимость.
Совместимость: что переезжает, а что нет
Главное правило: чистый Python переносится без правок. PyPy исполняет тот же язык, что и CPython: код, написанный на чистом Python, переезжает без единой правки. Стандартная библиотека тоже на месте. Трудности начинаются на границе с машинным кодом — там, где пакеты используют C API CPython напрямую:
| Что | На PyPy |
|---|---|
| Чистый Python | Работает как есть, ускоряется JIT-ом |
| Стандартная библиотека | Есть: часть на чистом Python, часть с совместимыми заменами |
| C-расширения через C API | Поддерживаются ограниченно и работают медленно: режим совместимости |
| cffi | Рекомендованный путь к библиотекам на C: быстрый и родной для PyPy |
| pip-пакеты | Своя копия пакетов, отдельная от CPython: бинарники не переезжают |
Именно C-расширения — причина, по которой PyPy остаётся нишевым. Большие пакеты анализа данных держат вычисления в машинном коде и так, и на них ускорение PyPy отступает на второй план, а режим совместимости с C API даже замедляет работу. Экосистема PyPy отвечает на это cffi — способом вызывать библиотеки на C без C API, — но переучивать весь мир пакетов никто не стал. Команды у PyPy свои, со своим набором пакетов:
pypy -m pip install cffi
pypy app.py
Алгоритм важнее движка
JIT ускоряет исполнение, но не исправляет алгоритм: сумма квадратов по списку в миллион элементов умножением быстрее, чем возведение в степень, на любом движке. Проверим, что оба варианта дают одинаковый результат — скорость различается, семантика нет:
def slow_sum(points):
s = 0
for x, y in points:
s = s + x * x + y * y
return s
def fast_sum(points):
return sum(x * x + y * y for x, y in points)
points = [(i, i + 1) for i in range(1000)]
print('результаты совпадают?', slow_sum(points) == fast_sum(points))
print('сумма квадратов:', fast_sum(points))
результаты совпадают? True сумма квадратов: 666667000
Результаты равны на любом движке — а вот соотношение скоростей меняется: на PyPy разница между неоптимальным циклом и генератором меньше, потому что JIT сам убирает часть накладных расходов. Это комплимент движку, но не замена голове: алгоритмическая экономия — с тысячами операций вместо миллионов — перекрывает ускорения от любой реализации.
Когда брать PyPy
Профиль работы решает всё. Долгоживущая программа на чистом Python — идеальный клиент PyPy: разогрев окупается на первых минутах. Сервис, обрабатывающий поток заказов, — тот же профиль: цикл живёт часами, и каждая процитированная итерация в машинном коде — экономия:
def process(orders):
shipped = 0
for order in orders:
if order <= 0:
continue
shipped += 1
return shipped
print('отправлено:', process([3, -1, 5, 0, 7]))
отправлено: 3
- брать PyPy: долгоживущие сервисы и обработчики потоков на чистом Python;
- брать PyPy: числительные расчёты на чистом языке, где нет готовой библиотеки с машинным кодом;
- не брать PyPy: одноразовые скрипты на секунды — разогрев не успеет окупиться;
- не брать PyPy: код, плотно связанный с C-расширениями через C API;
- не брать PyPy: встраивание в чужие приложения и работы, где нужен именно CPython-стек пакетов.
PyPy исполняет тот же язык, что и CPython: код, написанный на чистом Python, переезжает без единой правки, а выигрыш измеряется разами на горячих циклах. Цена — разогрев и ослабленная граница с C-кодом. Дальше по реализациям: MicroPython в уроке 18 выигрывает не скоростью, а размером — интерпретатор в сотни килобайт.
Сначала предскажи ответ в голове — это главный навык программиста.
import sys
print('мы на PyPy?', sys.implementation.name == 'pypy')
def process(orders):
shipped = 0
for order in orders:
if order <= 0:
continue
shipped += 1
return shipped
print(process([3, -1, 5, 0, 7]))
total = 0
for i in range(1, 11):
if i % 3 == 0 or i % 5 == 0:
total += i
print(total)
1. Что делает JIT-компиляция PyPy?
2. Почему первый прогон функции на PyPy медленнее последующих?
3. На каком коде ускорение от PyPy самое большое?
4. Как код узнаёт, что он запущен на PyPy?
5. Что рекомендует экосистема PyPy для вызова библиотек на C?
6. Что переезжает с CPython на PyPy без единой правки?
7. Когда PyPy проигрывает CPython?
Посчитайте сумму чётных чисел от 1 до 100000 циклом и напечатайте результат одним числом. Результат должен быть одинаков на CPython и PyPy.
Во сколько раз PyPy быстрее CPython?
На горячих циклах чистого Python — в разы, от двух-трёх до десятков в зависимости от задачи. На коде, где вся работа в C-расширениях, выигрыша нет, а на коротких скриптах PyPy может оказаться медленнее из-за разогрева.
Урок 16: реализации PythonРаботает ли numpy на PyPy?
Numpy держит вычисления в машинном коде через C API, поэтому ускорения от JIT он не получает, а режим совместимости с C API замедляет вызовы. Для PyPy экосистема рекомендует cffi-подход к библиотекам на C или чистый Python.
Можно ли запускать обычные pip-пакеты на PyPy?
Да, через свой интерпретатор: pypy -m pip install пакет. У PyPy отдельная среда пакетов, а бинарные сборки для CPython не работают — пакет должен быть чистым Python или иметь сборку для PyPy.
Чем PyPy отличается от Nuitka?
PyPy ускоряет во время работы: JIT наблюдает за реальным профилем и компилирует горячие участки. Nuitka компилирует всё заранее в C, без разогрева, но полученный exe — тема урока про Nuitka, а задача PyPy — скорость долгоживущих программ.
Урок 12: NuitkaНужно ли менять код, чтобы он работал на PyPy?
Для чистого Python — нет: язык и стандартная библиотека те же, результаты идентичны. Менять стоит только код, опирающийся на C-расширения или на CPython-детали вроде подсчёта ссылок.
Понравился урок? Сошлитесь на него
«PyPy исполняет тот же язык, что и CPython: код, написанный на чистом Python, переезжает без единой правки.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что дальше
Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.
Что читать дальше
Компиляция · Урок 16
Реализации Python: CPython и альтернативные среды
Python — это язык, а CPython — его главная реализация. Разбираем, кто ещё исполняет Python-код, как узнать свою среду через sys.implementation и что гарантировано любому коду на любой реализации.
Компиляция · Урок 12
Nuitka: компиляция Python-кода в C
От упаковки к компиляции: как Nuitka переводит программу в C, что становится машинным кодом, а что остаётся PyObject — и где ждать ускорения.
Компиляция · Урок 18
MicroPython: Python для микроконтроллеров ESP32 и RP2040
Python, который живёт на чипе за пару долларов: интерпретатор в сотни килобайт, модуль machine для пинов и стандартная библиотека, ужатая до сути. Разбираем отличия от CPython и стиль кода под маленькую память.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
asyncio · Урок 17
Мини-проект: загрузка страниц без сети
Сеть необязательна: шесть «страниц», фиксированные задержки и asyncio — и ты своими глазами увидишь, как конкурентная загрузка сжимает десять секунд до трёх.
asyncio gather ускорение
ООП · Урок 4
Методы: обычные, classmethod и staticmethod
У методов Python три лика: обычный с self, classmethod с cls и staticmethod без ничего. Строим альтернативный конструктор from_string и расставляем границы.
staticmethod когда использовать
Компиляция · Урок 5
Версии Python и magic-число: почему pyc не совместим
Каждая минорная версия CPython получает новое magic-число, и pyc одной версии не грузится другой. Находим число в importlib, сверяем с заголовком pyc и проверяем версию кодом.
совместимость версий pythoncpython версии