PyPy: как ускорить Python в разы без изменения кода

Самый дешёвый способ ускорить Python-код — вообще его не трогать. Без профилировщика, без переписывания горячих участков на C, без смены архитектуры: я просто запускал те же самые скрипты не обычным интерпретатором, а PyPy. На одних задачах это давало ускорение в разы, на других — в десятки раз. А на третьих PyPy оказывался медленнее CPython, и это тоже важно знать заранее.
Ниже — как PyPy ускоряет Python без изменения кода, на каких задачах он выстреливает, где бесполезен и как подключить его к проекту за пару команд.
Что такое PyPy и откуда берётся скорость
Когда мы пишем python script.py, почти всегда работает CPython — эталонная реализация языка. Она интерпретирует байт-код: каждая итерация цикла снова проходит через интерпретатор, даже если функция вызывается в миллионный раз.
PyPy — альтернативная реализация Python со встроенным JIT-компилятором (just-in-time). Идея простая: PyPy замечает участки, которые выполняются снова и снова, компилирует их в машинный код прямо во время работы программы и дальше использует уже готовый результат. Это называют «разогревом» JIT: первые итерации идут обычным путём, а затем горячий код начинает работать в разы быстрее.
По данным самих разработчиков, PyPy в среднем примерно в 4 раза быстрее CPython 3.11 на их наборе бенчмарков. Но «в среднем» здесь ключевое слово: разброс между задачами огромный.
Разогрев JIT: почему длинные задачи ускоряются сильнее
Первый тест, который я сделал, — функция с математикой внутри цикла:
import time
def compute(n: int) -> int:
total = 0
for i in range(n):
total += (i * i) % 7
return total
start = time.perf_counter()
compute(500_000)
print(f"{time.perf_counter() - start:.3f} s")
На 500 тысячах итераций CPython тратил около секунды, PyPy — в разы меньше. Затем я начал менять нагрузку, и картина стала показательной:
- на маленьком числе итераций разница была всего в несколько раз — JIT просто не успевает толком разогреться;
- на двух миллионах итераций отрыв стал заметно больше;
- на десяти миллионах CPython ощутимо задумывался, а PyPy отрабатывал почти мгновенно.
Вывод: если скрипт целиком выполняется за доли секунды, PyPy может почти ничего не дать. Чем дольше и тяжелее вычисления, тем сильнее эффект. Разработчики PyPy сами отмечают, что JIT нужна хотя бы секунда-другая работы, чтобы раскрыться.
Классы и «живой» код
Синтетические циклы — это хорошо, но реальный код состоит из объектов и методов. Поэтому второй тест был ближе к жизни: классы заказов, методы с подсчётами и обработка миллиона таких заказов.
На CPython обработка заняла около 7 секунд, на PyPy — примерно в три раза быстрее. И снова: чем больше заказов я давал, тем заметнее становился отрыв. Объектный код на чистом Python — как раз то, что PyPy оптимизирует хорошо.
Сеть и I/O: где ускорять почти нечего
Логичный вопрос — что будет с сетевыми запросами. Я отправлял один и тот же HTTP-запрос 50 раз подряд: CPython справился примерно за 5 секунд, PyPy — примерно за 4. Небольшой выигрыш есть, но основное время уходит на ожидание сети, а не на работу интерпретатора. Ускорить ответ сервера JIT не может.
Другое дело, если после загрузки страницы вы её разбираете: парсите текст, считаете, гоняете регулярные выражения. Эта часть уже CPU-bound, и PyPy снова начинает выигрывать.
Потоки, GIL и процессы
В PyPy тоже есть GIL — глобальная блокировка интерпретатора. Поэтому пять потоков, каждый из которых выполняет тяжёлые вычисления, не начинают работать параллельно. В моём тесте с пятью воркерами разница с CPython была, но небольшая: каждый поток ускорялся за счёт JIT, а не за счёт параллельности.
Совсем другая картина с multiprocessing. Каждый процесс получает свой интерпретатор и свой GIL, а внутри каждого процесса работает JIT. На CPU-bound задаче — суммирование, условия, циклы — PyPy с процессами дал самый впечатляющий результат во всём тесте. При этом код я не менял: только интерпретатор.
from multiprocessing import Pool
def heavy(n: int) -> int:
return sum(i * i for i in range(n) if i % 3)
if __name__ == "__main__":
with Pool() as pool:
print(sum(pool.map(heavy, [5_000_000] * 8)))
Где PyPy не поможет или навредит
Здесь начинаются подводные камни, из-за которых «просто заменить python на pypy» не всегда работает.
Библиотеки на C. PyPy совместим с чистым Python, но расширения на C работают через прослойку совместимости, и часть пакетов под PyPy не собирается вовсе. Я взял небольшую игру на pygame — в моём случае модуль под PyPy не установился, и запустить игру не получилось.
Код, который уже работает на C. Перебор MD5-хешей через itertools.product на CPython занял около 18 секунд, на PyPy — около 17. Почти никакой разницы: itertools в CPython реализован на C, и узкое место уже работает в машинном коде. Когда я переписал перебор на чистых циклах Python, PyPy снова вырвался вперёд, а на длинных паролях разница становилась огромной.
Pillow и обработка изображений. Pillow делает основную работу в C-коде, и JIT нечего оптимизировать. Хуже того, из-за прослойки совместимости в моём тесте PyPy обрабатывал изображение примерно в 14 раз медленнее CPython. Для таких сценариев PyPy использовать не стоит.
Короткие скрипты. Если программа завершается за доли секунды, JIT не успевает разогреться.
Как понять, подойдёт ли PyPy проекту
| Сценарий | Что ожидать |
|---|---|
| Долгие вычисления на чистом Python: циклы, математика, алгоритмы | Ускорение в разы, иногда в десятки раз |
| Объектный код: классы, методы, обработка коллекций | В моих тестах — примерно в 3 раза быстрее |
| Сеть и ожидание I/O | Небольшой выигрыш или никакого |
| Библиотеки, которые всё делают на C (Pillow, numpy-подобные) | Без выигрыша или медленнее |
| Скрипты на доли секунды | JIT не успевает помочь |
Самый надёжный способ — прогнать свой реальный код на обоих интерпретаторах и сравнить время. Это занимает пять минут и сразу отвечает на вопрос.
Как поставить PyPy
Проще всего скачать готовую сборку с pypy.org, распаковать архив и запустить бинарник из каталога bin:
./pypy3.11-v7.3.x-linux64/bin/pypy3 script.py
Я делаю иначе — через менеджер uv, потому что он сам скачивает интерпретаторы и закрепляет их за проектом:
uv init
uv python list pypy # какие сборки PyPy доступны
uv python install pypy # установить PyPy
uv python pin pypy # закрепить PyPy за проектом
uv run main.py # теперь проект запускается на PyPy
Команда uv python pin записывает выбранный интерпретатор в файл .python-version, и все следующие uv run используют PyPy. Если что-то пошло не так, файл можно поправить вручную.
Итог
PyPy — не волшебная кнопка, а очень сильный инструмент для конкретного класса задач: долгие вычисления и объектный код на чистом Python. Там он даёт ускорение, на которое иначе ушли бы дни оптимизации. В I/O, коротких скриптах и коде, который и так работает на C, выигрыша нет, а иногда становится хуже.
Поэтому правило такое: сначала замерьте свой сценарий на CPython и PyPy, и только потом переключайтесь. Если ваша проблема не в скорости интерпретатора, а в памяти, посмотрите разбор того, почему Python list может съесть всю память. А если хотите увереннее писать объектный код, который потом так хорошо ускоряется, — классы, наследование, SOLID и паттерны подробно разобраны в моём курсе по Python ООП.