Все статьи

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

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 ООП.

Источники