Deep Engineering

MEASUREMENT

bench/list-vs-tuple/layout.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/list-vs-tuple
How to run it
for v in 3.11 3.12 3.13 3.14; do python$v bench/list-vs-tuple/layout.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

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

Скрипт Что меряет
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.

Script

275 lines
"""Чем список отличается от кортежа в памяти — и почему разница не «16 байт».

ЗАЧЕМ ЭТО МЕРИТЬ. Ответ «кортеж легче, потому что неизменяемый» верен на
словах и бесполезен на практике: он не говорит, на сколько легче, при какой
длине и что происходит с этой разницей, когда список растёт. А разница берётся
из двух разных мест, и только одно из них — заголовок объекта.

  1. ЗАГОЛОВОК. У кортежа элементы лежат в самом объекте, сразу за заголовком.
     У списка в объекте лежит только указатель на отдельный массив — плюс поле
     `allocated`, которого у кортежа нет вовсе.
  2. ЗАПАС. Список при росте выделяет больше, чем попросили, и этот запас в
     `getsizeof` не виден, потому что живёт в отдельном массиве.

Из-за второго пункта наивное сравнение `getsizeof(list)` и `getsizeof(tuple)`
занижает разницу — иногда до нуля. Поэтому здесь меряется и то и другое:
`getsizeof` (что видит читатель у себя в консоли) и `tracemalloc` на батче
(сколько на самом деле).

ОБЩЕЕ ПРАВИЛО ЗАМЕРОВ: память меряется `tracemalloc` на батче, а не
`sys.getsizeof`. Здесь `getsizeof` используется намеренно и в паре с
`tracemalloc` — потому что предмет замера в том числе и есть расхождение между
ними.
"""
import sys
import tracemalloc

PTR = 8  # sizeof(void *) на 64-битной сборке
EMPTY_LIST = sys.getsizeof([])  # заголовок списка без массива


def rule(title):
    print()
    print(title)
    print("-" * len(title))


print("PY", sys.version.split()[0])

# ------------------------------------------------------- 1. что видит getsizeof
rule("1. sys.getsizeof: список против кортежа")

print(f"  {'длина':>6} {'список':>9} {'кортеж':>9} {'разница':>9}")
for n in (0, 1, 2, 3, 5, 10, 100, 1000):
    lst = [None] * n
    tpl = (None,) * n
    a, b = sys.getsizeof(lst), sys.getsizeof(tpl)
    print(f"  {n:>6} {a:>9} {b:>9} {a - b:>9}")

print()
print("  Формулы, из которых эти числа складываются:")
print(f"    кортеж:  40 + 8·n   (проверка на n=100: {40 + 8 * 100} против {sys.getsizeof((None,) * 100)})")
print(f"    список:  56 + 8·n   (проверка на n=100: {56 + 8 * 100} против {sys.getsizeof([None] * 100)})")
print("  Разница ровно 16 байт — и это ВСЯ разница, которую показывает getsizeof.")

# --------------------------------------------------------- 2. откуда 16 байт
rule("2. Откуда берутся эти 16 байт")

print("  У обоих объектов есть PyObject_VAR_HEAD: ob_refcnt, ob_type, ob_size —")
print(f"  это {3 * PTR} байта. Дальше они расходятся:")
print()
print("    tuple:  ob_item[] лежит ПРЯМО В ОБЪЕКТЕ, сразу за заголовком.")
print("    list:   ob_item — УКАЗАТЕЛЬ на отдельный массив (8 байт),")
print("            и рядом поле allocated (ещё 8) — сколько места выделено.")
print()
print("  8 + 8 = 16. Поле allocated и есть то, чего у кортежа нет и быть не может:")
print("  кортежу незачем помнить запас, потому что он не растёт.")

# ------------------------------------------------- 3. запас, невидимый глазу
rule("3. Запас списка: то, чего getsizeof не показывает")

lst = []
prev = sys.getsizeof(lst)
print(f"  {'после append':>14} {'getsizeof':>10} {'выделено под':>13}")
print(f"  {'0':>14} {prev:>10} {(prev - 56) // PTR:>13}")
for i in range(24):
    lst.append(i)
    size = sys.getsizeof(lst)
    if size != prev:
        print(f"  {len(lst):>14} {size:>10} {(size - 56) // PTR:>13}")
        prev = size

print()
print("  Список из 17 элементов держит место под 24. Кортеж из 17 держит 17.")
print("  Это и есть цена изменяемости, и в getsizeof она ВИДНА —")
print("  а вот при построении через list(...) с известной длиной запаса нет:")
for n in (1, 3, 17):
    grown = []
    for i in range(n):
        grown.append(i)
    print(f"    n={n:<4} через append: {sys.getsizeof(grown):>5}   "
          f"через list(range(n)): {sys.getsizeof(list(range(n))):>5}   "
          f"литералом: {sys.getsizeof([None] * n):>5}")

# ------------------------------------------- 3b. по какому правилу растёт запас
rule("3b. Формула роста: не удвоение, а +12,5 % и ещё шесть")

# ЗАЧЕМ ОТДЕЛЬНЫЙ РАЗДЕЛ. Раздел 3 показывает лестницу 0, 4, 8, 16, 24 — то
# есть ВЫВОД правила. Само правило из неё не читается, а именно оно и
# опровергает расхожее «список удваивается»: коэффициент здесь 1,125.
# Формула берётся из list_resize и проверяется на замере, а не наоборот.


def resize_formula(newsize: int) -> int:
    """new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3

    Objects/listobject.c, list_resize. Тег v3.13.7.
    """
    if newsize == 0:
        return 0
    return (newsize + (newsize >> 3) + 6) & ~3


def capacity(seq: list) -> int:
    """Сколько указателей помещается в выделенный массив."""
    return (sys.getsizeof(seq) - EMPTY_LIST) // PTR


print("  Формула из list_resize: newsize + newsize//8 + 6, округлённое вниз")
print("  до кратного четырём. Проверяем её на первых двенадцати ступенях:")
print()
print(f"    {'длина':>7} {'замер':>7} {'формула':>9}")
lst = []
prev = -1
steps = 0
n = 0
while steps < 12:
    lst.append(n)
    n += 1
    cap = capacity(lst)
    if cap != prev:
        f = resize_formula(len(lst))
        mark = "" if cap == f else "   <-- РАСХОЖДЕНИЕ"
        print(f"    {len(lst):>7} {cap:>7} {f:>9}{mark}")
        prev = cap
        steps += 1

print()
print("  ЧТО ЗДЕСЬ ЛЕГКО ПРОЧЕСТЬ НЕВЕРНО. Формула считает запас от ДЛИНЫ, а не")
print("  от прошлого запаса. Поэтому «плюс восьмая часть» и отношение соседних")
print("  запасов — не одно и то же число: на коротком списке правит слагаемое")
print("  +6, и запас поначалу удваивается. Отношение видно только на замере:")
print()
print(f"    {'длина':>8} {'запас':>8} {'к прошлому запасу':>19}")
lst2: list[int] = []
prev2 = -1
ladder = []
for i in range(300000):
    lst2.append(i)
    c = capacity(lst2)
    if c != prev2:
        ladder.append((len(lst2), c))
        prev2 = c
for k in range(1, len(ladder)):
    n2, c2 = ladder[k]
    if k <= 6 or n2 in (129, 673, 2885, 11977, 49365) or k == len(ladder) - 1:
        print(f"    {n2:>8} {c2:>8} {c2 / ladder[k - 1][1]:>19.3f}")
print()
print("  То есть 1,125 — это ПРЕДЕЛ, к которому отношение сходится на длинных")
print("  списках, а не множитель на каждом шаге. Удвоение (C++ vector, срез в Go")
print("  до 256 элементов) — другая стратегия, и разницу видно на счёте:")
print()


def realloc_count(target: int, grow) -> int:
    cap_, steps_ = 0, 0
    for n2 in range(1, target + 1):
        if n2 > cap_:
            cap_ = grow(cap_, n2)
            steps_ += 1
    return steps_, cap_


TARGET = 300000
py_steps, py_cap = realloc_count(TARGET, lambda c, n2: resize_formula(n2))
x2_steps, x2_cap = realloc_count(TARGET, lambda c, n2: max(4, c * 2))
print(f"    до {TARGET} элементов, перевыделений: CPython {py_steps}, удвоением {x2_steps}")
print(f"    итоговый запас:                     CPython {py_cap}, удвоением {x2_cap}")
print(f"    лишнего сверх длины:                CPython {(py_cap - TARGET) / TARGET:.1%}, "
      f"удвоением {(x2_cap - TARGET) / TARGET:.1%}")
print()
print("  Вот и весь размен: вчетверо больше перевыделений ради того, чтобы")
print("  готовый список не держал лишнего на три четверти своей длины.")

print()
print("  Большой скачок за один раз запаса НЕ получает — в list_resize для")
print("  этого стоит отдельная ветка («Do not overallocate if the new size is")
print("  closer to overallocated size than to the old size»):")
at_once = []
at_once.extend(range(1000))
one_by_one = []
for i in range(1000):
    one_by_one.append(i)
print(f"    extend(range(1000)) за раз: выделено под {capacity(at_once)}")
print(f"    1000 раз append:           выделено под {capacity(one_by_one)}")

print()
print("  И обратный ход: список не отдаёт память, пока длина не упадёт НИЖЕ")
print("  половины запаса. В list_resize это условие записано так:")
print("      if (allocated >= newsize && newsize >= (allocated >> 1))")
shrink = list(range(1000))
print(f"    list(range(1000)):     запас {capacity(shrink)}, длина {len(shrink)}")
for _ in range(400):
    shrink.pop()
print(f"    после 400 pop:         запас {capacity(shrink)}, длина {len(shrink)}")
for _ in range(200):
    shrink.pop()
print(f"    после ещё 200 pop:     запас {capacity(shrink)}, длина {len(shrink)}")
print()
print("  Сжатие срабатывает на длине 499 — первой, что оказалась меньше")
print(f"  половины тысячи, — и даёт ровно formula(499) = {resize_formula(499)}.")

# ----------------------------------------------------- 4. tracemalloc на батче
rule("4. tracemalloc на батче — и сходится ли он с getsizeof")

# ЗАЧЕМ ЭТОТ РАЗДЕЛ ВЫГЛЯДИТ ИМЕННО ТАК. Общее правило замеров — «память
# меряется tracemalloc на батче, а не sys.getsizeof» — появилось из случая, где
# getsizeof даёт ответ с обратным знаком (__slots__). Соблазн распространить
# его на всё велик, и здесь он проверяется, а не принимается: для списка и
# кортежа из общих элементов два инструмента сходятся ТОЧНО, если вычесть
# список, который держит батч.
#
# Без контрольного замера этого не увидеть: сам keep стоит ~8,5 байта на
# объект (указатель плюс собственный запас), и без поправки кажется, будто
# tracemalloc «нашёл» лишнее.

N = 10_000
LEN = 10


def measure(make):
    tracemalloc.start()
    before = tracemalloc.get_traced_memory()[0]
    keep = [make() for _ in range(N)]
    after = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    del keep
    return (after - before) / N


# Контроль: во что обходится сам батч, если объекты бесплатны.
# None — синглтон, память под него не выделяется.
overhead = measure(lambda: None)

rows = [
    ("список через включение", lambda: [i for i in range(LEN)], [i for i in range(LEN)]),
    ("список через list(range)", lambda: list(range(LEN)), list(range(LEN))),
    ("кортеж", lambda: tuple(range(LEN)), tuple(range(LEN))),
]

print(f"  {N:,} объектов по {LEN} элементов".replace(",", " "))
print(f"  Список, который держит батч, стоит {overhead:.2f} байта на объект — он вычтен.")
print()
print(f"    {'что мерим':<26} {'tracemalloc':>12} {'getsizeof':>10} {'разница':>9}")
for label, make, sample in rows:
    traced = measure(make) - overhead
    sized = sys.getsizeof(sample)
    print(f"    {label:<26} {traced:12.1f} {sized:10} {traced - sized:9.1f}")

print()
print("  Два инструмента сходятся. Для этих объектов getsizeof не занижает")
print("  ничего: массив списка вместе с запасом он считает целиком.")
print()
print("  Занижает он другое — то, на что объект ССЫЛАЕТСЯ. Здесь этого не")
print("  видно, потому что элементы общие: малые целые числа — синглтоны, и")
print("  списку с кортежем принадлежат не они, а только указатели на них.")
print("  Проверка: батч из списков с непустыми строками, которые создаются")
print("  заново каждый раз, — вот там расхождение и появляется.")

fresh = measure(lambda: [f"s{i}" * 3 for i in range(LEN)]) - overhead
fresh_sample = [f"s{i}" * 3 for i in range(LEN)]
print()
print(f"    {'список свежих строк':<26} {fresh:12.1f} {sys.getsizeof(fresh_sample):10} "
      f"{fresh - sys.getsizeof(fresh_sample):9.1f}")
print("    ^ вот здесь getsizeof и занижает: строки в него не входят.")