Python Производительность: профилирование, оптимизации, C-расширения, PyPy
0%

Производительность: профилирование, оптимизации, C-расширения, PyPy

Производительность: профилирование, оптимизации, C-расширения, PyPy

Разговор про скорость Python почти всегда начинается не с того конца. Кто-то приносит микробенчмарк «Python в 80 раз медленнее C», кто-то советует заменить for на map, кто-то предлагает «переписать на Go». Между тем в реальном сервисе на Python обычно происходит следующее: 92 % времени запроса уходит на ожидание базы данных, 5 % — на сериализацию JSON, и 3 % — на весь остальной код, который и пытаются оптимизировать.

Профессиональная работа с производительностью в Python — это дисциплина, а не набор трюков. Дисциплина состоит из трёх частей: понимать модель стоимости (почему конкретная строка стоит именно столько), уметь измерять (не обманывая себя) и знать лестницу инструментов — от смены структуры данных до Rust-расширения, с трезвым представлением о цене каждой ступени.

Эта статья не пересказывает основы циклов и функций — они в курсе «Программирование с нуля». Здесь мы опираемся на устройство объектов из «Модели данных», на сложность операций из «Коллекций» и на общую методологию из трека по производительности — «Измерение» и «Рабочий процесс оптимизации».

Правило номер ноль: сначала цель, потом цифра

«Сделать быстрее» — не задача. Задача выглядит так: «p99 эндпоинта /search должен быть ниже 300 мс при 200 RPS на текущем железе» или «ночной батч должен укладываться в двухчасовое окно на 40 млн строк». Из формулировки сразу следует, что мерить (латентность хвоста или пропускную способность), на каких данных и когда остановиться.

Два системных ограничения, о которых стоит помнить до начала работы:

  • Закон Амдала. Если оптимизируемая часть занимает долю p общего времени, то даже бесконечное её ускорение даст выигрыш не больше 1 / (1 - p). Ускорив вдесятеро функцию, которая занимает 20 % времени, вы получите 18 %, а не 900 %.
  • Стоимость сложности. Каждая оптимизация — это код, который кто-то будет читать и поддерживать. Замена понятного цикла на numpy-магию обоснована, если она даёт порядок; ради 4 % это чистый убыток.

Модель стоимости CPython: откуда берутся десятки раз

CPython — это интерпретатор байт-кода, где каждое значение является объектом в куче, а каждая операция проходит через тип этого объекта. Разберём одну строку:

import dis

def total(xs):
    s = 0
    for x in xs:
        s += x * 2
    return s

dis.dis(total)

Тело цикла компилируется в шесть инструкций:

  6          16 LOAD_FAST                1 (s)
             18 LOAD_FAST                2 (x)
             20 LOAD_CONST               2 (2)
             22 BINARY_OP                5 (*)
             26 BINARY_OP               13 (+=)
             30 STORE_FAST               1 (s)

Три «дешёвых» опкода читают слоты кадра, а два BINARY_OP делают всю настоящую работу: проверяют типы обоих операндов, распаковывают PyLongObject в машинные целые, выполняют арифметику — и выделяют новый объект под результат. Целое число в CPython занимает 28–32 байта (sys.getsizeof(2**30) == 32), кеш малых целых покрывает только диапазон от −5 до 256. То есть на каждый элемент приходится две аллокации, две операции с счётчиком ссылок и несколько проверок типа — при том, что «полезной работы» здесь на один такт процессора.

Анатомия горячего цикла в CPython

Начиная с Python 3.11 адаптивный интерпретатор (PEP 659) заменяет BINARY_OP на специализированный BINARY_OP_MULTIPLY_INT, если типы стабильны, — это убирает часть диспетчеризации, но не убирает аллокацию. Именно поэтому «Faster CPython» дал десятки процентов, а не десятки раз: устранён накладной расход интерпретации, а не сама объектная модель.

Практическая таблица порядков (CPython 3.12, x86-64; ваши числа будут другими, важны соотношения):

Операция Порядок Комментарий
Один простой опкод 1–3 нс LOAD_FAST, STORE_FAST
x + 1 для int ~12 нс с аллокацией результата
Вызов пустой функции Python ~22 нс кадр, аргументы, возврат
Поиск в dict по строке 35–45 нс хеш строки кеширован
Обращение к атрибуту объекта 20–40 нс см. ООП
Возбуждение и перехват исключения 100–200 нс трейсбек строится всегда
Элемент массива в numpy/C 0,2–1 нс без аллокаций, с SIMD
Обращение к странице вне кеша L3 ~100 нс см. кеш и локальность

Отсюда следует главный вывод, который стоит запомнить дословно: быстрый Python — это Python, который выполняет мало опкодов. Не «код без циклов», а код, где цикл уехал внутрь C: в sum, str.join, dict, re, numpy, в ваше собственное расширение.

Лестница ускорений

Как мерить, чтобы не обмануть себя

Самая частая ошибка — доверять единичному time.time() вокруг куска кода. Между двумя запусками меняются: частота процессора (turbo и тепловое троттлинг), содержимое кешей, адресное пространство (ASLR влияет на выравнивание), фаза сборщика мусора, соседние процессы. Разброс в 20–30 % на «одинаковых» прогонах — норма.

import timeit

setup = "data = list(range(1_000_000))"

# repeat даёт серию замеров; берём минимум — он ближе всего к «без помех»
loop = min(timeit.repeat("t = 0\nfor v in data: t += v", setup, repeat=5, number=3)) / 3
fast = min(timeit.repeat("sum(data)", setup, repeat=5, number=3)) / 3

print(f"ручной цикл: {loop*1000:.1f} мс")   # ~22 мс
print(f"builtins.sum: {fast*1000:.1f} мс")  # ~6.8 мс — тот же алгоритм, но цикл в C

Почему минимум, а не среднее: помехи могут только замедлить измерение, но не ускорить его, поэтому минимум — наиболее чистая оценка. Для оценки стабильности смотрят на разброс, и здесь timeit уже не хватает — нужен pyperf:

python -m pyperf timeit --setup "data=list(range(1_000_000))" "sum(data)"
# Mean +- std dev: 6.83 ms +- 0.09 ms

sudo python -m pyperf system tune     # фиксирует частоты, отключает turbo, изолирует CPU
python -m pyperf compare_to base.json new.json --table

Правила честного микробенчмарка:

  1. Данные реальные. range(1000) сортируется совсем не так, как реальный лог; для ветвлений предсказатель переходов на синтетике врёт особенно сильно.
  2. Прогрев отдельно от замера. Первый вызов включает импорт, компиляцию регулярок, заполнение кешей. Для PyPy и Numba прогрев обязателен и занимает тысячи итераций.
  3. Не оптимизируйте то, что не попало в профиль. Микробенчмарк отвечает на вопрос «какой из двух вариантов быстрее», а не «стоит ли это трогать».
  4. Результат должен использоваться. Если функция возвращает значение, которое никто не читает, можно случайно измерить не то (в Python это менее опасно, чем в C, но ленивые генераторы «не выполняются», пока их не потребить).
  5. Сравнивайте на одном железе и одной версии. Замер на ноутбуке с включённым энергосбережением не переносится на прод.

Подробнее о методологии, доверительных интервалах и подводных камнях бенчмаркинга — в «Бенчмаркинге».

Профилирование: четыре разных вопроса

Профайлеры отвечают на разные вопросы, и путать их дорого.

Вопрос Инструмент Накладные расходы Где применять
Какая функция ест CPU? cProfile, Scalene 1,3–2× локально, на тесте
Что происходит прямо сейчас в проде? py-spy, Austin, Pyinstrument 1–5 % живой процесс
Какая строка внутри функции? line_profiler 5–20× точечно
Куда уходит память? memray, tracemalloc 1,2–3× локально и в проде
Что делает C-код и ядро? perf, py-spy --native ~1 % нативные расширения

Детерминированный профайлер: cProfile

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

python -m cProfile -s tottime myscript.py | head -15
python -m cProfile -o prof.out myscript.py     # бинарный дамп для анализа
import pstats

st = pstats.Stats("prof.out")
st.strip_dirs().sort_stats("cumulative").print_stats(15)
st.print_callers("score")     # кто вызывает горячую функцию

Ключевое различие в выводе: tottime — время внутри самой функции без вложенных вызовов (по нему ищут «горячее ядро»), cumtime — вместе с вложенными (по нему ищут «дорогую ветку»). Для наглядной картинки удобны snakeviz (snakeviz prof.out) или конвертация в flamegraph.

Сэмплирующий профайлер: py-spy

py-spy не требует ни правки кода, ни перезапуска: он читает память чужого процесса и разбирает структуры интерпретатора.

py-spy top --pid 4242                       # интерактивный top по функциям
py-spy record -o flame.svg --pid 4242 -d 60 # флеймграф за минуту
py-spy record --native -o flame.svg -- python train.py  # со стеками C-расширений
py-spy dump --pid 4242                      # стеки всех потоков «здесь и сейчас»

py-spy dump — лучший первый шаг при зависании: он покажет, что процесс встал на socket.recv или на Lock.acquire, за секунду и без отладчика.

В контейнере понадобится --cap-add SYS_PTRACE (или запуск из соседнего контейнера в том же PID-namespace). Это единственный практичный способ профилировать прод, и его стоит подготовить заранее — см. деплой и наблюдаемость.

Построчно и по памяти

# pip install line_profiler; запуск: kernprof -l -v script.py
@profile                      # декоратор внедряется kernprof, импортировать не нужно
def transform(rows):
    out = []
    for r in rows:
        out.append(r["a"] * r["b"])
    return out

Память — отдельная дисциплина. sys.getsizeof показывает размер одного объекта без содержимого (getsizeof([1,2,3]) — это 88 байт списка, а не память трёх чисел), поэтому для реальных ответов нужны:

import tracemalloc

tracemalloc.start(25)                       # хранить до 25 кадров стека
snap1 = tracemalloc.take_snapshot()
run_workload()
snap2 = tracemalloc.take_snapshot()

for stat in snap2.compare_to(snap1, "lineno")[:10]:
    print(stat)      # строка, где выделено больше всего, и дельта между снимками

memray от Bloomberg делает то же самое, но захватывает и аллокации из C-расширений, и умеет живой режим:

memray run -o out.bin train.py
memray flamegraph out.bin        # интерактивный отчёт
memray run --live train.py       # наблюдать в реальном времени
memray run --trace-python-allocators --native worker.py

Scalene заслуживает отдельного упоминания: он разделяет время на «потрачено в Python» и «потрачено в нативном коде» — это ровно тот сигнал, который нужен, чтобы понять, поможет ли Cython.

Наконец, с Python 3.12 профайлеры могут использовать PEP 669 sys.monitoring — механизм с почти нулевым оверхедом на неотслеживаемых событиях, а python -X perf myscript.py включает трамплины, благодаря которым системный perf видит имена Python-функций (HOWTO).

Разбор реального случая: 3,6 с → 0,06 с

Функция считает, сколько слов из строк встречается в словаре:

import re

WORD = re.compile(r"[a-zA-Z]+")

def normalize(line: str) -> list[str]:
    return WORD.findall(line.lower())

def score(words: list[str], vocab: list[str]) -> int:
    return sum(1 for w in words if w in vocab)

def run(lines: list[str], vocab: list[str]) -> int:
    return sum(score(normalize(line), vocab) for line in lines)

На 20 000 строк по 20 слов и словаре из 2000 элементов — 3,6 секунды. Профиль:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   420000    3.465    0.000    3.465    0.000 prof_demo.py:12(<genexpr>)
    20000    0.045    0.000    0.045    0.000 {method 'findall' of 're.Pattern' objects}

Наивное чтение профиля: «генераторное выражение тормозит, заменим на цикл». Это ловушка. tottime генератора включает время выполнения его тела, а в теле — w in vocab, где vocab список. Проверка вхождения в список — это O(n) сравнений строк; при 400 000 проверок по 2000 элементов получаем порядка 400 миллионов сравнений.

Правка на один символ:

def score(words: list[str], vocab: frozenset[str]) -> int:
    return sum(1 for w in words if w in vocab)

# вызывающий код: run(lines, frozenset(vocab))

Результат: 3,626 с → 0,062 с, ускорение в 58 раз. Ни map, ни numpy, ни Cython — просто хеш-таблица вместо линейного поиска (устройство — в «Хеш-таблицах»).

Мораль двойная. Во-первых, профиль показывает где уходит время, но причину надо понимать самому. Во-вторых, ступень «алгоритм и структура данных» почти всегда даёт больше, чем всё остальное вместе взятое, и стоит дешевле.

Каталог типичных горячих точек

Строки: join вместо накопления

Классическое правило «конкатенация в цикле — это O(n²)» в CPython выполняется не всегда, и на этом легко обжечься. CPython умеет расширять строку на месте, если на неё есть ровно одна ссылка:

# всё хорошо: ~2 мс на 50 000 итераций — сработала оптимизация in-place
s = ""
for _ in range(50_000):
    s += "abcdefgh"

# катастрофа: 8,4 секунды — в 4000 раз медленнее
s = ""
keep = []
for _ in range(50_000):
    s += "abcdefgh"
    keep.append(s)          # вторая ссылка ломает оптимизацию, каждый раз копия целиком

Оптимизация невидима, недокументирована как гарантия и исчезает от безобидной строки кода (или на другой реализации Python). Поэтому правило остаётся прежним: собирайте куски в список и склеивайте через "".join(...) — это честные O(n) и предсказуемая производительность. Для потокового формирования текста — io.StringIO.

Поиск: set/dict вместо list

L = list(range(1_000_000))
S = set(L)

500_000 in L    # ~3,3 мс   — O(n)
500_000 in S    # ~0,26 мкс — O(1), в 13 000 раз быстрее

Тот же приём — dict вместо перебора списка словарей, bisect для отсортированного массива, collections.deque вместо list.insert(0, x) (O(1) против O(n)). Это самая доходная и самая дешёвая категория правок; таблица сложностей — в «Коллекциях».

Лишние объекты и лишние проходы

data = list(range(1_000_000))

sum(v * 2 for v in data)      # ~30 мс — генератор, память O(1)
sum([v * 2 for v in data])    # ~53 мс — список на миллион объектов, память O(n)

Здесь генератор быстрее не из-за «магии ленивости», а потому что не платит за аллокацию списка на миллион указателей. Обратный случай тоже бывает: если результат нужен несколько раз, материализация окупается. Мерьте — интуиция про генераторы врёт в обе стороны. Механика ленивых конвейеров разобрана в «Идиоматичном Python».

Кеширование

from functools import cache, lru_cache

@cache                              # то же, что lru_cache(maxsize=None)
def parse_rule(text: str) -> Rule:
    ...

@lru_cache(maxsize=10_000)          # ограниченный кеш — для пользовательского ввода
def geocode(city: str) -> tuple[float, float]:
    ...

Требования: аргументы хешируемые, функция чистая. Две классические ловушки: @cache на методе держит self в кеше вечно (утечка на весь процесс — используйте functools.cached_property или кеш на уровне модуля), и maxsize=None на функции с пользовательским вводом превращается в неограниченно растущий словарь.

Атрибуты, вызовы и мифы про «локальные ссылки»

Старый совет «вынесите math.sqrt в локальную переменную перед циклом» на современном CPython часто не работает:

import math

def f_naive(d):                    # 31,9 мкс на 1000 элементов
    out = []
    for v in d:
        out.append(math.sqrt(v))
    return out

def f_local(d):                    # 35,3 мкс — «оптимизация» сделала ХУЖЕ
    out = []
    ap, sq = out.append, math.sqrt
    for v in d:
        ap(sq(v))
    return out

def f_lc(d):                       # 24,8 мкс — comprehension дешевле цикла с append
    return [math.sqrt(v) for v in d]

def f_map(d):                      # 21,3 мкс — весь цикл внутри C
    return list(map(math.sqrt, d))

Специализация PEP 659 кеширует поиск глобалей и атрибутов прямо в байт-коде, поэтому ручное «кеширование» превратилось в шум. А вот map/comprehension выигрывают всерьёз: они убирают из цикла интерпретацию вызова append. Детали механики поиска имён — в «Функциях и областях видимости».

Исключения: дёшево на успехе, дорого на промахе

d = {"a": 1}

def with_try(k):
    try:
        return d[k]
    except KeyError:
        return None

with_try("a")     # ~35 нс  — сам блок try почти бесплатен, пока исключения нет
with_try("zz")    # ~156 нс — а вот возбуждение и перехват дороже вчетверо
d.get("zz")       # ~40 нс  — без исключения

Отсюда практическое правило: EAFP («проще просить прощения») хорош, когда исключение — редкость. Если промах происходит в половине случаев, dict.get или проверка in дешевле. Подробно про иерархию и цену — в «Исключениях».

Логирование, сериализация, регулярки

  • logger.debug(f"item {item!r} took {dt}") вычисляет f-строку всегда, даже когда уровень DEBUG выключен. Правильно: logger.debug("item %r took %s", item, dt) — форматирование произойдёт только при реальной записи. В по-настоящему горячем месте — if logger.isEnabledFor(logging.DEBUG):.
  • json из stdlib на порядок медленнее orjson и msgspec. Для API с крупными ответами это часто самая доходная однострочная правка.
  • re.compile вне цикла (внутренний кеш re есть, но он ограничен и стоит поиска), избегайте катастрофического бэктрекинга — вложенные квантификаторы вида (a+)+ превращают валидацию в ReDoS-уязвимость.
  • __slots__ экономит память: замер tracemalloc на 200 000 экземплярах с тремя атрибутами даёт 96 байт на объект против 64 со слотами — на треть меньше и лучше локальность. Цена и ограничения разобраны в «ООП».

Мусор, память и почему RSS не падает

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

Цена сборщика пропорциональна количеству живых контейнеров, а не мусора. Отсюда неприятный эффект: чем больше долгоживущих структур в памяти, тем дороже каждая случайная аллокация.

import gc, time

data = [(i, i) for i in range(2_000_000)]        # 2 млн живых кортежей

t = time.perf_counter()
junk = [[i] for i in range(200_000)]
print(f"gc включён: {(time.perf_counter()-t)*1000:.0f} мс")   # ~76 мс

gc.disable()
t = time.perf_counter()
junk2 = [[i] for i in range(200_000)]
print(f"gc выключен: {(time.perf_counter()-t)*1000:.0f} мс")  # ~23 мс

Три приёма из продакшена:

  1. gc.freeze() перед fork. Воркеры Gunicorn/uWSGI наследуют память мастера через copy-on-write. Но сборщик при обходе пишет в заголовки объектов, ломая CoW, и память «размножается». gc.collect(); gc.freeze() в хуке post_worker_init переносит всё загруженное в неотслеживаемое множество — Instagram описал этот путь в «Dismissing Python Garbage Collection».
  2. gc.disable() в короткоживущих батчах. Если процесс живёт минуты и не создаёт циклов, сборщик — чистый убыток. Только не делайте этого в долгоживущем сервисе: цикл с __del__ или граф объектов утечёт.
  3. gc.set_threshold(50_000, 20, 20) как компромисс: реже сборки, чуть больше пик памяти.

Про RSS важно понимать: CPython возвращает освобождённые блоки в свой аллокатор (pymalloc, арены по 1 МиБ), а ОС — только когда арена опустела целиком. Фрагментация приводит к тому, что после пика память процесса не уменьшается. Лечится не «вызовом gc.collect()», а архитектурно: потоковая обработка вместо загрузки всего в память, перезапуск воркеров по max_requests, вынос тяжёлого этапа в отдельный процесс.

Когда Python кончается: компиляция горячего ядра

Если профиль показывает плотный численный или строковый цикл, который не выражается через готовые C-примитивы, — пора компилировать. Вопрос только в том, чем.

Cython

Cython компилирует расширенный Python в C. Ключ к выигрышу — типизированные переменные и типизированные memoryview: без них вы получите тот же интерпретируемый код, только собранный.

# hot.pyx
# cython: language_level=3, boundscheck=False, wraparound=False
from libc.math cimport sqrt

def rms(double[::1] xs) -> float:
    """Среднеквадратичное по непрерывному массиву double."""
    cdef Py_ssize_t i, n = xs.shape[0]
    cdef double acc = 0.0
    with nogil:                      # GIL отпущен: другие потоки работают параллельно
        for i in range(n):
            acc += xs[i] * xs[i]
    return sqrt(acc / n)
# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(ext_modules=cythonize("hot.pyx", annotate=True))
python setup.py build_ext --inplace
cython -a hot.pyx      # hot.html: жёлтым подсвечены строки, где остался Python

Отчёт cython -a — главный рабочий инструмент: цель в том, чтобы горячий цикл стал белым. Типичный выигрыш на численных циклах — 20–100×; на коде, который в основном дёргает Python-объекты, — единицы процентов.

Numba

Numba компилирует функцию в машинный код через LLVM прямо в рантайме, без отдельного шага сборки:

import numpy as np
from numba import njit

@njit(cache=True, fastmath=True)     # cache=True — не перекомпилировать при каждом старте
def rms(xs: np.ndarray) -> float:
    acc = 0.0
    for i in range(xs.shape[0]):
        acc += xs[i] * xs[i]
    return (acc / xs.shape[0]) ** 0.5

rms(np.zeros(1))                     # прогрев: первый вызов компилирует

Numba блестяща на численных циклах над numpy-массивами и бесполезна на словарях, строках и произвольных объектах: в nopython-режиме она просто откажется компилировать. Цена — тяжёлая зависимость (LLVM) и секунды на первую компиляцию.

Rust через PyO3

Для сложной логики, а не только арифметики, современный выбор — Rust:

// src/lib.rs
use pyo3::prelude::*;

/// Считает RMS. Горячая часть выполняется без GIL.
#[pyfunction]
fn rms(py: Python<'_>, xs: Vec<f64>) -> f64 {
    py.allow_threads(|| {
        let acc: f64 = xs.iter().map(|v| v * v).sum();
        (acc / xs.len() as f64).sqrt()
    })
}

#[pymodule]
fn hotrs(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(rms, m)?)?;
    Ok(())
}
# Cargo.toml
[lib]
name = "hotrs"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
pip install maturin
maturin develop --release      # сборка и установка в текущее окружение
maturin build --release        # колесо для публикации

Важная деталь, которую пропускают новички: Vec<f64> в сигнатуре означает копирование всего массива через границу. Для больших данных берите numpy через крейт numpy и PyReadonlyArray1<f64> — тогда чтение идёт по месту. Флаг abi3 даёт одно колесо на все версии Python, что резко упрощает публикацию.

Так устроены самые заметные инструменты экосистемы: pydantic-core, ruff, uv, polars, orjson, tokenizers. Если вы уже платите за нативную сборку — платите за неё один раз и получите порядок.

mypyc, ctypes и cffi

  • mypyc компилирует аннотированный Python в C-расширение почти без правок кода. Даёт 1,5–4× на типичном коде с классами; так собраны сами mypy и black. Требует строгих аннотаций — см. «Типизацию».
  • ctypes (stdlib) и cffi — не для ускорения Python, а для вызова уже существующих C-библиотек. ctypes удобнее для разовых вызовов, cffi быстрее и безопаснее на потоке вызовов.

Главное про GIL в расширениях

Расширение, которое не отпускает GIL, не даёт параллелизма — оно просто быстрее выполняется в одиночку. Строчки with nogil: (Cython), Py_BEGIN_ALLOW_THREADS (C) и py.allow_threads(...) (PyO3) — то, ради чего numpy, zlib, hashlib и драйверы БД масштабируются по ядрам из обычных потоков. Внутри такого блока нельзя трогать Python-объекты. Подробности модели — в «Конкурентности».

Альтернативные рантаймы: PyPy и другие

PyPy — самостоятельная реализация Python с трассирующим JIT. Он наблюдает за исполнением, находит горячие циклы и генерирует машинный код со снятыми проверками типов, а при нарушении предположений сваливается обратно в интерпретатор.

Где PyPy выигрывает 3–10×:

  • длинные вычислительные циклы на чистом Python (парсеры, симуляции, интерпретаторы);
  • долгоживущие процессы, где есть время на прогрев;
  • код, который нельзя или не хочется переписывать на numpy.

Где PyPy проигрывает или не подходит:

  • Короткие скрипты. JIT не успевает окупить прогрев; CLI-утилита может стать медленнее.
  • Тяжёлые C-расширения. Слой совместимости cpyext эмулирует CPython C API и стоит дорого; код, где 90 % времени внутри numpy или pandas, обычно замедляется.
  • Память. JIT и объектная модель PyPy требуют заметно больше RSS.
  • Версия и колёса. PyPy отстаёт от CPython на минорную версию-две, часть пакетов не имеет для него колёс и собирается из исходников.

Практика: PyPy стоит попробовать за полчаса (pypy3 -m venv, прогнать бенчмарк) — если профиль показывает чистый Python-цикл, выигрыш будет виден сразу; если он показывает numpy и psycopg, дальше можно не идти. Есть также GraalPy — интересен там, где нужна интеграция с JVM.

Что делает сам CPython

Что из этого стоит знать прямо сейчас:

  • Обновление версии — самая дешёвая оптимизация. Переход с 3.10 на 3.12 обычно даёт 20–40 % «просто так», без правок кода. Это надо делать регулярно, а не «когда-нибудь».
  • Free-threading — не бесплатный обед. Сборка без GIL (python3.13t и далее) платит за отсутствие глобальной блокировки замедлением однопоточного кода; в 3.13 это были десятки процентов, к 3.14 разрыв заметно сократился. Плюс каждое C-расширение должно явно объявить совместимость, иначе GIL включится обратно.
  • JIT в CPython пока скромен. Копирующий JIT из PEP 744 даёт единицы процентов и включается флагом сборки. Это фундамент на будущее, а не решение сегодняшней проблемы.

Продакшн: то, что не видно в микробенчмарке

Время старта и импорта. Для CLI, лямбд и коротких задач доминирует не выполнение, а импорт:

python -X importtime -c "import myapp" 2>&1 | sort -k2 -n -r | head -10

Лечится ленивыми импортами внутри функций, importlib.util.LazyLoader и вынесением тяжёлых зависимостей из __init__.py. Общесистемные ленивые импорты (PEP 690) были отклонены; тема вернулась в виде предложений с явным синтаксисом, но полагаться пока стоит на ручные приёмы. Подробнее — в «Модулях и пакетировании».

Асинхронные приложения. Один блокирующий вызов останавливает весь event loop и портит p99 всем запросам сразу. Диагностика встроена:

loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.05    # предупреждать о колбэках дольше 50 мс

Плюс uvloop (замена цикла на libuv, обычно 2–4× на сетевом слое) и вынос синхронного кода в asyncio.to_thread / пул процессов. Про веб-слой — в «Веб и API».

Настройка воркеров. Классическая ошибка — крутить workers в надежде на скорость при упоре в базу данных. Ориентир: для CPU-bound число воркеров близко к числу ядер, для I/O-bound — больше, но потолок задают пул соединений к БД и память. --preload в Gunicorn экономит память через CoW (в паре с gc.freeze()).

Регрессии. Оптимизация без защиты откатывается следующим же PR. Минимум — pytest-benchmark на ключевых функциях с порогом в CI; максимум — нагрузочный тест на стенде перед релизом (см. «Тестирование» и «Нагрузочное тестирование»).

Грабли, на которых спотыкаются все

  1. Оптимизация без профиля. Классика: три дня ускоряли парсер, который занимал 4 % времени, при 80 % в неиндексированном SQL-запросе.
  2. Чтение tottime как приговора. Дорогая функция бывает лишь местом, где проявляется чужая ошибка (пример со списком вместо множества выше).
  3. cProfile в проде. Двукратный оверхед и искажение соотношений; для живой системы существует py-spy.
  4. Микробенчмарк на синтетике. range(1000) предсказуем, реальные данные — нет; на ветвлениях разница достигает разов.
  5. sys.getsizeof как измеритель памяти. Он не считает содержимое контейнера и не учитывает разделяемые объекты. Нужны tracemalloc или memray.
  6. Вера в устаревшие советы. «Кешируйте функции в локальные переменные», «map всегда быстрее», «try дорогой» — на 3.11+ это чаще шум, чем выигрыш, а иногда и регресс.
  7. Конкатенация строк, у которой «внезапно» появилась вторая ссылка. Разница между 2 мс и 8 секундами — в одной строке keep.append(s).
  8. @cache на методе. Кеш живёт на уровне функции класса и держит self — процесс растёт, пока не придёт OOM-killer.
  9. multiprocessing для мелких задач. Стоимость сериализации аргументов через pickle и запуск процессов легко превышают полезную работу; см. «Конкурентность».
  10. C-расширение, не отпускающее GIL. Ускорили в 50 раз, а по ядрам не масштабируется.
  11. gc.disable() в долгоживущем сервисе. Циклические структуры перестают освобождаться, утечка становится вопросом времени.
  12. Преждевременный переход на PyPy или Cython. Оба добавляют целый класс проблем сборки и совместимости; если верхние ступени лестницы не пройдены — это трата.
  13. Оптимизация среднего вместо хвоста. Пользователь чувствует p99, а средняя латентность может даже улучшиться, пока хвост горит.

Честно о месте Python

Где Python выигрывает. Там, где узкое место — не он. Веб-API, где время уходит в БД и сеть; аналитика и ML, где вся арифметика внутри numpy, torch и polars; склейка систем; скрипты и автоматизация. Здесь скорость разработки и качество библиотек перевешивают цену интерпретации, а нужные горячие точки уже написаны на C, Rust и Fortran за вас. Плюс сама экосистема позволяет спускаться на уровень ниже точечно — это редкое и недооценённое свойство.

Где Python проигрывает. Плотные вычисления над скалярами в чистом Python — 30–100× отставания. Многопоточная загрузка всех ядер CPU-bound кодом (GIL; free-threading пока не универсальный ответ). Приложения, чувствительные к хвостовой латентности в единицы миллисекунд. Память: объект на каждое число — это 28–32 байта вместо 8, и на десятках миллионов элементов разница решает. Холодный старт: интерпретатор плюс импорты — это десятки и сотни миллисекунд.

Куда не стоит тащить. Системы жёсткого реального времени и встраиваемые устройства с десятками килобайт памяти (там MicroPython — другой инструмент с другими ограничениями). Высокочастотный трейдинг и сетевые прокси на миллионы соединений. Игровые движки и рендеринг. Самодостаточные бинарники без рантайма на целевой машине — PyInstaller и Nuitka работают, но это обходной путь. Для таких задач честнее сразу взять Go, Rust, C++ или C — треки по Go и системному программированию на портале есть.

Разумная стратегия для большинства команд: Python как язык системы, нативный код как точечное лекарство. Не «переписать сервис», а «вынести один горячий модуль».

Чек-лист

  • Сформулирована цель в числах: метрика, значение, нагрузка, железо.
  • Есть воспроизводимый бенчмарк на реальных данных до начала правок.
  • Профиль снят: cProfile локально, py-spy на живом процессе, memray по памяти.
  • Проверены ступени сверху вниз: алгоритм → структуры данных → идиомы → кеш → вынос цикла в C → компиляция → параллелизм → рантайм.
  • Каждая правка перемерена тем же бенчмарком, выигрыш зафиксирован в PR числами.
  • Ключевой бенчмарк добавлен в CI с порогом, чтобы регрессия не прошла молча.
  • Версия Python — актуальная поддерживаемая; обновление проверено на бенчмарке.
  • В проде подготовлены средства диагностики: SYS_PTRACE для py-spy, метрики RSS и латентности по перцентилям.

Источники

Что дальше

Работа с данными: numpy, pandas, форматы, типичные ошибки производительности — разберём, как устроены массивы и векторизация, почему iterrows() убивает pandas, чем polars и Arrow отличаются от привычного стека и как выбирать форматы хранения так, чтобы чтение не стоило дороже вычислений.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов