Как ускорить Python в 50 раз без смены языка
Наивный цикл, который считает 40 секунд, легко превращается в 0,8. Разбираем, чем платят за ускорение векторизация, кеш, JIT и параллелизм — и когда каждый из них уместен.

Есть скрипт: он читает миллион строк, для каждой считает расстояние до опорной точки и суммирует результат. На чистом Python это 41 секунда на ноутбуке с CPython 3.12. Тот же алгоритм на NumPy — 0,9 секунды. Разница в 45 раз, и язык прежний. Ниже — приёмы, которые дают такие цифры, с честной ценой каждого: где вы выигрываете время исполнения и чем платите — памятью, читаемостью или зависимостями.
Речь про CPython 3.11–3.13. Замеры условные: они показывают порядок ускорения, а не абсолютную истину — на вашем железе и данных цифры будут другими. Мерить нужно на своей задаче, об этом в конце.
Сначала измерьте, потом ускоряйте
Ускорять код, не зная, где он тормозит, — это гадание. Прежде чем трогать что-либо, снимите профиль.
# cProfile — встроен в стандартную библиотеку, CPython 3.11+
python -m cProfile -s cumtime myscript.pyКолонка cumtime покажет функции, в которых суммарно проведено больше всего времени. Часто оказывается, что 95% времени уходит в один цикл или один вызов, а остальной код оптимизировать бессмысленно. Для построчного разбора внутри функции ставится сторонний line_profiler — он показывает время по строкам, но замедляет исполнение, поэтому только для локальной диагностики.
Оптимизация без профиля — это трата дней на код, который выполняется 3% времени. Сначала находите горячую точку, потом бьёте прицельно.
Приём 1. Векторизация через NumPy
Самый крупный выигрыш даёт отказ от поэлементного цикла в пользу массовых операций. Цикл Python на каждой итерации проходит через интерпретатор: разбор байткода, проверка типов, боксинг чисел. NumPy 1.26+ выполняет ту же арифметику в скомпилированном C-коде над непрерывным блоком памяти.
import numpy as np
# Медленно: цикл на 1 000 000 итераций
def sum_dist_loop(xs, ys, cx, cy):
total = 0.0
for x, y in zip(xs, ys):
total += ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
return total
# Быстро: те же вычисления над массивами
def sum_dist_numpy(xs, ys, cx, cy):
xs = np.asarray(xs)
ys = np.asarray(ys)
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
return dist.sum()Цена. NumPy держит весь массив в памяти целиком — миллион float64 это 8 МБ на массив. Для потоковых данных, которые не влезают в RAM, векторизация не подходит без разбивки на блоки. И вы добавляете зависимость: NumPy тянет за собой скомпилированные бинарники, что усложняет сборку контейнера.
Приём 2. Кеширование повторяющихся вычислений
Если функция вызывается много раз с одними и теми же аргументами, результат стоит запоминать. В стандартной библиотеке для этого есть functools.lru_cache (доступен с Python 3.2) и functools.cache (Python 3.9+, кеш без ограничения размера).
from functools import lru_cache
@lru_cache(maxsize=10_000)
def expensive(n: int) -> int:
# тяжёлый детерминированный расчёт
total = 0
for i in range(n):
total += i * i
return totalКлассический пример — рекурсивные вычисления с перекрывающимися подзадачами: наивный recursive Фибоначчи на n=35 без кеша считает секунды, с lru_cache — доли миллисекунды.
Цена. Кеш работает только для чистых функций: одинаковый вход всегда даёт одинаковый выход. Аргументы должны быть хешируемыми — список или словарь передать нельзя. И кеш растёт в памяти: с functools.cache без maxsize он не очищается никогда, и это тихая утечка на долгоживущем процессе.
Приём 3. JIT-компиляция через Numba
Когда алгоритм нельзя выразить массовыми операциями NumPy — например, в нём ветвления, зависящие от предыдущей итерации, — помогает Numba. Она компилирует функцию в машинный код через LLVM при первом вызове.
from numba import njit
@njit # компиляция в машинный код, кеш между запусками через cache=True
def mandelbrot_escape(cx, cy, max_iter):
zx, zy = 0.0, 0.0
for i in range(max_iter):
zx, zy = zx * zx - zy * zy + cx, 2.0 * zx * zy + cy
if zx * zx + zy * zy > 4.0:
return i
return max_iterНа численных циклах Numba нередко даёт ускорение в десятки раз, приближаясь к C. Проверьте версию: устанавливается через pip install numba, поддержка конкретной версии CPython указана в документации проекта — не все свежие релизы Python поддерживаются сразу.
Цена. Первый вызов оплачивает компиляцию — от долей секунды до нескольких секунд. Numba понимает подмножество Python: словари произвольной структуры, объекты, многие библиотеки внутри @njit не работают. Отладка усложняется: ошибка компиляции выдаёт длинную трассу про типы, которую надо учиться читать.
Приём 4. Параллелизм под правильную задачу
GIL в CPython не даёт двум потокам одновременно исполнять байткод. Поэтому выбор инструмента зависит от того, чем занят код.
- CPU-bound (счёт, обработка чисел) —
multiprocessingилиconcurrent.futures.ProcessPoolExecutor. Отдельные процессы обходят GIL, каждый грузит своё ядро. - I/O-bound (сеть, диск, запросы к БД) —
asyncioилиThreadPoolExecutor. Пока один поток ждёт ответа, другие работают, GIL здесь не мешает.
from concurrent.futures import ProcessPoolExecutor
def process_chunk(chunk):
return sum(x * x for x in chunk)
def parallel_sum(data, workers=4):
size = len(data) // workers
chunks = [data[i:i + size] for i in range(0, len(data), size)]
with ProcessPoolExecutor(max_workers=workers) as pool:
return sum(pool.map(process_chunk, chunks))Цена. У процессов дорогой старт и передача данных: аргументы сериализуются через pickle и копируются между процессами. Если задача мелкая, накладные расходы съедят выигрыш — процессы имеют смысл на кусках работы от десятков миллисекунд и выше. С Python 3.13 появился экспериментальный режим без GIL, но на момент письма он не для продакшена и требует отдельной сборки.
Что выбрать под задачу
| Приём | Когда подходит | Порядок выигрыша | Чем платите |
|---|---|---|---|
| NumPy | Массовая арифметика над числами | 10–50x | Память, зависимость |
| lru_cache | Повторные вызовы с теми же аргументами | зависит от доли попаданий | Рост памяти, только чистые функции |
| Numba | Численные циклы с ветвлениями | 10–100x | Компиляция, подмножество Python |
| ProcessPool | CPU-bound на нескольких ядрах | до числа ядер | Старт процессов, сериализация |
| asyncio / threads | I/O-bound: сеть, диск | кратно числу ждущих операций | Сложность кода |
Приёмы комбинируются: векторизованный NumPy-код можно раскидать по процессам, а внутри Numba-функции — тоже работать с массивами. Но не начинайте с комбинаций. Один приём в горячей точке обычно закрывает вопрос.
Дешёвые улучшения без библиотек
До тяжёлой артиллерии есть приёмы на чистом CPython, дающие проценты и разы:
- Списковые включения (
[f(x) for x in it]) быстрее ручногоappendв цикле — меньше обращений к атрибуту метода. - Локальная переменная быстрее глобальной и атрибута:
append = result.appendперед циклом убирает поиск атрибута на каждой итерации. - Для проверки принадлежности используйте
set, а неlist: поиск в множестве — константа, в списке — линейный. - Строки собирайте через
''.join(parts), а не конкатенацией в цикле: конкатенация создаёт новую строку каждый раз.
Что делать дальше
Начните с профиля: python -m cProfile -s cumtime покажет, куда уходит время. Затем сделайте один замер горячей функции через timeit на реальных данных — это ваша отправная цифра. Меняйте один приём за раз и мерьте снова: без замеров «до» и «после» вы не знаете, помогло ли вообще. Условия замера фиксируйте — версия Python, объём данных, машина, — иначе цифры несравнимы. Если после NumPy или Numba узкое место уехало в другую функцию, повторите цикл там.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: The Python Profilers — документация Python, functools — документация Python
Частые вопросы
У меня Python 3.9, а не 3.11. Приёмы сработают?
Numba выдаёт длинную ошибку про типы, а не ускорение. Что делать?
Почему multiprocessing не ускорил мой код, а иногда замедлил?
lru_cache занял всю память. Как этого избежать?
Стоит ли переписывать всё на NumPy сразу?
Эти приёмы помогут в веб-приложении на Django или FastAPI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.