Где время теряется на самом деле, почему половина известных микрооптимизаций перестала работать после 3.11, и в каком порядке дёргать рычаги, чтобы не переписывать то, что и так быстро.
Зафиксируй вывод и причину до того, как откроешь ответ.
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Разница в пределах шума. Классический совет «замени цикл на включение» больше ничего не даёт.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%, а не разы.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 c154 раза. Тот же код, другая структура данных.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 раза.Все механизмы уже разобраны в предыдущих уроках. Здесь — порядок, в котором их применять, и почему он именно такой.
Стоимость операции в Python определяется не строкой кода, а тем, сколько объектов она создаёт и сколько раз выходит в интерпретатор.
Порядок рычагов фиксирован и идёт по убыванию отдачи: алгоритм → структура данных → выход из объектной модели → выход из интерпретатора → переписывание.
Фрагмент C. Замена списка на множество в проверке вхождения даёт 154 раза — больше, чем любая микрооптимизация, любой JIT и любое переписывание на C вместе взятые. Причина в уроке 03: список не знает о своих элементах ничего, кроме адресов, поэтому in может быть только перебором.
Отсюда первое правило: прежде чем оптимизировать код, проверь, нет ли в нём цикла внутри цикла. Признак — in, index, remove по списку внутри обхода, или вложенный проход по той же коллекции.
Из урока 03: миллион целых в списке — это 36 МБ и миллион объектов, в numpy — 8 МБ одним куском. Из урока 04: __slots__ убирает треть памяти на объект. Из урока 13: генератор вместо списка — постоянная память вместо линейной.
Всё это один и тот же рычаг: перестать создавать объекты там, где нужны данные. Он работает на порядки, потому что убирает не операции, а сущности.
Фрагмент A — важный отрицательный результат. Совет «списковое включение быстрее цикла с append» когда-то давал заметный выигрыш; сейчас разница семь процентов, то есть шум. Причина в уроке 04: с 3.11 интерпретатор специализирует инструкции по факту исполнения, и разрыв между формами записи сократился.
Фрагмент B показывает, что не всё умерло: вынос math.sqrt в локальную переменную даёт 14% — поиск атрибута в модуле всё ещё дороже чтения локальной. Но это 14%, а не разы, и применять это стоит только в доказанно горячем месте.
Практический вывод: любой совет по производительности старше 3.11 подлежит перепроверке замером. Половина из них описывает интерпретатор, которого больше нет.
Фрагмент D — то, чего не найти в советах. Один и тот же цикл по объектам одного класса и по смеси трёх различается в 1.4 раза, потому что инлайн-кеш хранит одну версию типа и промахивается на разнородной коллекции (урок 04).
Отсюда неочевидный приём: разложить смешанную коллекцию по однородным и обработать по очереди бывает быстрее одного прохода по смеси. Профилировщик этого не подскажет — он покажет время в цикле, а причину видно только из механизма.
| рычаг | типичная отдача | откуда |
|---|---|---|
| алгоритм и структура данных | десятки и сотни раз | урок 03, 05 |
выход из объектной модели: numpy, array, генераторы, __slots__ | единицы и десятки раз | уроки 03, 04, 13 |
| выход из интерпретатора: потоки на C-коде, asyncio на ожиданиях | до числа ядер или на порядок | уроки 15, 19, 20 |
| однородность данных, кэширование | единицы раз | уроки 04, 08 |
| микрооптимизации записи | проценты | этот урок |
| переписывание в C или Rust | разы, но с большой ценой | урок 16 |
Каждая строка дешевле следующей на порядок и часто закрывает вопрос. Идти снизу вверх — самая частая ошибка в оптимизации Python.
Профилировщик искажает то, что мерит. cProfile добавляет накладные расходы на каждый вызов, поэтому завышает долю функций, вызываемых часто и коротко. Для относительного сравнения годится, для абсолютных чисел — нет; выборочные профилировщики вроде py-spy честнее.
Микробенчмарк почти всегда врёт. Кэш процессора, специализация интерпретатора, отсутствие реальных данных — всё это делает изолированный замер непохожим на прод. Ориентир: timeit для сравнения двух форм записи, реальная нагрузка для решений.
Числа привязаны к версии 🔧. 3.11 сократила разрыв между формами записи, 3.12 и 3.13 продолжили, JIT в 3.13+ поменяет ещё. Всё, что здесь измерено, стоит перемерять на своей версии.
Корень R2: атрибут резолвится в рантайме. Двадцать лет это стоило дорого — каждое обращение к атрибуту и каждый вызов проходили полный путь поиска. Именно отсюда репутация «Python медленный», и она была заслуженной.
Оплатили это в 3.11 проектом Faster CPython: специализирующий адаптивный интерпретатор (PEP 659) научился запоминать результат поиска прямо в потоке байткода, а фреймы стали дешевле. Механизм языка не изменился — изменилось то, что повторное исполнение не выполняет его целиком.
Практическое следствие для этого урока: значительная часть накопленного фольклора описывает интерпретатор до 3.11. Советы про хойстинг, про включения вместо циклов, про локальные переменные — всё это мерилось на машине, которой больше нет, и подлежит перепроверке.
Порядок рычагов — это иерархия оптимизации из C, сдвинутая на уровень вверх. Там сначала алгоритм, потом раскладка данных и кэш, потом векторизация, и только в конце ассемблер. Здесь ровно та же лестница, но первые ступени другие: сначала алгоритм, потом уход из объектной модели (аналог «раскладки»), потом уход из интерпретатора (аналог «векторизации»), и только в конце переписывание на C.
Полезно и то, что верхняя ступень одинакова в обоих языках: измерить, прежде чем трогать. Разница в том, что в C профиль показывает, где горячий код, а в Python он ещё и показывает, сколько объектов создаётся — и эта вторая цифра обычно важнее первой.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что они описывали интерпретатор, которого больше нет.
Советы вроде «списковое включение быстрее цикла», «вынеси точку из цикла», «локальные быстрее глобальных» родились в эпоху, когда каждая инструкция байткода стоила полного поиска: атрибут — три словаря, глобальное имя — два. Убрать одно обращение означало убрать заметную долю работы.
С 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 картина снова другая.
array, генераторы, __slots__), выход из интерпретатора — до числа ядер, и только в конце микрооптимизации, дающие проценты. Половина известных советов устарела: списковое включение против цикла с append — шесть процентов, то есть шум, потому что с 3.11 интерпретатор специализирует инструкции по факту исполнения. Хойстинг ещё работает, но даёт 14%, а не разы. И есть эффект, которого нет в советах: однородная коллекция обходится в 1.4 раза быстрее смеси классов, потому что инлайн-кеш хранит одну версию типа. Любой совет старше 3.11 подлежит перепроверке замером.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. Обрати внимание на разброс: между верхом и низом таблицы четыре порядка, и именно поэтому порядок шагов важнее их содержания.
Прогони фрагменты, сверяясь с предсказанием. Затем:
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
Последний вопрос выводит на общий принцип: любая работа, целиком выполненная внутри одной встроенной функции, не выходит в интерпретатор на каждой итерации — и потому дешевле цикла, делающего то же самое.
Lib/cProfile.py и Lib/pstats.py. Стоит один раз прочитать pstats, чтобы перестать смотреть на профиль как на стену текста: там всего несколько методов сортировки и фильтрации, и print_callers с print_callees отвечают на вопрос «кто это вызывает» лучше, чем чтение кода.
Lib/dis.py. Параметр adaptive=True показывает специализированные инструкции — единственный способ увидеть, применилась ли оптимизация из урока 04 к твоему коду. Полезно на горячем цикле: если инструкции остались общими, значит специализация не удалась, и стоит понять почему.
Lib/timeit.py. Заметь, что он по умолчанию отключает сборщик мусора и берёт минимум из прогонов, а не среднее. Оба решения объяснены в исходнике и объясняют, почему timeit даёт цифры лучше самодельного замера через time.perf_counter.
Из 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.
Тег v3.11.15.
Python/specialize.c — как принимается решение специализировать инструкцию и по каким причинам не получается. Список причин отказа сам по себе полезен как чеклист.Python/ceval.c — цикл интерпретатора: видно, что специализированные инструкции это отдельные ветви.Objects/frameobject.c — фреймы, ставшие в 3.11 дешевле; отсюда падение стоимости вызова.obj.x насквозь — инлайн-кеши и эффект однородности