Deep Engineering

MEASUREMENT

bench/decorators/cost.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/decorators
How to run it
python3.13 bench/decorators/cost.py
python3.14 bench/decorators/cost.py
python3.13 bench/decorators/wraps_signature.py

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Декораторы»

Скрипт Что меряет
cost.py цену слоёв обёртки: 0, 1, 2 и 3 слоя *args, **kwargs с functools.wraps над одной и той же функцией
wraps_signature.py что на самом деле показывает inspect.signature у обёртки с wraps и что меняет follow_wrapped=False
python3.13 bench/decorators/cost.py
python3.14 bench/decorators/cost.py
python3.13 bench/decorators/wraps_signature.py

Каталог runs/ — записи прогонов

В runs/ лежит дословный вывод скриптов, без правок и без пересказа. Это не украшение: на этих файлах стоят практические задачи урока. Ответ задачи — строка из записи прогона, и сборка сайта сверяет одно с другим (scripts/validate-practice.mjs): задача, чей ответ разошёлся с прогоном или чей скрипт исчез, не собирается.

Прогоны сняты 30.08.2026 на CPython 3.13.7 (Clang 20.1.4) и 3.14.7 (Clang 22.1.3), в песочнице разработки. Числа урока взяты из ЭТИХ же прогонов — таблица, заблуждение, визуализация WrapperCost и практическая задача показывают одну четвёрку, а не три разных, как было до 30.08.

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

Перезаписывать запись прогона имеет смысл только вместе с проверкой задач: если после перезапуска ответ изменился, изменить нужно и задачу, а не файл.

Что здесь важно прочитать правильно

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

Кратность устойчива только потому, что формы меряются вперемежку. Пока они мерились подряд, отношение трёх слоёв к голому вызову выходило 5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках — просадка машины в окне одной формы целиком доставалась ей. После перехода на чередующиеся круги тот же замер даёт 7,1–7,7. Это правило теперь общее для всех скриптов с временем.

Общее правило замеров: время между версиями не сравнивается вообще. Сравнивается только измеренное внутри одного запуска одного интерпретатора. Числа 3.13.7 и 3.14.7 приводятся рядом как два независимых результата, а не как сравнение — цена слоя внутри каждого прогона своя, и именно она переносится на другую машину.

Чего этот замер не показывает

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

Script

86 lines
"""Сколько стоит каждый слой декоратора.

ПОЧЕМУ ЭТОТ ФАЙЛ ПОЯВИЛСЯ ПОЗЖЕ УРОКА. Числа обёрток были в уроке и в
визуализации `WrapperCost`, а скрипта, которым они получены, в репозитории не
было — то есть проверить их читатель не мог, хотя весь сайт обещает обратное.
Файл закрывает эту дыру: он и есть источник четырёх чисел.

ЧТО МЕРЯЕТСЯ. Одна и та же функция под нулём, одним, двумя и тремя слоями
обычной обёртки `*args, **kwargs` с `functools.wraps`. Каждый слой добавляет
вызов Python-функции и упаковку аргументов, поэтому рост должен быть линейным —
и в этом всё содержание замера.

ОБЩЕЕ ПРАВИЛО ЗАМЕРОВ: время между версиями не сравнивается вообще. Все
четыре формы измеряются одним процессом, и сравнивать их между собой можно
всегда.

ПОЧЕМУ ЗАМЕРЫ ЧЕРЕДУЮТСЯ, А НЕ ИДУТ ПОДРЯД. Первая версия мерила формы одну
за другой: сначала семь повторов голого вызова, потом семь повторов одного
слоя и так далее. На машине с переменной нагрузкой это даёт не шум, а СДВИГ:
просадка, случившаяся в окне одной формы, целиком достаётся ей, и кратность
между формами меняется от запуска к запуску. Измерено на четырёх запусках
подряд: отношение трёх слоёв к голому вызову вышло 5,9 / 7,1 / 7,5 / 9,2 —
полуторакратный разброс там, где меряется одно и то же.

Теперь формы чередуются: в каждом круге меряются все четыре, и минимум для
каждой берётся по кругам. Просадка круга бьёт по всем четырём сразу, поэтому
кратность её переживает, а минимум по кругам остаётся оценкой наименее
потревоженного выполнения.
"""
import functools
import sys
import timeit

N = 20_000
REPEAT = 100


def target(x):
    return x + 1


def wrap(fn):
    @functools.wraps(fn)
    def inner(*args, **kwargs):
        return fn(*args, **kwargs)

    return inner


one = wrap(target)
two = wrap(wrap(target))
three = wrap(wrap(wrap(target)))

ROWS = [
    ("без декораторов", "target(1)"),
    ("один слой", "one(1)"),
    ("два слоя", "two(1)"),
    ("три слоя", "three(1)"),
]


def measure(stmts):
    """Минимум по кругам для каждой формы; в каждом круге меряются все формы."""
    setup = "from __main__ import target, one, two, three"
    best = [float("inf")] * len(stmts)
    for _ in range(REPEAT):
        for i, stmt in enumerate(stmts):
            best[i] = min(best[i], timeit.timeit(stmt, setup=setup, number=N) / N)
    return best


print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} чередующихся кругов по {N:,} вызовов".replace(",", " "))
values = [t * 1e9 for t in measure([stmt for _, stmt in ROWS])]
for (label, _), ns in zip(ROWS, values):
    print(f"    {label:<20} {ns:7.1f} нс")

base = values[0]
print("  прибавка на слой:")
for i in range(1, len(values)):
    print(f"    слой {i}: +{values[i] - values[i - 1]:6.1f} нс")
print(f"  доля работы самой функции при трёх слоях: {base / values[-1] * 100:.0f} %")
# Кратность печатается отдельной строкой, потому что именно она переносится на
# другую машину, а наносекунды — нет. На ней же стоит практическая задача урока
# («во сколько раз»), и сборочная проверка ищет это число здесь.
print(f"  три слоя дороже голого вызова в {values[-1] / base:.1f} раза")