Deep Engineering

ЗАМЕР

bench/bytecode_bench.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/runtime/source-to-bytecode
Как запустить
for v in 3.11 3.12 3.13 3.14; do python$v bench/build_config.py; done

Запись прогона

Скрипты замеров

Числа в статьях берутся отсюда, а не из головы. Каждый скрипт печатает таблицу и рассчитан на перезапуск на другом железе — потому что железо машины замеров (Xeon 2.80GHz, 2 vCPU) шумное, и время в статьях подаётся как порядок величины.

Скрипт Что меряет Для какой статьи
bytecode_bench.py состав объекта кода: инструкции против code units, кеш-слоты, число опкодов, HAVE_ARGUMENT От исходника к байткоду
source-to-bytecode/ разделение двух проходов оптимизатора: что сворачивает AST (ast_opt.c/ast_preprocess.c), что — CFG (flowgraph.c), и как это распределение сдвинулось от 3.13 к 3.14 От исходника к байткоду
lists/ ёмкость против длины и формула роста; сжатие при pop и его отсутствие при del с 3.13; цена работы с началом, квадратичность insert(0), цена индекса у deque Списки изнутри
strings/ раскладка PEP 393 и ширина символа; интернирование; быстрый путь конкатенации и цена одного шага Строки изнутри
calls/ цена пятнадцати форм вызова, звёздочки на приёме и на передаче; матрица «вид метода × способ чтения» и разброс по раундам; специализации после прогрева; ячейки замыкания Вызов функции
list_bench.py рост allocated, insert(0)/pop(0)/del L[i], getsizeof против tracemalloc — ранний разведочный скрипт, в статьях не цитируется; числа «Списков изнутри» берутся из lists/
str_bench.py представления PEP 393, интернирование, конкатенация — ранний разведочный скрипт, в статьях не цитируется; числа «Строк изнутри» берутся из strings/
set_bench.py smalltable, коллизии, порядок Множества изнутри — тема в резерве, из спины убрана 20.08
call_bench.py стоимость форм вызова, ячейки, специализация — ранний разведочный скрипт, в статьях не цитируется; числа «Вызова функции» берутся из calls/
typing/ вывод проверяющего на разных способах типизации декоратора Типизация декораторов
load-balancing/ алгоритмы балансировки на модели очередей + живые замеры DNS Балансировка нагрузки
iterators/ протокол итерации, память и время генератора против списка, PEP 479 и PEP 709 Итераторы и генераторы
async-await/ цена await, выхода в цикл событий и create_task; gather против TaskGroup async/await
args-kwargs/ цена звёздочек на приёме и на передаче, обёртки, __text_signature__ *args и **kwargs
context-managers/ цена with в двух формах, байт-код входа, ловушки протокола Контекстные менеджеры
exceptions/ цена не сработавшего и сработавшего try, finally, цепочки, группы Исключения и finally
decorators/ цена слоёв обёртки, порядок применения, functools.wraps Декораторы
lambda/ lambda против def, позднее связывание, цена key= lambda
event-loop/ из чего складывается цена await; во что обходится asyncio.run в цикле Цикл событий
dict/ раскладка dk_indices/dk_entries, ширина индекса, пороги роста, удаления; худший случай при вырожденном __hash__ и цена пользовательских __hash__/__eq__ Словари изнутри
gil/ интервал переключения, где он не действует, цена свободной сборки GIL
memory/ счётчики ссылок, поколения сборщика, арены и пулы; диагностическая рамка «tracemalloc против RSS» Управление памятью
bff/ что достаёт скрипт из источника страницы; рост цены экрана с числом обходов Backend for Frontend
interpreter-loop/ кадры, диспетчеризация, глубина рекурсии; когда объект-фрейм материализуется и во что это обходится под профайлером Цикл интерпретатора
adaptive/ семейства инструкций и прогрев, цена полиморфизма, второй уровень Адаптивный интерпретатор
closures/ ячейки замыкания, границы областей, цена доступа к имени Замыкания
async-vs-sync/ потолок пула, цена корутины, счётный кусок под GIL async против sync
copy/ глубина копирования, общность и циклы, два набора атомарных типов; цена пяти способов Поверхностное и глубокое копирование
identity/ кеш малых чисел, объединение констант, интернирование, nan; цена is против == is против ==
metaclass/ порядок шагов создания класса и экземпляра; цена метакласса и перехвата __call__ Метаклассы
defaults/ где лежит значение по умолчанию и когда вычисляется; цена проверки; кеш в аргументе против lru_cache Изменяемый аргумент по умолчанию
di/ одна сессия на запрос или две; этап обнаружения ошибки; цена контейнера DI-контейнер: dishka против dependency-injector
outbox/ что теряет двойная запись; какую строку теряет релей с памятью; порядок доставки; цена строки outbox, партии, холостого опроса и уборки Transaction outbox
goslice/ из чего состоит заголовок среза; когда два среза делят массив; как на самом деле растёт запас; цена построения, копирования, обхода и удаления Срез в Go
gomap/ устройство Swiss Table; сколько бывает порядков обхода; что делают delete и clear с памятью; цена промаха, попадания и подсказки размера; старая реализация против новой Карта в Go
gochan/ прямая передача мимо буфера; равномерность select и отсутствие приоритета у отмены; закрытый и nil-канал; утечка горутин; цена буфера, ветки select и размера элемента Канал в Go
gogenerics/ сколько тел кода компилятор порождает на форму; цена вызова метода через ограничение, чтения, построения среза, сортировки и арифметики Дженерики в Go

Линия SRE: замеры с записанным прогоном

У этих каталогов есть то, чего нет у остальных: рядом со скриптом лежит runs/*.txt — дословный вывод того прогона, из которого взяты числа урока. Это не архив, а предмет проверки: node scripts/validate-runs.mjs требует, чтобы каждый пронумерованный блок вывода в тексте урока дословно нашёлся в одном из этих файлов. Гейт добавлен после того, как урок 13 разошёлся с прогоном на два блока; та же проверка сразу поймала расхождение в уроке 14.

Каталог Что предъявляет Урок
signals/ умолчательные действия сигналов для PID 1; цена отсутствия обработчика 1. Сигналы
cgroups/ пик, равный лимиту; счёт освобождений перед выбором жертвы 2. cgroups и OOM
rlimits/ лимит считает занятые номера, а не вызовы open 3. Дескрипторы
durability/ кеш страниц переживает SIGKILL процесса, но не машину 4. Кеш страниц и fsync
acceptq/ рукопожатие без единого accept; очередь как молчаливый таймаут 5. Очередь accept
deadlines/ цепочка таймаутов складывается; дедлайн едет с запросом 6. Таймауты и дедлайны
retries/ амплификация, синхронизация клиентов, цена джиттера 7. Ретраи и джиттер
shutdown/ 20 из 20 оборванных без дренажа и 0 из 20 с ним; настоящий выход по SIGTERM 8. Graceful shutdown
queueing/ хвост растёт кратно от загрузки и отрывается от середины от разброса 9. Очереди и хвост
slo/ объём неуспешных запросов против громкости события 10. SLO и бюджет ошибок
probes/ liveness с зависимостью удлиняет чужую аварию 11. Health-checks
rollout/ канарейка с порогом по счётчику проигрывает полному переключению 12. Выкатка и откат
nameres/ timeout × attempts × nameservers; отсутствие кеша в процессе; ndots и список поиска 13. Разрешение имени
tls/ 1.3 против 1.2 в кругах; возобновление экономит сертификат, а не круг 14. TLS-рукопожатие
timewait/ TIME_WAIT достаётся закрывшему первым; потолок из диапазона портов 15. TIME_WAIT и порты
balancing/ четыре правила на одних и тех же заявках; однородный бенчмарк как ловушка 16. Выбор реплики
hashring/ переезд ключей и перекос раздачи — две разные задачи 17. Консистентное хеширование
idempotency/ порядок работы с ключом решает всё; окно между эффектом и ответом 18. Идемпотентность

Шесть из них — retries/, queueing/, slo/, probes/, rollout/, balancing/ — не замеры, а модели и расчёты, и печатают это первой строкой вывода; hashring/ — точное вычисление, случайны там только ключи. Остальные запускают то, о чём говорят: nameres/ поднимает свой молчащий сервер имён и подменяет resolv.conf с восстановлением из копии, tls/ проводит настоящее рукопожатие через свой релей с задержкой, timewait/ честно ждёт минуту в блоке 2.

Последние шесть каталогов появились позже статей, которые на них опираются, и это стоит записать: скрипты лежали в tmp/, а tmp/ стоит в .gitignore — то есть в репозитории их не было вовсе. Правило первой строки этого файла («числа в статьях берутся отсюда, а не из головы») для уроков выполнялось, для статей — нет. Перенос это исправил, и первая же сверка поймала две ошибки: расхождение чисел цикла событий с уроком про async/await вдвое и расчёт словаря по двум байтам на слот индекса там, где их четыре. Подробности — на страницах этих замеров.

Каталог typing/ — исключение из всего, что написано ниже: там доказательство не время и не байты, а вывод mypy. Правила воспроизводимости для него свои.

Два правила, нарушение которых уже давало неверные числа

Память меряется tracemalloc на батче, а не sys.getsizeof. getsizeof — это tp_basicsize + tp_itemsize*ob_size: только сам объект, без того, на что он ссылается, и без округлений аллокатора. Для __slots__ он даёт ответ с обратным знаком (48 без слотов против 56 со слотами), хотя реальная экономия — 40 байт на экземпляр.

Время между версиями не сравнивается вообще. Сравнивается только то, что измерено внутри одного запуска одного интерпретатора.

Правило здесь было слабее и потому неверное: «время сравнивается только между сборками одного тулчейна». Из него следовало, что 3.13.7 и 3.14 ставить рядом можно — обе Clang 20.1.4, — и это было написано в четырёх подписях к визуализациям и в уроке про async/await. Компилятор действительно один. Конфигурация сборки — нет. Проверяется запуском:

for v in 3.11 3.12 3.13 3.14; do python$v bench/build_config.py; done
версия компилятор флаги, меняющие скорость
3.11.15 GCC 13.3.0 ни одного
3.12.3 GCC 13.3.0 ни одного
3.13.7 Clang 20.1.4 PGO, BOLT, LTO, mimalloc, экспериментальный JIT
3.14.7 Clang 22.1.3 всё то же и --with-tail-call-interp

Компилятор у 3.14 теперь тоже свой. До перехода на финальную 3.14 в этой таблице стояла сборка 3.14.0rc2, собранная тем же Clang 20.1.4, что и 3.13.7, — и на этом совпадении когда-то строился неверный вывод, разобранный ниже. С установкой 3.14.7 совпадения нет вовсе: она собрана Clang 22.1.3. Правило от этого не меняется, но опереться на «компилятор один» больше нельзя даже формально.

Последний флаг — не мелочь и не совпадение. «Что нового в 3.14» говорит о нём: «Preliminary benchmarks suggest a geometric mean of 3-5% faster on the standard pyperformance benchmark suite» — и там же: «This feature is opt-in for now». То есть 3.14 здесь собран с ускорением, которого у 3.13 нет и быть не может, и разделить «стал быстрее язык» и «стала быстрее сборка» этими замерами нельзя.

Что из этого следует практически:

  • Кратности внутри одного запуска остаются в силе полностью. «**kwargs вчетверо дороже позиционной передачи», «await вдвое дороже вызова» — измерены одним процессом на одной сборке и от неё не зависят.
  • Абсолютные числа приводятся с указанием версии и читаются как «столько вышло на этой сборке», а не как свойство версии.
  • Разрешения сравнивать две версии по времени в репозитории быть не должно ни в какой формулировке. Список запрещённых оборотов и проверка всех текстов держатся отдельным тестом.

Числа привязаны к машине и к дате

Абсолютные значения в материалах — это результат конкретного прогона на конкретной машине, а не свойство языка. Проверено дорогой ценой: числа, измеренные в одну сессию, в другую сессию на той же модели процессора (Intel Xeon @ 2.80GHz, 2 vCPU) разошлись до +28 % — сработавший try дал 128,5 нс тогда и 161,3 нс теперь, вход в with на классе 144,4 против 184,7. Дешёвые операции при этом почти не сдвинулись, дорогие сдвинулись сильно.

Что при этом НЕ разошлось — отношения внутри одного запуска. Контрольное сравнение из урока про контекстные менеджеры давало −5,3 / −6,0 / −34,3 % на старых числах и −2,0 / −11,4 / −38,1 % на новых: вывод, ради которого оно делалось, устоял полностью.

Отсюда рабочее правило: утверждение строится на кратности, число приводится как иллюстрация. Все абсолютные числа в материалах перемерены 21 августа 2026 года; при следующем расхождении их надо перемерить снова, а выводы проверить — они должны выстоять.

Как читается ёмкость списка

У трёх разведочных скриптов (list_bench.py, str_bench.py, call_bench.py) внутренние поля читаются через ctypes по id(obj), и это работает только в сборках с GIL. В каталоге lists/ тот же allocated берётся иначе — вычитанием, (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P") — делитель здесь размер указателя, а не восьмёрка, — и прогон lists/growth.py печатает оба числа рядом, чтобы способ был проверен, а не объявлен.

Скрипт

95 строк
"""Что видно между текстом файла и первой инструкцией.

Запускается одним и тем же файлом на 3.12 / 3.13 / 3.14 — разница в выводе и
есть таблица версий для статьи «От исходника к байткоду».
"""

import dis
import opcode
import sys
import types

SRC = "def f(a): return a.x + 1\n"


def line(k, v):
    print(f"{k:<34} {v}")


print(f"python {sys.version.split()[0]}")

ns: dict[str, object] = {}
exec(compile(SRC, "<bench>", "exec"), ns)
f = ns["f"]
code: types.CodeType = f.__code__

line("len(co_code)", len(code.co_code))
line("sys.getsizeof(code)", sys.getsizeof(code))
line("co_consts", repr(code.co_consts))
line("co_names", repr(code.co_names))
line("co_varnames", repr(code.co_varnames))
line("co_stacksize", code.co_stacksize)
line("co_nlocals", code.co_nlocals)
line("число опкодов в языке", len([n for n in opcode.opname if not n.startswith("<")]))
line("opcode.HAVE_ARGUMENT", opcode.HAVE_ARGUMENT)

# Инструкции против code units: dis скрывает CACHE, co_code — нет.
instrs = list(dis.get_instructions(f))
adaptive = list(dis.get_instructions(f, adaptive=True, show_caches=True)) if sys.version_info < (3, 13) else list(dis.get_instructions(f, adaptive=True))
line("инструкций у dis", len(instrs))
line("code units в co_code", len(code.co_code) // 2)
line("строк в исходнике", SRC.count("\n"))

print("  опкоды:", " ".join(i.opname for i in instrs))

# co_lines: сколько диапазонов на одну строку кода.
line("диапазонов co_lines()", len(list(code.co_lines())))
line("co_positions() есть", hasattr(code, "co_positions"))

# --------------------------------------------------------------------------
# Числа, которых здесь не хватало, а в статье они стояли.
#
# ЗАЧЕМ ЭТОТ БЛОК. Три величины статьи не производил ни один скрипт: число
# узлов дерева, число опкодов с аргументом и экономия от `len` в локальной
# переменной. Первая строка bench/README.md обещает обратное, и вычитка
# поймала ровно это. Считать их «очевидными» нельзя: одну из них —
# «опкодов с аргументом» — статья приводит рядом с ловушкой HAVE_ARGUMENT,
# то есть в том самом месте, где число легко объяснить неправильно.
import ast
import timeit

# Число узлов дерева зависит от того, что считать узлом, поэтому печатаются
# оба счёта: полный (ast.walk считает и контексты Load/Store, и операторы
# вроде Add) и содержательный — без них и без обёртки Module.
tree = ast.parse(SRC)
NOISE = (ast.Load, ast.Store, ast.Del, ast.operator, ast.expr_context, ast.Module)
line("узлов в дереве, все (ast.walk)", sum(1 for _ in ast.walk(tree)))
line(
    "узлов в дереве, без контекстов и операторов",
    sum(1 for n in ast.walk(tree) if not isinstance(n, NOISE)),
)

# Опкоды, которые принимают аргумент. Не то же самое, что HAVE_ARGUMENT:
# с 3.12 эта константа перестала делить таблицу пополам, и её значение
# больше не равно числу таких опкодов.
line(
    "опкодов с аргументом",
    sum(
        1
        for i, n in enumerate(opcode.opname)
        if not n.startswith("<") and i >= opcode.HAVE_ARGUMENT
    ),
)

# Приём «положить len в локальную переменную». Экономия измеряется, а не
# предполагается: в статье вывод из неё противоположен ожидаемому.
SETUP = "L = list(range(100))\n_len = len"
best = lambda stmt, setup=SETUP: min(  # noqa: E731
    timeit.repeat(stmt, setup=setup, number=2_000_000, repeat=7)
) / 2_000_000 * 1e9
glob_ns = best("len(L)")
loc_ns = best("_len(L)")
line("len(L) через глобальное имя, нс", f"{glob_ns:.2f}")
line("len(L) через локальное имя, нс", f"{loc_ns:.2f}")
line("экономия, нс на вызов", f"{glob_ns - loc_ns:.2f}")