Производительность: профилирование, оптимизации, 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 % это чистый убыток.
зафиксировать вход и окружение"] C --> B B -- да --> D{"Где уходит время?"} D -- "CPU в Python-коде" --> E["cProfile локально,
py-spy или Scalene в проде"] D -- "ожидание I/O или блокировок" --> F["трассировка запросов, py-spy dump,
логи БД и внешних вызовов"] D -- "память, своп, OOM" --> G["memray, tracemalloc,
RSS по времени"] E --> H{"Проблема алгоритмическая?"} F --> I["Батчинг, пул соединений, кеш,
асинхронность"] G --> J["Меньше объектов, потоковая обработка,
компактные типы"] H -- да --> K["Сменить структуру данных
или алгоритм"] H -- нет --> L["Убрать работу из горячего цикла,
вынести цикл в C"] K --> M["Перемерить тем же бенчмарком"] L --> M I --> M J --> M M --> N{"Цель достигнута?"} N -- нет --> D N -- да --> O["Зафиксировать бенчмарк в CI
как защиту от регрессий"]
Модель стоимости 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. То есть на каждый элемент приходится две аллокации, две операции с
счётчиком ссылок и несколько проверок типа — при том, что «полезной работы» здесь на
один такт процессора.
Начиная с 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
Правила честного микробенчмарка:
- Данные реальные.
range(1000)сортируется совсем не так, как реальный лог; для ветвлений предсказатель переходов на синтетике врёт особенно сильно. - Прогрев отдельно от замера. Первый вызов включает импорт, компиляцию регулярок, заполнение кешей. Для PyPy и Numba прогрев обязателен и занимает тысячи итераций.
- Не оптимизируйте то, что не попало в профиль. Микробенчмарк отвечает на вопрос «какой из двух вариантов быстрее», а не «стоит ли это трогать».
- Результат должен использоваться. Если функция возвращает значение, которое никто не читает, можно случайно измерить не то (в Python это менее опасно, чем в C, но ленивые генераторы «не выполняются», пока их не потребить).
- Сравнивайте на одном железе и одной версии. Замер на ноутбуке с включённым энергосбережением не переносится на прод.
Подробнее о методологии, доверительных интервалах и подводных камнях бенчмаркинга — в «Бенчмаркинге».
Профилирование: четыре разных вопроса
Профайлеры отвечают на разные вопросы, и путать их дорого.
| Вопрос | Инструмент | Накладные расходы | Где применять |
|---|---|---|---|
| Какая функция ест 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, за секунду и без отладчика.
и ничего не знает о наблюдателе loop 100 раз в секунду Spy->>OS: снять новый снимок стеков Spy->>Spy: агрегировать в дерево вызовов end Spy-->>Spy: flame.svg или профиль в формате speedscope
В контейнере понадобится --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 мс
Три приёма из продакшена:
gc.freeze()передfork. Воркеры Gunicorn/uWSGI наследуют память мастера через copy-on-write. Но сборщик при обходе пишет в заголовки объектов, ломая CoW, и память «размножается».gc.collect(); gc.freeze()в хукеpost_worker_initпереносит всё загруженное в неотслеживаемое множество — Instagram описал этот путь в «Dismissing Python Garbage Collection».gc.disable()в короткоживущих батчах. Если процесс живёт минуты и не создаёт циклов, сборщик — чистый убыток. Только не делайте этого в долгоживущем сервисе: цикл с__del__или граф объектов утечёт.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; максимум — нагрузочный тест на стенде перед релизом (см. «Тестирование» и «Нагрузочное тестирование»).
Грабли, на которых спотыкаются все
- Оптимизация без профиля. Классика: три дня ускоряли парсер, который занимал 4 % времени, при 80 % в неиндексированном SQL-запросе.
- Чтение
tottimeкак приговора. Дорогая функция бывает лишь местом, где проявляется чужая ошибка (пример со списком вместо множества выше). cProfileв проде. Двукратный оверхед и искажение соотношений; для живой системы существуетpy-spy.- Микробенчмарк на синтетике.
range(1000)предсказуем, реальные данные — нет; на ветвлениях разница достигает разов. sys.getsizeofкак измеритель памяти. Он не считает содержимое контейнера и не учитывает разделяемые объекты. Нужныtracemallocилиmemray.- Вера в устаревшие советы. «Кешируйте функции в локальные переменные», «
mapвсегда быстрее», «tryдорогой» — на 3.11+ это чаще шум, чем выигрыш, а иногда и регресс. - Конкатенация строк, у которой «внезапно» появилась вторая ссылка. Разница между
2 мс и 8 секундами — в одной строке
keep.append(s). @cacheна методе. Кеш живёт на уровне функции класса и держитself— процесс растёт, пока не придёт OOM-killer.multiprocessingдля мелких задач. Стоимость сериализации аргументов черезpickleи запуск процессов легко превышают полезную работу; см. «Конкурентность».- C-расширение, не отпускающее GIL. Ускорили в 50 раз, а по ядрам не масштабируется.
gc.disable()в долгоживущем сервисе. Циклические структуры перестают освобождаться, утечка становится вопросом времени.- Преждевременный переход на PyPy или Cython. Оба добавляют целый класс проблем сборки и совместимости; если верхние ступени лестницы не пройдены — это трата.
- Оптимизация среднего вместо хвоста. Пользователь чувствует 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 и латентности по перцентилям.
Источники
- The Python Profilers, timeit, tracemalloc, gc — официальная документация.
- Python Profiling with perf — HOWTO по нативному профилированию с 3.12.
- PEP 659 — Specializing Adaptive Interpreter, PEP 669 — Low Impact Monitoring, PEP 683 — Immortal Objects, PEP 703 — Making the GIL Optional, PEP 744 — JIT Compilation.
- Micha Gorelick, Ian Ozsvald, High Performance Python, 2nd ed. — единственная книга целиком по этой теме; профилирование, numpy, Cython, кластеры.
- Luciano Ramalho, Fluent Python, 2nd ed. — объектная модель и стоимость идиом.
- py-spy, memray, Scalene, line_profiler, Pyinstrument, pyperf.
- Cython documentation, Numba, PyO3 User Guide, maturin, mypyc.
- PyPy documentation и pypy.org/performance — честный разбор, где JIT помогает, а где нет.
- Faster CPython — ideas и отчёты и бенчмарки CPython.
- Dismissing Python Garbage Collection at Instagram —
разбор copy-on-write и
gc.freeze()на реальной нагрузке. - PythonSpeed/PerformanceTips — старый, но всё ещё полезный список приёмов (с поправкой на возраст советов).
Что дальше
Работа с данными: numpy, pandas, форматы, типичные ошибки производительности —
разберём, как устроены массивы и векторизация, почему iterrows() убивает pandas, чем
polars и Arrow отличаются от привычного стека и как выбирать форматы хранения так,
чтобы чтение не стоило дороже вычислений.