Перейти к основному содержимому

Оптимизация кода на Python

📺 Слайды к лекции

Почему Python медленный?

Этот вопрос задают часто, но формулировка неточная. Python не медленный — он медленный для определённого класса задач. Веб-приложения, скрипты автоматизации, прототипы — Python тянет без проблем. Проблемы начинаются с тяжёлыми числовыми вычислениями, обработкой больших массивов данных в циклах или real-time задачами.

Чтобы понять, где теряется производительность, нужно заглянуть внутрь CPython. Всё, что мы изучали в предыдущих лекциях — модель данных, протоколы, дескрипторы — имеет свою цену.

Динамическая типизация: цена гибкости

Когда вы пишете a + b, компилятор C генерирует одну машинную инструкцию ADD. CPython же выполняет цепочку:

  1. Извлечь объект a из фрейма (dict lookup по имени переменной)
  2. Извлечь объект b
  3. Определить тип a — прочитать поле ob_type из PyObject_HEAD
  4. Найти метод __add__ в MRO типа a (поиск по tp_as_number->nb_add)
  5. Проверить, поддерживает ли тип b эту операцию
  6. Если a.__add__ вернул NotImplemented — попробовать b.__radd__
  7. Выполнить саму операцию
  8. Упаковать результат в новый PyObject
  9. Обновить счётчик ссылок для a, b и результата

Одна строчка a + b — десятки вызовов C-функций внутри CPython. В C — одна инструкция.

Memory overhead: всё — объект

Вспомните модель данных из первой лекции: в Python всё — объект. Каждый объект содержит как минимум PyObject_HEAD:

  • ob_refcnt — счётчик ссылок (8 байт)
  • ob_type — указатель на тип (8 байт)

Целое число 42 в C занимает 4 байта. В Python — 28 байт (sys.getsizeof(42)). Список из миллиона чисел хранит миллион указателей на миллион отдельных объектов, разбросанных по памяти — кеш процессора работает неэффективно.

NumPy Array vs Python List

NumPy-массив хранит данные компактно — один непрерывный блок памяти с заголовком (data, dimensions, strides). Python-список хранит массив указателей, каждый ведёт к отдельному PyObject в произвольном месте кучи. Разница в потреблении памяти и скорости итерации — на порядки.

Интерпретируемость: нет глобальной оптимизации

CPython компилирует исходный код в байткод, затем интерпретирует его инструкция за инструкцией. Компилятор видит одну строку за раз — никакой межпроцедурной оптимизации, инлайнинга функций или автовекторизации. Каждый вызов функции — это создание нового фрейма, dict для локальных переменных (до Python 3.11), обновление стека.

GIL — ограничение многопоточности

GIL (Global Interpreter Lock) — мьютекс, который позволяет только одному потоку выполнять байткод Python в каждый момент времени. Потоки полезны для I/O-bound задач, но бесполезны для CPU-bound вычислений. Подробно мы разбирали это в лекции о многозадачности.

Профилирование: научный метод оптимизации

Представьте: вы потратили день на переписывание функции с Cython, получили 10x ускорение — а общее время работы программы уменьшилось на 0.3%. Потому что эта функция занимала 0.03% общего времени.

Оптимизация без профилирования — это гадание. Профилирование — это научный метод: гипотеза → измерение → вывод.

Шаг 1: Бенчмаркинг — «сколько?»

Самый простой способ — UNIX time:

$ time python my_script.py
python my_script.py 0.02s user 0.01s system 0% cpu 10.061 total

Для точного измерения конкретных участков — timeit:

timeit_example.py
from timeit import timeit

def factorial(n: int, /) -> int:
return n * factorial(n - 1) if (n > 0) else 1

total_seconds = timeit(lambda: factorial(100), number=10_000)
print(total_seconds)

Из командной строки:

$ python -m timeit --setup "text = 'sample string'; char = 'g'" "char in text"
20000000 loops, best of 5: 13.5 nsec per loop

Бенчмарк отвечает на вопрос «сколько?», но не на вопрос «почему?». Если func() тормозит — бенчмарк покажет время, но не скажет, какая из вызываемых ею функций виновата.

Осторожно с бенчмарками

Программа может тормозить из-за накопительных эффектов. Функция bar() занимает 100 мс, но baz() вызывается 1000 раз по 1 мс — суммарно baz тратит в 10 раз больше. Профилировщик покажет это, бенчмарк — нет.

Шаг 2: Профилирование — «почему?»

Профиль — набор статистических показателей: как часто и как долго выполнялись различные части программы (количество вызовов, длительность, стек).

cProfile — стандартный профилировщик

Написан на C, минимальный overhead:

cprofile_example.py
import cProfile

def factorial(n: int, /) -> int:
if not isinstance(n, int):
raise TypeError("'n' must be 'int'")
return n * factorial(n - 1) if (n > 0) else 1

cProfile.run("factorial(10_000)")
     20005 function calls (10005 primitive calls) in 0.037 seconds

ncalls tottime percall cumtime percall filename:lineno(function)
10001/1 0.036 0.000 0.037 0.037 main.py:4(factorial)
10001 0.001 0.000 0.001 0.000 {built-in method builtins.isinstance}

Запуск без изменения кода:

python -m cProfile -s cumtime my_script.py

Стандартная библиотека также содержит profile — аналог на Python. Медленнее, но расширяемый. Полезен, если нужно кастомизировать логику сбора статистики.

cProfile и потоки

При профилировании многопоточного кода cumtime для Thread.join может превышать реальное время работы — cProfile суммирует время ожидания каждого потока. С C-расширениями аналогично: время внутри C-функции будет одним блоком без детализации.

Scalene — профилировщик нового поколения

Scalene — sampling-профилировщик, который одновременно показывает CPU, память и GPU по строкам. Не требует модификации кода:

pip install scalene
scalene my_script.py

Scalene выводит HTML-отчёт с построчным разбором: какие строки тратят CPU, какие аллоцируют память, и даже предлагает AI-подсказки по оптимизации. Для студенческих и рабочих проектов — самый наглядный инструмент.

py-spy — flamegraphs для продакшна

py-spy — sampling-профилировщик на Rust. Его суперсила — возможность подключиться к уже работающему процессу без его остановки:

pip install py-spy
py-spy top --pid 12345 # live top-like view
py-spy record -o flame.svg -- python my_script.py # flamegraph

Flamegraph — визуализация, где ширина полоски пропорциональна времени. Мгновенно видно, где программа проводит большую часть времени.

Memray — детальный анализ памяти

Memray от Bloomberg отслеживает каждую аллокацию. Полезен, когда подозреваете утечки памяти или избыточное потребление:

pip install memray
memray run my_script.py
memray flamegraph memray-output.bin

Порядок профилирования

  1. time / timeit — общее время, сравнение подходов
  2. cProfile — какие функции тратят больше всего времени
  3. Scalene — построчный CPU + память
  4. py-spy — flamegraphs, анализ в продакшне
  5. line_profiler — построчный анализ горячих функций
  6. Memray — если подозреваете проблемы с памятью

Оптимизация чистого Python

Прежде чем тянуться к Numba или Cython, стоит исчерпать возможности самого Python. Эти оптимизации работают в любом проекте и не требуют дополнительных зависимостей.

Выбор структур данных

Самый большой выигрыш часто даёт не оптимизация кода, а выбор правильной структуры данных:

set_vs_list.py
import timeit

data = list(range(1_000_000))
data_set = set(data)

# O(n) — линейный поиск
timeit.timeit(lambda: 999_999 in data, number=100) # ~1.8s

# O(1) — хеш-таблица
timeit.timeit(lambda: 999_999 in data_set, number=100) # ~0.000003s
ЗадачаМедленноБыстро
Поиск элементаlist O(n)set / dict O(1)
Добавление/удаление с началаlist O(n)collections.deque O(1)
Вставка в отсортированный списокРучной поиск O(n)bisect.insort O(n), но с бинарным поиском
Подсчёт элементовРучной циклcollections.Counter
Слияние словарейЦикл .update(){**a, **b} или a | b (3.9+)
VISION-вопрос

Почему x in set — O(1), а x in list — O(n)? Потому что set — хеш-таблица: элемент хешируется, вычисляется индекс в массиве, проверяется одна ячейка. list — массив указателей: для поиска нужно пройти все элементы по очереди.

__slots__ — экономия памяти объектов

По умолчанию Python хранит атрибуты экземпляра в __dict__ — полноценном словаре. Для класса с тремя атрибутами это ~100 байт overhead на каждый экземпляр. __slots__ заменяет dict на фиксированный массив:

slots_example.py
import sys

class PointDict:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z

class PointSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z

p1 = PointDict(1, 2, 3)
p2 = PointSlots(1, 2, 3)

print(sys.getsizeof(p1) + sys.getsizeof(p1.__dict__)) # ~200 байт
print(sys.getsizeof(p2)) # ~64 байта

Экономия 40-50% памяти + ускорение доступа к атрибутам (нет dict lookup). Подробнее — в лекции об ООП.

functools.cache и lru_cache — мемоизация

Если функция чистая (результат зависит только от аргументов), её результаты можно кешировать. Вспоминаем лекцию о функциональном программировании — чистые функции идеальны для мемоизации:

cache_example.py
from functools import cache
import timeit

def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)

@cache
def fib_cached(n):
if n < 2:
return n
return fib_cached(n - 1) + fib_cached(n - 2)

timeit.timeit(lambda: fib(30), number=1) # ~0.18s — экспоненциальная рекурсия
timeit.timeit(lambda: fib_cached(30), number=1) # ~0.00001s — O(n) с кешем

functools.cache (Python 3.9+) — неограниченный кеш. lru_cache(maxsize=N) — с вытеснением по LRU. Для чисто числовых аргументов разница с ручным кешированием через dict минимальна.

Генераторы вместо списков

Когда нужно обработать большой объём данных, но не хранить всё в памяти одновременно:

generators_vs_lists.py
# Создаёт список из 10M элементов в памяти — ~80 МБ
total = sum([x * x for x in range(10_000_000)])

# Генератор — обрабатывает по одному элементу, ~0 МБ
total = sum(x * x for x in range(10_000_000))

Генераторное выражение работает чуть медленнее из-за overhead на __next__(), но экономит память на порядки. Для данных, которые не помещаются в RAM, это единственный вариант.

Встроенные функции быстрее циклов

sum(), min(), max(), sorted(), any(), all() реализованы на C и работают быстрее эквивалентных циклов на Python:

builtins.py
data = list(range(1_000_000))

# Медленно — Python-цикл
total = 0
for x in data:
total += x

# Быстро — C-реализация
total = sum(data)

Конкатенация строк

str_join.py
parts = ["hello"] * 100_000

# Медленно — O(n²), каждый += создаёт новую строку
result = ""
for p in parts:
result += p

# Быстро — O(n), одна аллокация
result = "".join(parts)

NumPy — векторизация

NumPy выполняет операции на уровне скомпилированного C-кода, минуя интерпретатор Python для каждого элемента:

numpy_vectorization.py
import numpy as np
import timeit

n = 1_000_000

def python_sum():
return sum(i * i for i in range(n))

arr = np.arange(n)
def numpy_sum():
return np.sum(arr * arr)

t1 = timeit.timeit(python_sum, number=10)
t2 = timeit.timeit(numpy_sum, number=10)
print(f"Python: {t1:.3f}s, NumPy: {t2:.3f}s")
# NumPy обычно в 10-100 раз быстрее

Почему NumPy быстрый: memory layout

NumPy-массив — один непрерывный блок памяти с данными одного типа. Операция arr * arr транслируется в C-цикл, который итерирует по памяти последовательно. Процессорный кеш загружает данные блоками (cache lines по 64 байта) — если данные расположены рядом, следующий элемент уже в кеше.

У NumPy-массива есть strides — шаги в байтах между элементами по каждой оси. Для C-contiguous массива (row-major, по умолчанию) строки расположены последовательно. Для Fortran-contiguous (column-major) — столбцы. Итерация вдоль contiguous-оси быстрее, поперёк — медленнее из-за cache misses.

Практический совет

Избегайте циклов по элементам NumPy-массива. Вместо for x in arr: result += x*x используйте np.sum(arr * arr). Если вы пишете for рядом с np.array — скорее всего, что-то не так.

Numba — JIT-компиляция

Когда NumPy-векторизация невозможна (сложная логика в цикле, зависимости между итерациями), Numba транслирует Python-функции в машинный код с помощью LLVM.

pip install numba

Базовое использование

numba_basic.py
from numba import njit
import numpy as np

@njit
def fast_sum(arr):
total = 0.0
for x in arr:
total += x * x
return total

arr = np.random.random(1_000_000)
result = fast_sum(arr) # Первый вызов — компиляция (~секунды)
result = fast_sum(arr) # Последующие — быстро

С Numba 0.59+ декораторы @jit и @njit эквивалентны — оба работают в nopython-режиме по умолчанию. Если функцию не удаётся полностью скомпилировать, Numba бросает ошибку (раньше @jit молча откатывался в медленный object mode).

Как Numba работает под капотом

Когда вы вызываете функцию с @njit в первый раз, Numba:

  1. Type inference — анализирует типы аргументов и прослеживает их через всю функцию. Если вы передали np.float64[:], Numba знает, что каждый элемент — float64.
  2. Numba IR — транслирует Python-байткод в собственное промежуточное представление (IR).
  3. LLVM IR — конвертирует Numba IR в LLVM IR — тот же формат, который используют Clang (C/C++) и Rust.
  4. Machine code — LLVM применяет десятки оптимизаций (инлайнинг, автовекторизация, loop unrolling) и генерирует нативный машинный код для вашей архитектуры.

Результат: нет вызовов Python C API, нет refcounting, нет dict lookup — чистые машинные инструкции над типизированными данными. Именно поэтому nopython-режим быстрый, а object mode (который сохраняет Python-объекты) — нет.

Аргументы njit

  • cache=True — сохраняет скомпилированный код на диск. При следующем запуске компиляция не нужна.
  • parallel=True — автоматическое распараллеливание циклов с prange.
  • fastmath=True — разрешает LLVM небезопасные FP-оптимизации (аналог -ffast-math). Жертвует точностью IEEE 754.
  • nogil — deprecated с Numba 0.59: в nopython-режиме GIL отпускается автоматически.

parallel и prange

numba_parallel.py
from numba import njit, prange
import numpy as np

@njit(parallel=True)
def mean_rmse_parallel(input_errors):
rmse_sum = 0.0
for row_idx in prange(len(input_errors)):
row = input_errors[row_idx]
rmse_sum += np.sqrt(np.sum(row) ** 2)
return rmse_sum / len(input_errors)

Результаты на RMSE-примере (Ryzen 12-core):

ВерсияВремя
Чистый Python1.2 s ± 6.95 ms
@njit648 ms ± 1.98 ms
@njit(parallel=True)203 ms ± 782 µs

Ускорение сублинейное (6x на 12 ядрах) — overhead на синхронизацию и неравномерную нагрузку.

fastmath и Intel SVML

fastmath=True — это флаг LLVM, разрешающий переупорядочивать операции с плавающей точкой. Отдельно от fastmath Numba может использовать Intel SVML — оптимизированные реализации математических функций (sin, cos, sqrt). SVML подключается автоматически при наличии пакета icc_rt и эффективнее на Intel CPU.

Пример (сумма корней, 100M)RyzenIntel
Без fastmath19.7 ms28.2 ms
С fastmath19.6 ms11.3 ms

На AMD эффект минимален (нет SVML). На Intel — 2.5x за счёт комбинации fastmath и SVML.

warning

fastmath жертвует точностью IEEE 754 — не используйте для финансовых вычислений или научных расчётов, где важна точность.

Сигнатуры типов

Можно явно указать типы, чтобы ускорить первую компиляцию:

numba_signature.py
@njit('f8(f8[:, :])')
def mean_rmse_typed(input_errors):
...

# Или через объекты:
import numba as nb
@njit(nb.float64(nb.float64[:, :]))
def mean_rmse_typed2(input_errors):
...

Numba: итоги

Максимальный эффект: NumPy-массивы, тяжёлые числовые циклы, задачи с prange. Не работает со строками, словарями, произвольными объектами. GPU через пакет numba-cuda (NVIDIA). AOT-компиляция (numba.pycc.CC) deprecated — используйте cache=True.

Cython — компиляция Python в C

Cython — язык, расширяющий Python типами данных из C. Любой валидный Python — валидный Cython. Основное ускорение даёт добавление C-типов.

pip install cython

Почему C-типы ускоряют код

Когда Cython видит cdef int i, он генерирует C-переменную — не PyObject с refcounting, а обычный int на стеке. Для неё не нужен:

  • PyObject_HEAD (экономия 16 байт на переменную)
  • Подсчёт ссылок при каждом присваивании
  • Dict lookup для доступа к атрибутам
  • Поиск метода в MRO при каждой операции

Цикл с cdef int i компилируется в обычный C-цикл for (int i = 0; i < n; i++) — такой же, как написал бы программист на C.

cython -a — ключевой инструмент

Команда cython -a myfile.pyx генерирует HTML-файл с аннотациями. Жёлтые строки — вызовы Python C API (медленные). Белые — чистый C (быстрые). Цель оптимизации — сделать горячие циклы полностью белыми.

Синтаксис

cdef float x = 5.0         # C-переменная
cdef int i, j
cdef double *ptr # указатель

cdef struct Point: # C-структура
double x
double y

Функции: def, cdef, cpdef

Ключевое словоИз PythonИз CythonКогда использовать
defДаДаPython API
cdefНетДаВнутренняя реализация, максимальная скорость
cpdefДаДаКомпромисс

Pure Python Mode (Cython 3.x)

С Cython 3.0+ можно писать обычные .py файлы с декораторами вместо .pyx:

fast_math.py
import cython

@cython.cfunc
@cython.locals(x=cython.double, result=cython.double)
def square(x):
result = x * x
return result

@cython.ccall
@cython.locals(data=cython.double[:], s=cython.double, i=cython.int)
def fast_sum(data):
s = 0.0
for i in range(data.shape[0]):
s += data[i]
return s

Это обычный Python-файл — он работает без Cython (декораторы ничего не делают). Но при компиляции Cython использует аннотации типов. Порог входа радикально ниже, чем у .pyx.

Бенчмарки

Численное интегрирование (N = 50 000 000):

ВерсияВремяУскорение
Чистый Python14.8 s1x
Cython с типами2.83 s~5x
Numba (parallel)4.56 ms~3000x

Cython блестит в чистых циклах без NumPy. Numba с параллелизацией — ещё быстрее за счёт многоядерности.

Когда Cython, а когда Numba?

КритерийNumbaCython
Сложность внедренияОдин декораторНовый язык (или Pure Python mode)
Параллелизацияprange из коробкиВручную через nogil
Работа с NumPyОтличноХорошо
C/C++ интеграцияНетНативная
GPUДа (numba-cuda)Нет
Произвольный PythonОграниченноЛюбой Python валиден

Начинайте с Numba — минимум усилий, максимум эффекта. Переходите на Cython, если нужна интеграция с C-библиотеками или Numba не справляется.

Эволюция CPython: от 3.11 к 3.15

VISION курса: «Python — не застывший стандарт. CPython ускоряют с каждым релизом». История ускорения CPython — это одна из самых интересных инженерных историй последних лет.

Python 3.11 (октябрь 2022): Faster CPython

В среднем на 25% быстрее CPython 3.10 (pyperformance, GCC, Ubuntu). В зависимости от нагрузки — 10-60%. Ключевые оптимизации:

  • Specializing adaptive interpreter — интерпретатор отслеживает типы в горячем коде и заменяет универсальные инструкции специализированными. Если BINARY_ADD всегда складывает int — подставляется быстрая версия без проверки типов.
  • Faster startup — «заморозка» стандартных модулей (marshal вместо импорта с диска).
  • Cheaper frames — фреймы вызовов больше не аллоцируются в куче, а размещаются в C-стеке.

Python 3.12 (октябрь 2023): PEP 709

Comprehensions (списков, словарей, множеств) теперь инлайнятся — без создания одноразовой функции. Ускорение на 10-20% в типичных случаях.

Python 3.13 (октябрь 2024): Free-threaded и экспериментальный JIT

Два революционных нововведения (оба экспериментальные):

Free-threaded mode (PEP 703) — сборка CPython без GIL. Потоки наконец могут выполнять Python-код параллельно на разных ядрах. В 3.13 — экспериментальный, с ~40% overhead на однопоточный код.

Copy-and-patch JIT — Tier 2 optimizer, который компилирует горячий байткод в машинный код. В 3.13 — экспериментальный, скромные результаты.

Python 3.14 (октябрь 2025): Free-threading выходит из эксперимента

Free-threaded mode официально поддерживается (уже не «experimental»). Overhead упал с ~40% до ~5-10% на однопоточном коде. Многие расширения ещё подключают GIL обратно, но экосистема активно мигрирует. Трекер совместимости: py-free-threading.github.io.

Tail-call interpreter — полностью переписанный цикл интерпретатора на основе tail-calls. Даёт ~10% среднее ускорение (до 40% на отдельных бенчмарках) — бесплатно, просто обновите Python.

Python 3.15 (ожидается октябрь 2026): JIT набирает скорость

JIT-компилятор возвращается с реальными результатами. Ранние alpha-версии показывают 11-12% ускорение поверх tail-call интерпретатора на macOS AArch64. Это copy-and-patch JIT — он не генерирует код с нуля, а собирает машинный код из предварительно скомпилированных шаблонов (stencils).

Если тренд продолжится, к 3.16-3.17 CPython может приблизиться к скорости PyPy для многих рабочих нагрузок — без жертв совместимости с C-расширениями.

Большая картина

От 3.11 к 3.15 CPython прошёл путь: специализирующий интерпретатор → инлайнинг comprehensions → free-threading + экспериментальный JIT → tail-call interpreter → полноценный JIT. Каждый релиз строит на фундаменте предыдущего. Это не хаотичные оптимизации — это спланированная дорожная карта проекта Faster CPython, запущенного Гвидо ван Россумом в Microsoft.

Современная экосистема

Polars — замена Pandas для производительности

Polars — DataFrame-библиотека на Rust с lazy evaluation и автоматической параллелизацией. Часто в 10x+ быстрее Pandas на больших датасетах. В 2026 году — мейнстрим для аналитики данных.

polars_example.py
import polars as pl

df = pl.scan_csv("large_file.csv") # lazy — не читает файл сразу
result = (
df.filter(pl.col("age") > 30)
.group_by("city")
.agg(pl.col("salary").mean())
.collect() # выполнение — оптимизированный план запроса
)

Pydantic v2 — паттерн «hot path на Rust»

Pydantic v2 переписал ядро валидации на Rust (через PyO3). Результат — 5-50x ускорение валидации. Это становится распространённым паттерном: Python-API, Rust-реализация. Другие примеры: Polars, Ruff (линтер), uv (менеджер пакетов).

mypyc — компиляция типизированного Python

mypyc компилирует type-annotated Python в C-расширения через mypy. Сам mypy компилируется mypyc и работает в 3-5x быстрее. Пока alpha, но интересная альтернатива Cython с нулевым изменением синтаксиса.

PyPy

PyPy поддерживает Python 3.11, всё ещё в 5-10x быстрее CPython для CPU-bound чистого Python. Но CPython с free-threading + JIT постепенно сокращает разрыв. Бенчмарки.

Общие стратегии оптимизации

  1. Профилируйте перед оптимизацией. Не гадайте — измеряйте.
  2. Алгоритмическая оптимизация — самый большой выигрыш. O(n²) → O(n log n) даёт больше, чем любой JIT.
  3. Правильные структуры данныхset вместо list для поиска, deque для очередей, __slots__ для объектов.
  4. Встроенные функцииsum(), min(), max(), sorted() реализованы на C.
  5. Кэшированиеfunctools.cache для чистых функций.
  6. Векторизация с NumPy — замена циклов на операции над массивами.
  7. Numba/Cython — для числовых вычислений, где NumPy недостаточно.
  8. multiprocessing — для CPU-bound задач (см. лекцию 5).
Workflow оптимизации

Шаг 1: Профилирование → найти bottleneck. Шаг 2: Алгоритм и структуры данных → правильный выбор. Шаг 3: Чистый Python → __slots__, cache, builtins, генераторы. Шаг 4: NumPy → векторизация. Шаг 5: Numba/Cython → JIT или компиляция. Шаг 6: Обновить Python → бесплатное ускорение с каждым релизом.

Каждый следующий шаг — только если предыдущего недостаточно.

Полезные ссылки