Deep Engineering

ЗАМЕР

bench/lists/shrink.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 его числа зависят от состояния аллокатора, так что от прогона к прогону меняются — там утверждается только то, что оба исхода встречаются.

Скрипт

124 строк
"""Отдаёт ли список ёмкость обратно — и одинаково ли это делают `pop` и `del`.

ЗАЧЕМ ЭТОТ СКРИПТ. «Список никогда не отдаёт память» — утверждение, которое
повторяют, не проверяя. Оно неверно: `pop` уменьшает ёмкость, когда длина
падает ниже половины. Но верна его половина, и ровно та, которую не называют:
начиная с 3.13 `del L[i]` ёмкость не трогает вовсе. Две операции, которые
в документации описаны как одно и то же действие, оставляют объект в разном
состоянии.

ЧТО ИМЕННО СРАВНИВАЕТСЯ. Один и тот же список из тысячи элементов, из которого
девятьсот элементов удалены двумя способами: `L.pop(0)` и `del L[0]`.
Печатается ёмкость после. Оба способа дают одинаковую длину — разница только в
запасе, и её видно по `sys.getsizeof`.

ПОЧЕМУ ЭТО НЕ ПРО ВРЕМЯ. Здесь меряются байты. Разницу во времени между этими
же двумя операциями меряет `ops.py`, и там она измерена внутри одного запуска
одного интерпретатора — единственный способ, которым её вообще можно
утверждать.

ГДЕ ЗАПИСАНЫ ГРАНИЦЫ ЭТОГО ВЫВОДА. Расхождение `del` и `pop` — поведение
конкретных сборок, а не правило языка, поэтому в `runs/` лежат записи пяти
сборок: 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t и 3.14.7t. Первая
строка каждой записи говорит, включён ли GIL: без этого «проверено на 3.13» не
значит ничего — у свободнопоточной сборки в `Objects/listobject.c` свои пути
выделения.

ЗАПУСК:

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

import platform
import struct
import sys

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


def allocated(seq: list) -> int:
    return (sys.getsizeof(seq) - EMPTY) // PTR


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(f"Указатель: {PTR} Б (struct.calcsize('P'))")
    print("\nЗдесь меряются байты, а не время: ёмкость — это раскладка объекта.")

    head(1, "СПИСОК ОТДАЁТ ЁМКОСТЬ, КОГДА ДЛИНА ПАДАЕТ НИЖЕ ПОЛОВИНЫ")
    seq = list(range(1000))
    print(f"  начало:           длина {len(seq)}, ёмкость {allocated(seq)}")
    prev = allocated(seq)
    drops = []
    while seq:
        seq.pop()
        cap = allocated(seq)
        if cap != prev:
            drops.append((len(seq), cap))
            prev = cap
    print("  первые шесть уменьшений ёмкости при pop() с конца:")
    for n, cap in drops[:6]:
        print(f"    длина стала {n:<5d} ёмкость стала {cap}")
    print(f"  всего уменьшений за тысячу pop(): {len(drops)}")

    head(2, "ДЕВЯТЬСОТ УДАЛЕНИЙ ИЗ НАЧАЛА: ДВА СПОСОБА, ОДНА ДЛИНА")
    by_pop = list(range(1000))
    for _ in range(900):
        by_pop.pop(0)
    by_del = list(range(1000))
    for _ in range(900):
        del by_del[0]
    print("  способ           длина  ёмкость  getsizeof")
    print(f"  L.pop(0)        {len(by_pop):>6d} {allocated(by_pop):>8d} "
          f"{sys.getsizeof(by_pop):>10d} Б")
    print(f"  del L[0]        {len(by_del):>6d} {allocated(by_del):>8d} "
          f"{sys.getsizeof(by_del):>10d} Б")
    same = allocated(by_pop) == allocated(by_del)
    print(f"  ёмкость совпала: {'да' if same else 'НЕТ'}")
    if not same:
        print(f"  разница в байтах на список: {sys.getsizeof(by_del) - sys.getsizeof(by_pop)}")

    head(3, "ТО ЖЕ САМОЕ ИЗ СЕРЕДИНЫ И С КОНЦА")
    cases = [
        ("L.pop(0)", lambda L: L.pop(0)),
        ("del L[0]", lambda L: L.__delitem__(0)),
        ("L.pop(len(L)//2)", lambda L: L.pop(len(L) // 2)),
        ("del L[len(L)//2]", lambda L: L.__delitem__(len(L) // 2)),
        ("L.pop()", lambda L: L.pop()),
        ("del L[-1]", lambda L: L.__delitem__(-1)),
    ]
    print("  операция, повторённая 900 раз над списком из 1000:")
    print("  операция             длина  ёмкость")
    for name, op in cases:
        seq = list(range(1000))
        for _ in range(900):
            op(seq)
        print(f"  {name:<20s} {len(seq):>5d} {allocated(seq):>8d}")

    head(4, "ЧТО ОСВОБОЖДАЕТ ЁМКОСТЬ НАВЕРНЯКА")
    seq = list(range(1000))
    for _ in range(900):
        del seq[0]
    print(f"  после 900 × del L[0]:        длина {len(seq)}, ёмкость {allocated(seq)}")
    seq2 = seq.copy()
    print(f"  L.copy() от него:            длина {len(seq2)}, ёмкость {allocated(seq2)}")
    seq3 = list(seq)
    print(f"  list(L) от него:             длина {len(seq3)}, ёмкость {allocated(seq3)}")
    seq4 = seq[:]
    print(f"  L[:] от него:                длина {len(seq4)}, ёмкость {allocated(seq4)}")
    seq.append(0)
    seq.pop()
    print(f"  append + pop над ним:        длина {len(seq)}, ёмкость {allocated(seq)}")


main()