Deep Engineering

ЗАМЕР

bench/list-vs-tuple/cost.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/list-vs-tuple
Как запустить
for v in 3.11 3.12 3.13 3.14; do python$v bench/list-vs-tuple/layout.py; done

Запись прогона

Замеры для урока «Список против кортежа»

Скрипт Что меряет
layout.py раскладка в памяти: getsizeof списка против кортежа, откуда берутся 16 байт, запас списка при росте и формула list_resize (коэффициент 1,125, а не удвоение), поведение запаса на большом скачке и при pop, tracemalloc на батче
bytecode.py почему константный кортеж не строится во время исполнения и когда он это преимущество теряет
cost.py пять операций по отдельности: создание из литерала, создание из переменных, чтение по индексу, распаковка, обход
traps.py пять мест, где «кортеж неизменяемый» даёт неверный вывод
silent.py ошибка, которая не падает: пропущенная запятая и общий список от умножения
hash_cache.py кеш хеша кортежа, появившийся в 3.14 (gh-131525): проверка внутри одного запуска, без сравнения версий
for v in 3.11 3.12 3.13 3.14; do python$v bench/list-vs-tuple/layout.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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

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

«Кортеж быстрее» — утверждение без указания операции. cost.py разносит их по строкам, и результат на 3.13.7 такой: создание из литерала — кортеж быстрее в 5,2–5,4 раза, создание из переменных — в 1,4, чтение по индексу и распаковка — 1,00, обход тысячи элементов — 1,10. Кратности даны диапазоном по трём запускам: с одного прогона вторая значащая цифра не воспроизводится.

Первая строка не про типы вовсе: константный кортеж во время исполнения не строится, он целиком лежит в co_consts (bytecode.py). Стоит появиться внутри одному неконстантному выражению — и преимущество схлопывается до полутора раз, а на чтении и распаковке его нет совсем.

Направление на обходе зависит от сборки. На 3.13.7 кортеж обходится быстрее списка в 1,10 раза; на 3.14.7 — наоборот, список быстрее кортежа на 7–8 %. Оба числа измерены внутри своего прогона и оба верны; из этого следует не «на 3.14 стало хуже» (так сравнивать нельзя), а то, что направление на обходе не свойство языка.

getsizeof и tracemalloc здесь сходятся — и это не опечатка

Первая версия этого каталога утверждала, что getsizeof не видит массива, на который список ссылается, и потому занижает разницу. Это неверно, и вычитка поймала это до публикации. getsizeof считает массив вместе с запасом: выросший через append список из 17 элементов даёт 248 = 56 + 24 × 8.

Раздел 4 layout.py теперь сводит два инструмента напрямую и вычитает список, который держит батч (он стоит 8,51 байта на объект). Результат:

что мерим tracemalloc getsizeof
кортеж из 10 120,0 120
список через list(range(10)) 136,0 136
список через включение 183,9 184

Сходятся до десятых. Правило «память меряется tracemalloc на батче» здесь ничего бы не изменило — и это не отменяет правила, а очерчивает его границу: оно про то, что getsizeof не считает элементы. Здесь элементы — малые целые, то есть синглтоны, и считать нечего.

Замените их на строки, которые создаются заново, — и расхождение появляется:

что мерим tracemalloc getsizeof
список из 10 свежих строк 653,6 184

Отсюда формулировка, которая и попала в урок: getsizeof надёжен ровно настолько, насколько элементы контейнера принадлежат кому-то ещё.

Разница в 16 байт — против одного списка из трёх. При длине 17: кортеж 176, список литералом 192, list(range(17)) 200, выросший через append 248. Против последнего разница не 16 байт, а 72.

Про 3.14 и сравнение версий

hash_cache.py написан так, чтобы не сравнивать время между версиями, хотя соблазн велик. Наличие кеша доказывается сравнением двух чисел одного прогона: первый хеш нового объекта против повторного хеша того же объекта.

версия getsizeof(()) повторный хеш дешевле первого в
3.11.15 40 0,9 раза
3.12.3 40 0,9 раза
3.13.7 40 0,9 раза
3.14.7 48 3,9 раза

Три версии подряд говорят «кеша нет», четвёртая — «есть», и ровно там же кортеж прибавил 8 байт. Это одно поле ob_hash, добавленное в PyTupleObject.

Скрипт

116 строк
"""Во сколько обходится выбор между списком и кортежем — по операциям.

ЧТО ИЗМЕРЯЕТСЯ. «Кортеж быстрее» — утверждение без указания операции, а
операций тут четыре, и ведут они себя по-разному:

  1. СОЗДАНИЕ ИЗ ЛИТЕРАЛА. Здесь разница наибольшая, и она не про типы:
     константный кортеж во время исполнения не строится вовсе (`bytecode.py`).
  2. СОЗДАНИЕ ИЗ ПЕРЕМЕННЫХ. Как только внутри есть неконстантное выражение,
     оба собираются инструкцией — и разница схлопывается.
  3. ЧТЕНИЕ ПО ИНДЕКСУ. То, ради чего контейнер обычно и заводят.
  4. ОБХОД ЦЕЛИКОМ.

Плюс отдельная строка про распаковку — она в спорах не участвует, а в реальном
коде встречается чаще индексирования.

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

N = 2_000_000
REPEAT = 7

SETUP = """
L = [1, 2, 3]
T = (1, 2, 3)
a = 1
BIG_L = list(range(1000))
BIG_T = tuple(range(1000))
"""

ROWS = [
    ("создать литерал: [1, 2, 3]", "[1, 2, 3]", N),
    ("создать литерал: (1, 2, 3)", "(1, 2, 3)", N),
    ("создать из переменной: [a, 2, 3]", "[a, 2, 3]", N),
    ("создать из переменной: (a, 2, 3)", "(a, 2, 3)", N),
    ("прочитать L[1]", "L[1]", N),
    ("прочитать T[1]", "T[1]", N),
    ("распаковать x, y, z = L", "x, y, z = L", N),
    ("распаковать x, y, z = T", "x, y, z = T", N),
    ("обойти список из 1000", "for _ in BIG_L: pass", N // 500),
    ("обойти кортеж из 1000", "for _ in BIG_T: pass", N // 500),
]


def best(stmt, number):
    return min(timeit.repeat(stmt, setup=SETUP, number=number, repeat=REPEAT)) / number


# ЗАЧЕМ ПОВТОРЫ РАЗНЕСЕНЫ ПО ОТДЕЛЬНЫМ ПРОЦЕССАМ.
#
# Урок держится не на наносекундах, а на кратностях, и про каждую обязан
# сказать, различима ли она прибором. Повторять замер внутри одного процесса
# для этого мало, и это здесь измерено: строка «создать из переменной» у
# одного процесса выходит 1.29, у другого — 1.79, и внутри каждого процесса
# все пять повторов держатся своего значения. То есть разброс живёт МЕЖДУ
# запусками, а не внутри них: он задаётся тем, в каком состоянии процесс
# стартовал (раскладка кучи, специализация, частота ядра). Замер, повторённый
# внутри процесса, этого не видит вовсе и печатает ложно узкий размах.
#
# Поэтому OUTER проходов запускаются отдельными процессами: родитель зовёт
# сам себя с --pass, ребёнок печатает одну строку JSON, родитель собирает.
OUTER = 5


def one_pass():
    return {label: best(stmt, number) for label, stmt, number in ROWS}


if "--pass" in sys.argv:
    print(json.dumps(one_pass()))
    raise SystemExit

series = {label: [] for label, _, _ in ROWS}
for _ in range(OUTER):
    out = subprocess.run(
        [sys.executable, __file__, "--pass"], capture_output=True, text=True, check=True
    ).stdout
    for label, sec in json.loads(out).items():
        series[label].append(sec)
results = {label: min(vals) for label, vals in series.items()}

print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} прогонов в каждом из {OUTER} процессов")
for label, _, _ in ROWS:
    sec = results[label]
    unit, value = ("нс", sec * 1e9) if sec < 1e-6 else ("мкс", sec * 1e6)
    print(f"    {label:<36} {value:9.1f} {unit}")

print()
print("  Пары, ради которых всё это считается (кортеж против списка):")
PAIRS = [
    ("создать литерал", "создать литерал: [1, 2, 3]", "создать литерал: (1, 2, 3)"),
    ("создать из переменной", "создать из переменной: [a, 2, 3]", "создать из переменной: (a, 2, 3)"),
    ("прочитать по индексу", "прочитать L[1]", "прочитать T[1]"),
    ("распаковать", "распаковать x, y, z = L", "распаковать x, y, z = T"),
    ("обойти 1000 элементов", "обойти список из 1000", "обойти кортеж из 1000"),
]
for name, lst, tpl in PAIRS:
    ratio = results[lst] / results[tpl]
    print(f"    {name:<24} кортеж быстрее в {ratio:5.2f} раза")

print()
print(f"  Различима ли каждая кратность прибором (размах по {OUTER} процессам):")
for name, lst, tpl in PAIRS:
    rs = sorted(a / b for a, b in zip(series[lst], series[tpl]))
    lo, hi = rs[0], rs[-1]
    # Кратность «различима», если весь её размах лежит по одну сторону от
    # единицы: тогда знак разницы не меняется от повтора к повтору.
    verdict = "да " if lo > 1.0 or hi < 1.0 else "нет"
    print(f"    {name:<24} {lo:5.2f} .. {hi:5.2f}   размах {hi - lo:4.2f}   различима {verdict}")