Производительность

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

L1 · 9 минL2 · 10 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
import timeit
N = 1_000_000
variants = {
    "цикл с append": f"r = []\nfor i in range({N}): r.append(str(i))",
    "списковое":     f"r = [str(i) for i in range({N})]",
    "map":           f"r = list(map(str, range({N})))",
}
for name, stmt in variants.items():
    print(f"{name:14} {min(timeit.repeat(stmt, number=1, repeat=7)):.3f} c")
цикл с append  0.082 c
списковое      0.077 c
map            0.086 c
Разница в пределах шума. Классический совет «замени цикл на включение» больше ничего не даёт.
Фрагмент B
import math
vals = list(range(300_000))

#   [math.sqrt(v) for v in vals]
#   s = math.sqrt; [s(v) for v in vals]
через модуль:      0.346 c
локальная ссылка:  0.297 c
Старый приём с хойстингом ещё работает — но даёт 14%, а не разы.
Фрагмент C
import timeit
setup = '''
import random
random.seed(0)
data = [random.randrange(3000) for _ in range(9000)]
'''
by_list = '''
result = []
for x in data:
    if x not in result:
        result.append(x)
'''
by_set = '''
seen, result = set(), []
for x in data:
    if x not in seen:
        seen.add(x)
        result.append(x)
'''
print(f"по списку:     {min(timeit.repeat(by_list, setup, number=10, repeat=5)):.3f} c")
print(f"по множеству:  {min(timeit.repeat(by_set,  setup, number=10, repeat=5)):.4f} c")
по списку:     0.568 c
по множеству:  0.0040 c
154 раза. Тот же код, другая структура данных.
Фрагмент D
import timeit
setup = '''
class A:
    def __init__(self): self.x = 1
class B:
    def __init__(self): self.x = 1
class C:
    def __init__(self): self.x = 1

mono = [A() for _ in range(30_000)]      # один тип на площадке доступа
poly = [A(), B(), C()] * 10_000          # три типа вперемешку

def total(objs):
    t = 0
    for o in objs:
        t += o.x
    return t

total(mono); total(poly)                 # прогрев: инлайн-кеш успевает специализироваться
'''
print(f"один класс:   {min(timeit.repeat('total(mono)', setup, number=100, repeat=7)):.3f} c")
print(f"три класса:   {min(timeit.repeat('total(poly)', setup, number=100, repeat=7)):.3f} c")
один класс:   0.063 c
три класса:   0.089 c
Идентичный код, разница в 1.4 раза.

2Механизм

Все механизмы уже разобраны в предыдущих уроках. Здесь — порядок, в котором их применять, и почему он именно такой.

Стоимость операции в Python определяется не строкой кода, а тем, сколько объектов она создаёт и сколько раз выходит в интерпретатор.

Порядок рычагов фиксирован и идёт по убыванию отдачи: алгоритм → структура данных → выход из объектной модели → выход из интерпретатора → переписывание.

Следствие 1 · класс сложности бьёт всё остальное

Фрагмент C. Замена списка на множество в проверке вхождения даёт 154 раза — больше, чем любая микрооптимизация, любой JIT и любое переписывание на C вместе взятые. Причина в уроке 03: список не знает о своих элементах ничего, кроме адресов, поэтому in может быть только перебором.

Отсюда первое правило: прежде чем оптимизировать код, проверь, нет ли в нём цикла внутри цикла. Признак — in, index, remove по списку внутри обхода, или вложенный проход по той же коллекции.

Следствие 2 · объектная модель — второй по величине рычаг

Из урока 03: миллион целых в списке — это 36 МБ и миллион объектов, в numpy — 8 МБ одним куском. Из урока 04: __slots__ убирает треть памяти на объект. Из урока 13: генератор вместо списка — постоянная память вместо линейной.

Всё это один и тот же рычаг: перестать создавать объекты там, где нужны данные. Он работает на порядки, потому что убирает не операции, а сущности.

Следствие 3 · большинство микрооптимизаций устарели

Фрагмент A — важный отрицательный результат. Совет «списковое включение быстрее цикла с append» когда-то давал заметный выигрыш; сейчас разница семь процентов, то есть шум. Причина в уроке 04: с 3.11 интерпретатор специализирует инструкции по факту исполнения, и разрыв между формами записи сократился.

Фрагмент B показывает, что не всё умерло: вынос math.sqrt в локальную переменную даёт 14% — поиск атрибута в модуле всё ещё дороже чтения локальной. Но это 14%, а не разы, и применять это стоит только в доказанно горячем месте.

Практический вывод: любой совет по производительности старше 3.11 подлежит перепроверке замером. Половина из них описывает интерпретатор, которого больше нет.

Следствие 4 · однородность данных влияет на скорость

Фрагмент D — то, чего не найти в советах. Один и тот же цикл по объектам одного класса и по смеси трёх различается в 1.4 раза, потому что инлайн-кеш хранит одну версию типа и промахивается на разнородной коллекции (урок 04).

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

Следствие 5 · порядок рычагов
рычагтипичная отдачаоткуда
алгоритм и структура данныхдесятки и сотни разурок 03, 05
выход из объектной модели: numpy, array, генераторы, __slots__единицы и десятки разуроки 03, 04, 13
выход из интерпретатора: потоки на C-коде, asyncio на ожиданияхдо числа ядер или на порядокуроки 15, 19, 20
однородность данных, кэшированиеединицы разуроки 04, 08
микрооптимизации записипроцентыэтот урок
переписывание в C или Rustразы, но с большой ценойурок 16

Каждая строка дешевле следующей на порядок и часто закрывает вопрос. Идти снизу вверх — самая частая ошибка в оптимизации Python.

3Границы модели

Где сказанное перестаёт держать

Профилировщик искажает то, что мерит. cProfile добавляет накладные расходы на каждый вызов, поэтому завышает долю функций, вызываемых часто и коротко. Для относительного сравнения годится, для абсолютных чисел — нет; выборочные профилировщики вроде py-spy честнее.

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

Числа привязаны к версии 🔧. 3.11 сократила разрыв между формами записи, 3.12 и 3.13 продолжили, JIT в 3.13+ поменяет ещё. Всё, что здесь измерено, стоит перемерять на своей версии.

4Корень

Корень R2: атрибут резолвится в рантайме. Двадцать лет это стоило дорого — каждое обращение к атрибуту и каждый вызов проходили полный путь поиска. Именно отсюда репутация «Python медленный», и она была заслуженной.

Оплатили это в 3.11 проектом Faster CPython: специализирующий адаптивный интерпретатор (PEP 659) научился запоминать результат поиска прямо в потоке байткода, а фреймы стали дешевле. Механизм языка не изменился — изменилось то, что повторное исполнение не выполняет его целиком.

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

5Аналогия

Порядок рычагов — это иерархия оптимизации из C, сдвинутая на уровень вверх. Там сначала алгоритм, потом раскладка данных и кэш, потом векторизация, и только в конце ассемблер. Здесь ровно та же лестница, но первые ступени другие: сначала алгоритм, потом уход из объектной модели (аналог «раскладки»), потом уход из интерпретатора (аналог «векторизации»), и только в конце переписывание на C.

Полезно и то, что верхняя ступень одинакова в обоих языках: измерить, прежде чем трогать. Разница в том, что в C профиль показывает, где горячий код, а в Python он ещё и показывает, сколько объектов создаётся — и эта вторая цифра обычно важнее первой.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему половина советов по производительности перестала работать?

Потому что они описывали интерпретатор, которого больше нет.

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

С 3.11 работает специализация (PEP 659): инструкция после прогрева переписывает себя под наблюдаемый тип и хранит наблюдение в инлайн-кеше прямо в потоке байткода (урок 23). Повторный поиск заменился одним сравнением версии типа — и разрыв между формами записи схлопнулся.

цикл с append  0.082 c
списковое      0.077 c        ← 7%, то есть шум
map            0.086 c

Что при этом не устарело: всё, что убирает не операции, а сущности. Класс сложности (154× на замене списка множеством), выход из объектной модели (numpy, array, генераторы, __slots__), выход из интерпретатора (потоки на C-коде). Эти рычаги работали двадцать лет назад и работают сейчас, потому что они про количество созданных объектов, а не про форму записи.

Практическое правило: у совета по производительности есть срок годности, и он определяется датой публикации. Всё, что старше 3.11, подлежит перепроверке замером — включая числа из этого канона, если вы читаете его на другой версии.

Профилировщик показал функцию — что дальше?

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

Прикидка из урока 23: инструкция байткода — около 6 нс, вызов функции — 27 нс. Значит функция из десяти строк простого кода это примерно сотня инструкций, то есть 600 нс плюс вызов. Если профиль показывает десять микросекунд — время уходит не на интерпретацию, и микрооптимизации там не помогут.

Дальше три разных вопроса и три разных инструмента:

  • Сколько раз её зовут. cProfile покажет ncalls. Часто выясняется, что функция дешёвая, но вызвана миллион раз из цикла в цикле — и чинить надо не её, а алгоритм.
  • Сколько объектов она создаёт. tracemalloc до и после. Аллокация — второй по величине источник времени после алгоритма, и в профиле по времени она размазана и невидима.
  • Кто её зовёт. print_callers в pstats. Ответ «её зовут оттуда, откуда не должны» встречается чаще, чем «она медленная».
python -m cProfile -s tottime script.py | head -20
# tottime — своё время без вложенных вызовов; сортировать надо по нему,
# иначе наверху всегда будет main

И на живом процессе — py-spy top --pid: без перезапуска, почти без накладных расходов, читает фреймы прямо из памяти чужого процесса (урок 23).

Почему однородность данных влияет на скорость — этого нет ни в одном совете?

Потому что эффект появился только в 3.11 и вытекает не из кода, а из механизма инлайн-кешей.

Каждая площадка обращения к атрибуту хранит в кеше версию типа, который там видели. Пока приходит один и тот же тип — одно сравнение и прямой доступ. Приходят разные — кеш промахивается, инструкция деоптимизируется обратно в общую.

один класс:   0.063 c
три класса:   0.089 c        ← 1.4× при полностью идентичном коде

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

Где это встречается в реальности. Список узлов дерева разных классов. Коллекция событий, где каждый тип события — свой класс. ORM-объекты, подгруженные из разных таблиц в один список. Полиморфизм, за который принято хвалить, здесь оплачивается временем.

И оборотная сторона: увидеть это можно только дизассемблером, и никакого API «сработала ли специализация» нет:

import dis
dis.dis(hot_function, adaptive=True)
# LOAD_ATTR_INSTANCE_VALUE — специализация удержалась
# LOAD_ATTR или LOAD_ATTR_ADAPTIVE — нет

Эффект целиком 🔧: на 3.10 его не было, на 3.13 с JIT картина снова другая.

Итог. Стоимость определяется не формой записи, а числом созданных объектов и числом выходов в интерпретатор. Порядок рычагов фиксирован и идёт по убыванию отдачи: алгоритм и структура данных дают сотни раз (проверка вхождения по множеству вместо списка — 154×), выход из объектной модели даёт единицы и десятки (numpy, array, генераторы, __slots__), выход из интерпретатора — до числа ядер, и только в конце микрооптимизации, дающие проценты. Половина известных советов устарела: списковое включение против цикла с append — шесть процентов, то есть шум, потому что с 3.11 интерпретатор специализирует инструкции по факту исполнения. Хойстинг ещё работает, но даёт 14%, а не разы. И есть эффект, которого нет в советах: однородная коллекция обходится в 1.4 раза быстрее смеси классов, потому что инлайн-кеш хранит одну версию типа. Любой совет старше 3.11 подлежит перепроверке замером.
Дальше — по желанию
контрфактуалC, C++ и JIT-языкиГде узкое место в компилируемом языке — и почему в Python оно в другом месте

C и C++ оптимизируют кэш, ветвления и векторизацию: работа уже скомпилирована, узкое место — доступ к памяти. В Python на этом уровне бороться не с чем, пока данные лежат объектами: любая операция дороже кэш-промаха на порядки. Поэтому первый рычаг здесь — не раскладка, а уход от объектов: numpy и есть способ вернуться в мир, где раскладка снова имеет значение.

Показательно, что после перехода на numpy советы из C начинают работать буквально: непрерывность, тип элемента, размер батча, векторизация — та же оптимизация, что и в C, потому что данные наконец лежат так же.

Java и JavaScript с их JIT решают ровно ту задачу, которую CPython начал решать в 3.11: специализировать динамический код по факту исполнения. V8 пришёл к инлайн-кешам в 2008-м, CPython — в 2022-м. Разрыв объясняет и отставание в скорости, и то, почему оно сокращается.

🐛 багОптимизировали не тоНеделя на переписывание функции, которая занимала четыре процента времени

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

# «Этот парсер точно медленный, он же в цикле»
def parse_line(line):          # переписали, ускорили в три раза
    ...

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

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

Правильный порядок начинается не с кода, а с ответа на вопрос «где время»:

# на живом процессе, без перезапуска и почти без накладных расходов
py-spy top --pid 12345
py-spy dump --pid 12345          # где стоят потоки прямо сейчас

# локально, для сравнения вариантов
python -m cProfile -s tottime script.py | head -20

Читать надо tottime, а не cumtime: первое — время в самой функции, второе включает вызовы. Кандидат на оптимизацию — тот, у кого велико tottime, а не тот, кто выглядит сложным.

И проверить, что упирается вообще в процессор: если py-spy показывает потоки в read или в ожидании драйвера, оптимизировать Python бессмысленно — задача из урока 20, а не из этого.

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

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

Порядок важнее знания приёмов: почти всегда вопрос закрывается на первом-втором шаге.

Шаг 1 — где время. py-spy на проде или cProfile локально. Ответ «в ожидании» переводит задачу в урок 20 и заканчивает эту.

Шаг 2 — алгоритм и структуры. Искать in по списку внутри цикла, вложенные проходы, повторный пересчёт одного и того же. Отдача — сотни раз.

seen = set()               # вместо if x not in result
result = [x for x in items if not (x in seen or seen.add(x))]

Шаг 3 — уйти от объектов. Однородные числа в array или numpy; промежуточные списки — в генераторы; массовые мелкие объекты — на __slots__. Отдача — единицы и десятки раз, плюс память.

Шаг 4 — уйти из интерпретатора. Замер из урока 19: последовательно против двух потоков. Ускорилось — масштабировать потоками; нет — смотреть процессы с учётом объёма передачи.

Шаг 5 — только теперь микро- и переписывание. Кэширование через lru_cache (урок 08), хойстинг в доказанно горячем цикле, однородность данных из следствия 4. И расширение на C или Rust — с полным счётом цены из урока 16.

шагчто даёт
структура данных140× на дедупликации
numpy вместо списка9× по памяти, порядки по времени
потоки на C-коде1.9× на двух ядрах
однородная коллекция1.4×
хойстинг14%
включение вместо цикла6%, то есть шум

Числа — из замеров этого канона на 3.11. Обрати внимание на разброс: между верхом и низом таблицы четыре порядка, и именно поэтому порядок шагов важнее их содержания.

💻 терминалПроверь в терминалеЧетыре фрагмента плюс dis с прогревом, py-spy и tracemalloc

Прогони фрагменты, сверяясь с предсказанием. Затем:

import dis

class P:
    def __init__(self): self.x = 1

p = P()
def f(o): return o.x

dis.dis(f, adaptive=True)
for _ in range(200): f(p)
dis.dis(f, adaptive=True)
# Что изменилось в инструкциях после прогрева?

import tracemalloc
tracemalloc.start()
data = [{"i": i} for i in range(100_000)]
print(tracemalloc.get_traced_memory())
# Сколько памяти стоит горячий путь? Это второй по важности вопрос после «где время».

import timeit
print(timeit.timeit("sum(range(1000))", number=10_000))
print(timeit.timeit("total=0\nfor i in range(1000): total+=i", number=10_000))
# Во сколько раз? Почему встроенная функция выигрывает?

И одна проверка из терминала — сколько времени старта уходит на импорты (урок 17):

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

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

исходникиcProfile и disИнструменты, которые уже стоят и которыми почти не пользуются

Lib/cProfile.py и Lib/pstats.py. Стоит один раз прочитать pstats, чтобы перестать смотреть на профиль как на стену текста: там всего несколько методов сортировки и фильтрации, и print_callers с print_callees отвечают на вопрос «кто это вызывает» лучше, чем чтение кода.

Lib/dis.py. Параметр adaptive=True показывает специализированные инструкции — единственный способ увидеть, применилась ли оптимизация из урока 04 к твоему коду. Полезно на горячем цикле: если инструкции остались общими, значит специализация не удалась, и стоит понять почему.

Lib/timeit.py. Заметь, что он по умолчанию отключает сборщик мусора и берёт минимум из прогонов, а не среднее. Оба решения объяснены в исходнике и объясняют, почему timeit даёт цифры лучше самодельного замера через time.perf_counter.

L2Что именно стоит дорогоЦена вызова, аллокации и специализации в наносекундах

Из L1 ты знаешь порядок рычагов. Здесь — из чего складывается стоимость.

Ориентиры на 3.11, порядок величин, а не точные числа:

операцияпорядок
арифметика над мелкими intдесятки наносекунд
доступ к атрибуту после специализацииединицы наносекунд
вызов питоновской функциидесятки наносекунд
создание объектасотни наносекунд, плюс работа сборщику
поиск в словаре по строкедесятки наносекунд, хеш кэширован

Главный вывод из таблицы: создание объекта дороже любой отдельной операции над уже существующими. Поэтому первый вопрос при оптимизации горячего цикла — не «какие операции сделать быстрее», а «какие объекты не создавать»: промежуточные списки, временные строки, обёртки.

Про специализацию. Инструкция специализируется после нескольких исполнений и обесценивается при изменении класса. Посмотреть можно так:

import dis
def f(o): return o.x
class A:
    def __init__(self): self.x = 1
a = A()
for _ in range(100): f(a)
dis.dis(f, adaptive=True)
# → LOAD_ATTR_INSTANCE_VALUE вместо общего LOAD_ATTR

Если после прогрева инструкция осталась общей, специализация не удалась — типичные причины перечислены в Python/specialize.c: слишком много разных типов в одном месте, динамические изменения класса, необычные дескрипторы. Это и есть механизм следствия 4.

L3Где это в исходниках CPythonspecialize.c, ceval.c, PEP 659

Тег v3.11.15.

  • Python/specialize.c — как принимается решение специализировать инструкцию и по каким причинам не получается. Список причин отказа сам по себе полезен как чеклист.
  • Python/ceval.c — цикл интерпретатора: видно, что специализированные инструкции это отдельные ветви.
  • Objects/frameobject.c — фреймы, ставшие в 3.11 дешевле; отсюда падение стоимости вызова.
  • PEP 659 — специализирующий адаптивный интерпретатор; PEP 744 — JIT, следующий шаг.
связиКуда это ведётL03, L04, L15, L20 · контраст с оптимизацией в C
корень R2 · Атрибут резолвится в рантайме
← основа L03 · Контейнеры — откуда берутся 154× и 9× по памяти
← основа L04 · obj.x насквозь — инлайн-кеши и эффект однородности
← основа L20 · Конкурентность — ветка «упёрлись не в процессор»
↔ контраст Оптимизация в C: та же лестница, но первые ступени другие
↔ контраст JIT в V8 и JVM: та же идея специализации, на четырнадцать лет раньше
чего здесь нет Оптимизация запросов к БД и сети — чаще всего именно там настоящее узкое место, но это не про язык
источникиЧто почитатьPEP 659 🟧 · py-spy 🟥 · pstats 🟦