Deep Engineering

MEASUREMENT

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

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

скрипт что показывает
identity.py тип, байт-код и __name__ у lambda и def; что в lambda не компилируется
late_binding.py [lambda: i for i in range(3)] даёт [2, 2, 2]; одна ячейка на три функции; чем включение отличается от for-цикла
silent.py ошибка, которая не падает: три проверки с одним порогом дают 3 вместо 6
cost.py lambda против def при вызове и в key=; operator.itemgetter против обоих

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

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v late_binding.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 все сравнения делаются внутри одного запуска одного интерпретатора: lambda против def, и оба против itemgetter. Такое сравнение честно всегда и от тулчейна не зависит. Числа для 3.13.7 и 3.14.7 приводятся рядом как два независимых результата, а не как сравнение.

Отдельно про то, чего в cost.py НЕТ: там не измеряется sorted(DATA) без key. Он сравнивал бы кортежи по первому элементу, то есть по строке, а три измеряемые формы сортируют по числу — это разная работа, и приписывать разницу способу извлечения ключа было бы подлогом.

identity.py, late_binding.py и silent.py от тулчейна не зависят вовсе: они смотрят на типы, имена, содержимое ячеек замыкания и результат вычисления.

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

cost.py — лучшее из семи прогонов. Повторные запуски дают разброс около ±3 %, и он важен для чтения результата: разница между key=lambda и key=def в него укладывается (в повторных прогонах порядок этих двух даже меняется местами), а разница с itemgetter — нет, она устойчиво в полтора раза. Поэтому в уроке сказано «неотличимо» про первую пару и названа кратность для второй.

Script

52 lines
"""Стоит ли lambda дороже def, и что дороже — key=lambda или itemgetter.

ОБЩЕЕ ПРАВИЛО ЗАМЕРОВ: время между версиями не сравнивается вообще.
Сравнивается только измеренное внутри одного запуска одного интерпретатора —
все сравнения ниже такие. Числа 3.13.7 и 3.14.0rc2 приводятся рядом как два
независимых результата, а не как сравнение.
"""
import sys, timeit, operator, random

N = 10_000
REPEAT = 60

def named(x):
    return x[1]

anon = lambda x: x[1]                 # noqa: E731
getter = operator.itemgetter(1)

random.seed(20260821)
DATA = [(f"k{i}", random.randint(0, 1_000_000)) for i in range(200)]

def measure(stmts, setup, number):
    """Минимум по кругам; в каждом круге меряются все формы — см. докстринг."""
    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=number) / number)
    return best

setup = "from __main__ import named, anon, getter, DATA"
print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} чередующихся кругов")

DIRECT = [("def", "named(('k', 1))"), ("lambda", "anon(('k', 1))")]
print("  вызов напрямую (по", f"{N:,}", "итераций):")
for (label, _), t in zip(DIRECT, measure([s for _, s in DIRECT], setup, N)):
    print(f"    {label:<22} {t * 1e9:7.1f} нс")

M = 2_000
print("  sorted по 200 элементам (по", f"{M:,}", "сортировок):")
SORTED = [
    ("key=lambda", "sorted(DATA, key=anon)"),
    ("key=def", "sorted(DATA, key=named)"),
    ("key=itemgetter(1)", "sorted(DATA, key=getter)"),
    # `sorted(DATA)` без key сюда НЕ добавлен намеренно: он сравнивает
    # кортежи по первому элементу, то есть по строке, а три строки выше
    # сортируют по числу. Это разная работа, и приписывать разницу способу
    # извлечения ключа было бы подлогом. Сравниваются три способа достать
    # ОДИН И ТОТ ЖЕ ключ.
]
for (label, _), t in zip(SORTED, measure([s for _, s in SORTED], setup, M)):
    print(f"    {label:<22} {t * 1e6:7.2f} мкс")