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}")