Deep Engineering

ЗАМЕР

bench/lists/growth.py

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

Цитируется в статье
/ru/python/data-structures/list-internals
Как запустить
Записи прогонов — в `runs/`. У `growth.py` и `shrink.py` записи на ПЯТИ
сборках — 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t: там
меряются байты и ёмкость, то есть раскладка объекта, а её между версиями
сравнивать можно и нужно — половина смысла именно в сравнении. Свободнопоточные
сборки в списке не для полноты: в `Objects/listobject.c` у них свои пути
выделения, и «проверено на 3.13» без указания сборки не значит ничего.
У `ops.py` запись одна: там меряется время, а время между версиями не
сравнивается вовсе (корневой `bench/README.md`), и второй файл провоцировал бы
ровно такое сравнение.

## Как читается ёмкость

`list.__sizeof__` в CPython считает размер по `allocated`, а не по длине:

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

Замеры: список изнутри — ёмкость, сжатие и цена работы с началом

У списка два числа длины. len(L) знают все; второе — allocated, число уже выделенных мест под указатели — из Python не видно ни одним публичным именем, и почти всё поведение списка объясняется именно им.

скрипт что показывает
growth.py ёмкость против длины; формула роста и её проверка на каждом скачке; почему одинаковые с виду способы построить список дают разный запас; переезжает ли массив при росте
shrink.py список отдаёт ёмкость при pop, но с 3.13 не отдаёт при del; что освобождает её наверняка
ops.py время: наполнение и опустошение с обоих концов, квадратичность insert(0), pop(0) против del L[0], цена индекса у deque

Запуск:

for v in 3.12 3.13 3.14 3.13t 3.14t; do echo "== $v"; python$v bench/lists/growth.py; done
for v in 3.12 3.13 3.14 3.13t 3.14t; do echo "== $v"; python$v bench/lists/shrink.py; done
python3.13 bench/lists/ops.py

Записи прогонов — в runs/. У growth.py и shrink.py записи на ПЯТИ сборках — 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t: там меряются байты и ёмкость, то есть раскладка объекта, а её между версиями сравнивать можно и нужно — половина смысла именно в сравнении. Свободнопоточные сборки в списке не для полноты: в Objects/listobject.c у них свои пути выделения, и «проверено на 3.13» без указания сборки не значит ничего. У ops.py запись одна: там меряется время, а время между версиями не сравнивается вовсе (корневой bench/README.md), и второй файл провоцировал бы ровно такое сравнение.

Как читается ёмкость

list.__sizeof__ в CPython считает размер по allocated, а не по длине:

list___sizeof___impl(PyListObject *self)
{
    size_t res = _PyObject_SIZE(Py_TYPE(self));
    Py_ssize_t allocated = FT_ATOMIC_LOAD_SSIZE_RELAXED(self->allocated);
    res += (size_t)allocated * sizeof(void*);
    return PyLong_FromSize_t(res);
}

Отсюда способ, которым ёмкость читается в обоих скриптах:

allocated = (sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P")

Делитель — размер указателя, а не восьмёрка: на 64-битной сборке это 8, но писать 8 значит зашивать разрядность в формулу.

Способ не требует ctypes и потому работает в любой сборке. Блок 1 в growth.py печатает рядом и ctypes-чтение того же поля, и СРАВНИВАЕТ два числа, а не объявляет их совпавшими: на сборках с GIL они сходятся, а на свободнопоточных ob_size по тому же смещению читается нулём — раскладка объекта там другая. Это ровно та причина, по которой во всех замерах ёмкость берётся из getsizeof.

Сам способ — деталь реализации CPython, а не обещание языка: list.__sizeof__ считает по allocated потому, что так написано в Objects/listobject.c.

Что нашлось и чего в «Что нового» нет

del L[i] перестал менять ёмкость начиная с 3.13. Девятьсот удалений из начала списка в тысячу элементов оставляют ёмкость 184 на 3.12 и 1000 на 3.13 и 3.14 — то есть список из ста элементов продолжает занимать место под тысячу. L.pop(0) в тех же условиях отдаёт ёмкость на всех пяти сборках.

Вывод shrink.py на 3.13.7, 3.14.7, 3.13.7t и 3.14.7t совпадает целиком; от них отличается только 3.12.3. То есть расхождение del и pop — не особенность сборки с GIL: свободнопоточные ведут себя так же. И всё же это поведение конкретных сборок, а не обещание языка: ни одна версия не говорит, когда список отдаёт ёмкость.

Причина видна в исходнике и не требует догадок. В 3.12 удаление по индексу уходит в общий путь присваивания срезу:

static int
list_ass_item(PyListObject *a, Py_ssize_t i, PyObject *v)
{
    ...
    if (v == NULL)
        return list_ass_slice(a, i, i+1, v);

а list_ass_slice после memmove хвоста вызывает list_resize, и тот при падении длины ниже половины ёмкость уменьшает. В 3.13 у этой функции появился вариант под блокировкой, и удаление он делает сам:

static int
list_ass_item_lock_held(PyListObject *a, Py_ssize_t i, PyObject *v)
{
    ...
    PyObject *tmp = a->ob_item[i];
    if (v == NULL) {
        Py_ssize_t size = Py_SIZE(a);
        for (Py_ssize_t idx = i; idx < size - 1; idx++) {
            FT_ATOMIC_STORE_PTR_RELAXED(a->ob_item[idx], a->ob_item[idx + 1]);
        }
        Py_SET_SIZE(a, size - 1);
    }

list_resize здесь не вызывается вовсе — длина уменьшается на месте. То же изменение объясняет и разницу во времени, которую печатает ops.py: внутри одного запуска 3.13.7 L.pop(0) дороже del L[0] в семь раз (×7,0 в записи прогона).

Чего этим замером утверждать нельзя. Что «del стал быстрее» — это сравнение времени между версиями, и оно в проекте запрещено (корневой bench/README.md). Утверждать можно ровно то, что измерено внутри одного запуска: на 3.13.7 две операции, делающие по смыслу одно и то же, стоят по-разному и оставляют объект в разном состоянии.

Формула роста не менялась

Блоки 2–5 growth.py — ёмкость, формула, запас по форме построения — совпадают целиком на всех пяти сборках. Формула

new_allocated = (n + (n >> 3) + 6) & ~3

совпала на всех 24 скачках ёмкости на первых шестистах append на каждой из пяти сборок.

Целиком файлы runs/growth*.txt при этом НЕ совпадают, и это нарочно. Блок 1 на свободнопоточных сборках честно сообщает, что ctypes-чтение ob_size там не сходится с len. Блок 6 считает переезды массива по адресу ob_item, и на свободнопоточных сборках он не делается вовсе; а на сборках с GIL его числа зависят от состояния аллокатора, так что от прогона к прогону меняются — там утверждается только то, что оба исхода встречаются.

Скрипт

202 строк
"""Ёмкость списка: по какой формуле она растёт и от чего зависит запас.

ЗАЧЕМ ЭТОТ СКРИПТ. У списка два числа длины, и путают их постоянно: `len(L)` —
сколько элементов сейчас, `allocated` — сколько мест под указатели уже
выделено. Второе из Python не видно ни одним публичным именем, и именно оно
объясняет, почему `append` в среднем дёшев, почему один и тот же список из
десяти элементов занимает разное место в зависимости от того, как его
построили, и почему `sys.getsizeof` меняется скачками, а не по одному.

КАК ЧИТАЕТСЯ `allocated` БЕЗ ctypes. `list.__sizeof__` в CPython считает
`_PyObject_SIZE(Py_TYPE(self)) + allocated * sizeof(void*)` — то есть по
`allocated`, а НЕ по длине. Значит,

    allocated = (sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P")

Делитель — размер указателя, а не восьмёрка: на 64-битной сборке это 8, но
писать 8 значит зашивать разрядность в формулу. Способ опирается только на
публичный `getsizeof`, поэтому работает и в свободнопоточной сборке, где
ctypes-адресация полей объекта уже неверна: блок 1 печатает рядом ctypes-чтение
и СРАВНИВАЕТ два числа, а не объявляет, что они совпали. На сборке без GIL
ob_size по этому смещению читается как 0 — это и видно в записи прогона.

Сам способ — деталь реализации CPython: `list.__sizeof__` считает по
`allocated` потому, что так написано в `Objects/listobject.c`, а не потому, что
это обещано языком.

ЧТО ЗДЕСЬ НЕ МЕРЯЕТСЯ. Время. Байты и ёмкость — раскладка объекта, их между
версиями сравнивать можно; время между версиями не сравнивается вовсе
(bench/README.md), и его меряет `ops.py` отдельно.

ЗАПУСК:

    for v in 3.12 3.13 3.14; do python$v bench/lists/growth.py; done
"""

import ctypes
import platform
import struct
import sys

PTR = struct.calcsize("P")
EMPTY = sys.getsizeof([])


def allocated(seq: list) -> int:
    """Ёмкость списка — из getsizeof, потому что getsizeof считает именно её."""
    return (sys.getsizeof(seq) - EMPTY) // PTR


def allocated_ctypes(seq: list) -> int:
    """То же число, прочитанное из поля структуры. Только для сверки."""
    # PyObject_VAR_HEAD: ob_refcnt, ob_type, ob_size; затем ob_item, allocated.
    return ctypes.c_ssize_t.from_address(id(seq) + PTR * 4).value


def ob_size_ctypes(seq: list) -> int:
    return ctypes.c_ssize_t.from_address(id(seq) + PTR * 2).value


def item_ptr(seq: list) -> int:
    """Адрес самого массива указателей — поле ob_item. Только на сборках с GIL."""
    return ctypes.c_ssize_t.from_address(id(seq) + PTR * 3).value


def head(n: int, title: str) -> None:
    line = f"{n}. {title}"
    print(f"\n{line}\n{'-' * len(line)}")


def main() -> None:
    gil = getattr(sys, "_is_gil_enabled", lambda: True)()
    print(f"Python {platform.python_version()} · GIL {'включён' if gil else 'выключен'}")
    print(f"Сборка: {sys.version.split('[')[-1].rstrip('] ')}")
    print("\nЗдесь меряются байты и ёмкость — раскладка объекта, а не время.")
    print("Числа этого файла сравнимы между версиями; время — нет и не меряется.")

    head(1, "ДЛИНА И ЁМКОСТЬ — ЭТО РАЗНЫЕ ЧИСЛА")
    print(f"  размер указателя: struct.calcsize('P') = {PTR} Б")
    print(f"  sys.getsizeof([])                        {EMPTY} Б")
    print("  выражение            len  ёмкость  getsizeof  ctypes: ob_size/ёмкость")
    cap_agree = size_agree = True
    for expr in ("[]", "[0]", "[0]*5", "[0]*100"):
        seq = eval(expr)  # noqa: S307 — выражения свои, не из ввода
        if allocated_ctypes(seq) != allocated(seq):
            cap_agree = False
        if ob_size_ctypes(seq) != len(seq):
            size_agree = False
        print(
            f"  {expr:<18s} {len(seq):>5d} {allocated(seq):>8d} "
            f"{sys.getsizeof(seq):>10d}  {ob_size_ctypes(seq)}/{allocated_ctypes(seq)}"
        )
    # НЕ «совпало», а «вот что вышло». Раньше здесь стояла фраза о совпадении,
    # напечатанная безусловно, — и на сборке без GIL она была прямо неверна:
    # смещения полей там другие, ob_size по нашему читается нулём.
    print(f"\n  ёмкость: getsizeof и ctypes {'совпали' if cap_agree else 'РАСХОДЯТСЯ'}")
    print(f"  ob_size из ctypes {'совпал' if size_agree else 'РАСХОДИТСЯ'} с len")
    if not (cap_agree and size_agree):
        print("  Так и должно быть в этой сборке: ctypes читает поля по смещениям,")
        print("  а раскладка объекта здесь другая. Именно поэтому ёмкость дальше")
        print("  берётся из getsizeof — он не зависит от раскладки.")
    else:
        print("  Два независимых способа дали одно и то же. Дальше ёмкость берётся")
        print("  из getsizeof: он один работает во всех сборках.")

    head(2, "ЧТО ДЕЛАЕТ append: ЁМКОСТЬ РАСТЁТ СКАЧКАМИ")
    seq: list[int] = []
    prev = -1
    jumps = []
    for i in range(600):
        seq.append(i)
        cap = allocated(seq)
        if cap != prev:
            jumps.append((len(seq), cap))
            prev = cap
    print("  скачок происходит на длине, ёмкость становится равной:")
    print("    длина:  " + " ".join(f"{n:>5d}" for n, _ in jumps[:14]))
    print("    ёмкость:" + " ".join(f"{c:>5d}" for _, c in jumps[:14]))
    print(f"  всего скачков на первых 600 append: {len(jumps)}")
    print(f"  после 600 append: длина {len(seq)}, ёмкость {allocated(seq)}, "
          f"запас {allocated(seq) - len(seq)}")

    head(3, "ФОРМУЛА РОСТА И ЕЁ ПРОВЕРКА НА КАЖДОМ СКАЧКЕ")
    print("  new_allocated = (n + (n >> 3) + 6) & ~3, где n — новая длина")
    mismatch = [(n, c) for n, c in jumps if ((n + (n >> 3) + 6) & ~3) != c]
    for n, c in jumps[:8]:
        print(f"    n={n:<5d} формула={(n + (n >> 3) + 6) & ~3:<6d} на самом деле={c}")
    print(f"  совпало на всех {len(jumps)} скачках: {'да' if not mismatch else 'НЕТ'}")
    if mismatch:
        for n, c in mismatch:
            print(f"    РАСХОЖДЕНИЕ n={n}: формула {(n + (n >> 3) + 6) & ~3}, на самом деле {c}")
    print("  запас растёт как одна восьмая длины — то есть доля, а не константа.")

    head(4, "ФОРМА ПОСТРОЕНИЯ РЕШАЕТ, БУДЕТ ЛИ ЗАПАС")
    forms = [
        ("[0,1,2,3,4,5,6,7,8,9]", "литерал"),
        ("[0]*10", "повторение"),
        ("list(range(10))", "конструктор по длине"),
        ("list(x for x in range(10))", "конструктор по генератору"),
        ("[x for x in range(10)]", "списковое включение"),
    ]
    print("  выражение                        len  ёмкость  запас  getsizeof")
    for expr, kind in forms:
        seq = eval(expr)  # noqa: S307
        print(
            f"  {expr:<32s} {len(seq):>4d} {allocated(seq):>8d} "
            f"{allocated(seq) - len(seq):>6d} {sys.getsizeof(seq):>10d}   {kind}"
        )
    print("\n  Запас есть ровно у тех двух форм, которые не знают длину заранее.")
    print("  Остальные три её знают и берут место ровно под неё.")

    head(5, "ОДИН И ТОТ ЖЕ СПИСОК, ДВА СПОСОБА ПОСТРОИТЬ")
    by_append: list[int] = []
    for i in range(1000):
        by_append.append(i)
    exact = list(range(1000))
    print(f"  тысяча append          len={len(by_append):<6d} ёмкость={allocated(by_append):<6d} "
          f"getsizeof={sys.getsizeof(by_append)} Б")
    print(f"  list(range(1000))      len={len(exact):<6d} ёмкость={allocated(exact):<6d} "
          f"getsizeof={sys.getsizeof(exact)} Б")
    diff = sys.getsizeof(by_append) - sys.getsizeof(exact)
    print(f"  разница                {diff} Б на список — это запас, а не элементы")

    head(6, "ПЕРЕЕЗЖАЕТ ЛИ МАССИВ ПРИ РОСТЕ")
    print("  Ходовое объяснение запаса: «массив нельзя удлинить на месте».")
    print("  Проверяется это адресом самого массива: ob_item до скачка ёмкости")
    print("  и после. Если адрес тот же — realloc расширил выделение на месте,")
    print("  ничего не копируя.")
    if not gil:
        print("  В этой сборке проверка не делается: ctypes читает ob_item по")
        print("  смещению, а раскладка объекта здесь другая (см. блок 1).")
    else:
        for total in (600, 20000):
            grow: list[int] = []
            prev_cap = allocated(grow)
            prev_ptr = item_ptr(grow)
            stayed = moved = 0
            for i in range(total):
                grow.append(i)
                cap = allocated(grow)
                if cap == prev_cap:
                    continue
                ptr = item_ptr(grow)
                if ptr == prev_ptr and prev_ptr != 0:
                    stayed += 1
                else:
                    moved += 1
                prev_cap, prev_ptr = cap, ptr
            print(f"  {total:>6d} append: скачков {stayed + moved:>3d}, "
                  f"массив остался на месте {stayed:>3d}, переехал {moved:>3d}")
        print("\n  Оба исхода встречаются, и на длинном списке чаще первый. То есть")
        print("  удлинить массив на месте ИНОГДА можно; чего нельзя — рассчитывать")
        print("  на это: свободного места за концом может не оказаться, и тогда")
        print("  выделение переезжает вместе со всем содержимым. Запас берётся")
        print("  затем, чтобы такие переезды случались реже, а не потому, что")
        print("  расширение невозможно.")
        print("  Как именно поделятся скачки, зависит от состояния аллокатора и")
        print("  истории процесса; утверждается здесь только то, что оба исхода")
        print("  есть, а не пропорция.")


main()