Deep Engineering

MEASUREMENT

bench/args-kwargs/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/args-kwargs
How to run it
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done

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

Замеры для урока «*args и **kwargs»

скрипт что показывает
identity.py *args — кортеж, **kwargs — словарь, и оба создаются даже при пустом вызове; что о звёздочках знает код функции (co_flags, co_kwonlyargcount, co_posonlyargcount)
swallowed_typo.py **kwargs, добавленный «для гибкости», превращает опечатку в имени аргумента из TypeError в молчаливо принятое значение по умолчанию
cost.py цена именованной передачи, распаковки кортежа, распаковки словаря, приёма звёздочками и обёрток-декораторов
wrapper_signature.py что обёртка делает с подписью функции и куда уезжает ошибка

Запускать на всех версиях, которые есть:

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

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

Общее правило замеров: время между версиями не сравнивается вообще. Сравнивается только то, что измерено внутри одного запуска одного интерпретатора. Дело не только в компиляторе: 3.11 и 3.12 собраны GCC 13.3.0, 3.13.7 и 3.14.7 — Clang 20.1.4, но и эти две сборки различаются между собой, причём ровно тем флагом (--with-tail-call-interp), которому «Что нового в 3.14» приписывает «a geometric mean of 3-5% faster».

В cost.py все сравнения делаются внутри одного запуска одного интерпретатора: семь форм вызова одной и той же функции меряются подряд одним процессом. Такое сравнение честно всегда. Числа 3.13.7 и 3.14.7 приводятся рядом как два независимых результата, а не как сравнение.

identity.py, swallowed_typo.py и wrapper_signature.py от тулчейна не зависят вовсе: они смотрят на типы, флаги, подписи и текст исключений.

Разброс между запусками

cost.py — лучшее из семи прогонов. Три повторных запуска подряд на 3.13.7 дали 24,3 / 24,2 / 24,2 нс на позиционном вызове и 211 / 207 / 206 нс на трёх обёртках, то есть разброс около ±2 %. Все кратности, названные в уроке (×1,4 на именованной передаче, ×4 на распаковке словаря, ×8,5 на трёх обёртках), в этот разброс не укладываются — они настоящие.

Чего в cost.py НЕТ и почему

Нет строки plain(*ARGS, **KWARGS). Она передала бы два позиционных и два именованных аргумента в функцию с двумя параметрами, то есть упала бы с got multiple values for argument 'a'. Обе распаковки сразу происходят в строках про обёртки — там они и измерены, на вызове, который действительно работает.

Нет сравнения с functools.partial и с вызовом метода: и то и другое — другой механизм передачи, и их место в отдельном замере, а не в строке, которую прочтут как «звёздочки против непонятно чего».

Замеченное попутно: подсказка имени появилась в 3.13

connect("db", timeuot=5) у функции без **kwargs:

3.11.15 -> connect() got an unexpected keyword argument 'timeuot'
3.12.3  -> connect() got an unexpected keyword argument 'timeuot'
3.13.7  -> connect() got an unexpected keyword argument 'timeuot'. Did you mean 'timeout'?
3.14.7 -> то же, что 3.13.7

Это прямо относится к уроку: интерпретатор научился ловить ровно эту опечатку лучше, а функция, объявленная с **kwargs, эту помощь выбрасывает — и на 3.14 ведёт себя так же, как на 3.11. Проверено swallowed_typo.py и wrapper_signature.py.

Script

82 lines
"""Во сколько обходятся звёздочки — на приёме и на передаче.

ЧТО ИЗМЕРЯЕТСЯ И ЗАЧЕМ. Про `*args, **kwargs` обычно говорят как про запись,
а не как про работу. Работа есть, и её видно с двух сторон:

  1. НА ПРИЁМЕ — функция, объявленная как `def f(*args, **kwargs)`, на каждом
     вызове собирает кортеж и словарь, даже если внутри они не нужны;
  2. НА ПЕРЕДАЧЕ — вызов `f(*args, **kwargs)` эти кортеж и словарь
     разбирает обратно.

Обёртка-декоратор делает и то и другое, поэтому её цена меряется отдельно —
и отдельно же цена трёх обёрток друг на друге, потому что в реальном коде
их редко бывает одна.

ПРАВИЛО (bench/README.md): время между версиями не сравнивается вообще.
Сравнивается только измеренное внутри одного запуска одного интерпретатора —
все сравнения ниже такие. Числа 3.13.7 и 3.14.0rc2 приводятся рядом как два
независимых результата, а не как сравнение.
"""
import functools
import sys
import timeit

N = 1_000_000
REPEAT = 7


def plain(a, b):
    return a


def starred(*args, **kwargs):
    return args[0] if args else None


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

    return inner


one = wrap(plain)
three = wrap(wrap(wrap(plain)))

ARGS = (1, 2)
KWARGS = {"a": 1, "b": 2}


def best(stmt, number=N):
    setup = "from __main__ import plain, starred, one, three, ARGS, KWARGS"
    return min(timeit.repeat(stmt, setup=setup, number=number, repeat=REPEAT)) / number


ROWS = [
    ("позиционно: plain(1, 2)", "plain(1, 2)"),
    ("именованно: plain(a=1, b=2)", "plain(a=1, b=2)"),
    ("распаковка кортежа: plain(*ARGS)", "plain(*ARGS)"),
    ("распаковка словаря: plain(**KWARGS)", "plain(**KWARGS)"),
    # Строки `plain(*ARGS, **KWARGS)` здесь нет, и это не пропуск: такой
    # вызов передал бы два позиционных И два именованных в функцию с двумя
    # параметрами, то есть упал бы с «got multiple values for argument 'a'».
    # Обе распаковки сразу происходят в строках про обёртки — там они и
    # измерены, на настоящем вызове.
    ("приём звёздочками: starred(1, 2)", "starred(1, 2)"),
    ("одна обёртка над plain", "one(1, 2)"),
    ("три обёртки над plain", "three(1, 2)"),
]

print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} прогонов, по {N:,} вызовов".replace(",", " "))
results = {}
for label, stmt in ROWS:
    ns = best(stmt)
    results[label] = ns
    print(f"    {label:<36} {ns * 1e9:7.1f} нс")

base = results["позиционно: plain(1, 2)"]
print("  во сколько раз дороже прямого позиционного вызова:")
for label in list(results)[1:]:
    print(f"    {label:<36} x{results[label] / base:5.2f}")