ЗАМЕР
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}")