Deep Engineering

MEASUREMENT

bench/lists/ops.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/python/data-structures/list-internals
How to run it
Записи прогонов — в `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`, а не по длине:

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

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

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

Script

131 lines
"""Что стоит дорого у списка и почему — время операций в начале и в конце.

ЗАЧЕМ ЭТОТ СКРИПТ. «`insert(0)` медленный» знают все, «`append` быстрый» —
тоже. Здесь проверяется не это, а три вещи, которые из общего знания не
следуют:

1. Насколько именно медленный. Не «в разы», а как растёт с длиной: удвоение
   длины даёт вчетверо больше времени — это и есть предъявленная квадратичность,
   а не её пересказ.
2. Что `del L[0]` и `L.pop(0)` — РАЗНЫЕ по цене операции, хотя делают одно и
   то же. Разница появилась в 3.13 вместе с изменением в `list_ass_item`
   (см. `shrink.py`, там же расходится и ёмкость).
3. Чего это стоит по сравнению с `collections.deque`, у которого обе стороны
   дешёвые.

ПРАВИЛО ЗАМЕРА. Всё меряется ВНУТРИ ОДНОГО запуска ОДНОГО интерпретатора, и
сравниваются только числа из одного прогона (bench/README.md). Между версиями
время не сравнивается вовсе: у сборок песочницы разные компиляторы и разные
флаги, и разделить «стал быстрее язык» и «стала быстрее сборка» нечем. Поэтому
запись прогона здесь одна, и суффикса версии у неё нет.

МЕТОДИКА. `timeit`, лучшее из семи раундов. Берётся лучшее, а не среднее: шум
на этой машине односторонний — соседний процесс может отнять время, но не может
его добавить.

ПОЧЕМУ ОПЕРАЦИЯ МЕРЯЕТСЯ ПАРТИЕЙ. Одно `insert(0)` в список из десяти тысяч
стоит микросекунды, одно `append` — наносекунды. Один цикл `timeit` на обе не
натягивается: у первой он утонет в шуме, у второй сам цикл будет дороже
операции. Поэтому меряется «опустошить список целиком» и «наполнить список
целиком», а цена одной операции получается делением. Числа в колонке «нс на
операцию» — амортизированные, и это сказано в заголовке колонки.

ЗАПУСК:

    python3.13 bench/lists/ops.py
"""

import platform
import sys
import timeit

ROUNDS = 7
N = 10_000


def best(stmt: str, setup: str, number: int) -> float:
    """Секунды на один прогон stmt. Лучшее из ROUNDS раундов."""
    timer = timeit.Timer(stmt, setup)
    return min(timer.repeat(ROUNDS, number)) / number


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


def main() -> None:
    print(f"Python {platform.python_version()}")
    print(f"Сборка: {sys.version.split('[')[-1].rstrip('] ')}")
    print(f"\nВсе числа сняты одним запуском одного интерпретатора: сравнивать")
    print("их можно только между собой, с другими версиями — нельзя.")
    print(f"Длина списка в замерах: {N}. Лучшее из {ROUNDS} раундов.")

    head(1, "НАПОЛНИТЬ СПИСОК: С КОНЦА И С НАЧАЛА")
    fill = [
        ("L.append(i)", "L=[]\nfor i in r: L.append(i)", f"r=range({N})", 100),
        ("L.insert(0, i)", "L=[]\nfor i in r: L.insert(0,i)", f"r=range({N})", 10),
        ("d.append(i)", "d=deque()\nfor i in r: d.append(i)",
         f"from collections import deque\nr=range({N})", 100),
        ("d.appendleft(i)", "d=deque()\nfor i in r: d.appendleft(i)",
         f"from collections import deque\nr=range({N})", 100),
    ]
    print(f"  операция, повторённая {N} раз       всего, мкс   нс на операцию")
    for name, stmt, setup, number in fill:
        sec = best(stmt, setup, number)
        print(f"  {name:<32s} {sec * 1e6:>10.0f} {sec / N * 1e9:>16.1f}")
    print("\n  Работа одна и та же — добавить десять тысяч элементов. Различает")
    print("  строки только то, с какого края это делают и какая это структура.")

    head(2, "ОПУСТОШИТЬ СПИСОК: С КОНЦА, С НАЧАЛА, ДВУМЯ СПОСОБАМИ")
    drain = [
        ("L.pop()", "L=src.copy()\nwhile L: L.pop()", 50),
        ("del L[-1]", "L=src.copy()\nwhile L: del L[-1]", 50),
        ("L.pop(0)", "L=src.copy()\nwhile L: L.pop(0)", 10),
        ("del L[0]", "L=src.copy()\nwhile L: del L[0]", 10),
        ("d.pop()", "d=deque(src)\nwhile d: d.pop()", 50),
        ("d.popleft()", "d=deque(src)\nwhile d: d.popleft()", 50),
    ]
    setup = f"from collections import deque\nsrc=list(range({N}))"
    print(f"  операция, повторённая {N} раз       всего, мкс   нс на операцию")
    got = {}
    for name, stmt, number in drain:
        sec = best(stmt, setup, number)
        got[name] = sec
        print(f"  {name:<32s} {sec * 1e6:>10.0f} {sec / N * 1e9:>16.1f}")
    ratio = got["L.pop(0)"] / got["del L[0]"]
    print(f"\n  L.pop(0) / del L[0] = ×{ratio:.1f} — одна и та же работа по смыслу,")
    print("  разные пути в реализации. Что при этом происходит с ёмкостью — в shrink.py.")

    head(3, "КВАДРАТИЧНОСТЬ, ПРЕДЪЯВЛЕННАЯ, А НЕ НАЗВАННАЯ")
    print("  наполнение через insert(0): длина удваивается — время вчетверо")
    print("  длина    всего, мкс   нс на операцию   отношение к предыдущей строке")
    prev = None
    for n in (2_500, 5_000, 10_000, 20_000):
        sec = best("L=[]\nfor i in r: L.insert(0,i)", f"r=range({n})", 10)
        rel = "—" if prev is None else f{sec / prev:.2f}"
        print(f"  {n:>6d} {sec * 1e6:>12.0f} {sec / n * 1e9:>16.1f}   {rel:>10s}")
        prev = sec
    print("\n  Для сравнения — та же таблица для append, где отношение около двух:")
    print("  длина    всего, мкс   нс на операцию   отношение к предыдущей строке")
    prev = None
    for n in (2_500, 5_000, 10_000, 20_000):
        sec = best("L=[]\nfor i in r: L.append(i)", f"r=range({n})", 50)
        rel = "—" if prev is None else f{sec / prev:.2f}"
        print(f"  {n:>6d} {sec * 1e6:>12.0f} {sec / n * 1e9:>16.1f}   {rel:>10s}")
        prev = sec

    head(4, "ЧЕМ ЗА ЭТО ПЛАТИТ deque: ЦЕНА ИНДЕКСА")
    print("  Обе стороны у deque дёшевы — но индекс перестал быть постоянным.")
    setup = "from collections import deque\nL=list(range(100000))\nd=deque(L)"
    print("  выражение, повторённое 1000 раз в витке     нс на операцию")
    for expr in ("L[0]", "L[50000]", "L[99999]", "d[0]", "d[50000]", "d[99999]"):
        stmt = "; ".join([expr] * 1000)
        sec = best(stmt, setup, 200)
        print(f"  {expr:<42s} {sec / 1000 * 1e9:>10.1f}")
    print("\n  У списка три числа почти совпали, у deque середина дороже краёв в сотни раз:")
    print("  это и есть разница между массивом указателей и связкой блоков.")


main()