ЗАМЕР
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}")