Списки изнутри: рост, сжатие и цена работы с началом
У списка два числа длины, и второго из Python не видно ни одним публичным именем. Из него следует всё остальное: почему одинаковые с виду списки занимают разное место, почему append дёшев, а insert(0) квадратичен, и почему начиная с 3.13 del L[0] и L.pop(0) оставляют объект в разном состоянии — при одинаковой длине в сто элементов список занимает 8056 байт вместо 1528.
Полное техническое изложение
TL;DR
У списка два числа длины. len(L) — сколько элементов; ёмкость — сколько
мест под них уже выделено. Второго из Python не видно ни одним публичным именем,
но читается из размера: (sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P").
Отсюда разный размер у одинаковых списков. Список из десяти элементов
занимает 136 байт, если построен литералом, [0] * 10 или list(range(10)), и
184 байта, если списковым включением: у включения длина заранее неизвестна, и
место берётся с запасом.
Запас — доля, а не константа. Формула (n + (n >> 3) + 6) & ~3; на первых
шестистах append массив перевыделяется двадцать четыре раза, а не шестьсот.
Поэтому append в среднем дёшев.
Список ёмкость отдаёт — но не всегда. L.pop() уменьшает её, когда длина
падает ниже половины. А del L[i] начиная с 3.13 не уменьшает вовсе: после
девятисот удалений из списка в тысячу элементов pop(0) оставляет 1528 байт,
del — 8056. Длина у обоих сто.
Работа с началом дорога и растёт с длиной. insert(0) квадратичен:
удвоение длины — вчетверо больше времени. deque делает оба конца дешёвыми, но
платит индексом: его середина дороже краёв в сотни раз.
Два числа вместо одного
Список в CPython — это объект плюс отдельный массив указателей; сами элементы
лежат где угодно. Массив при росте иногда удаётся расширить на месте, а иногда
приходится перенести целиком, и заранее неизвестно, как выйдет. Чтобы платить за
это не на каждом append, место берётся с запасом.
Запас и есть второе число. Его не показывает ни len, ни какой-либо метод
списка, зато его считает sys.getsizeof: размер списка — это заголовок плюс
один указатель на каждое ВЫДЕЛЕННОЕ место, а не на элемент.
def capacity(seq):
return (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P")Одинаковые списки, разное место
Разделяет пять способов одно: знал ли интерпретатор длину заранее. Литерал,
[0] * 10 и list(range(10)) знают — место берётся ровно под десять
элементов. Включение и генератор не знают, добавляют по одному, и работает тот
же механизм запаса, что у append: шестнадцать мест вместо десяти, 184 байта
вместо 136.
Запас берётся по формуле
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;Новая длина плюс её восьмая часть плюс шесть. Запас пропорционален длине, и
поэтому число перевыделений на длинной череде append растёт логарифмически:
на первых шестистах добавлениях их двадцать четыре.
Формула — деталь реализации CPython, в документации языка её нет. На 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t она одна и та же.
Что происходит при удалении
Из списка в тысячу элементов удаляется девятьсот, двумя способами. Длина после
одинаковая — сто. Память нет: на 3.13 и 3.14 L.pop(0) оставляет 184 места, а
del L[0] — тысячу.
Причина в том, что удаление по индексу с 3.13 делает другая функция, и
list_resize она не вызывает вовсе. То же и с конца списка: L.pop() ёмкость
отдаёт, del L[-1] — нет.
Ни одна версия при этом не обещает, когда список сжимается. Если список сильно
уменьшился и живёт дальше, надёжный способ один — пересобрать его: L.copy(),
list(L) или L[:] приводят ёмкость к длине на всех проверенных сборках.
Почему начало дорого
Вставка в начало сдвигает весь уже накопленный хвост, и цена одной такой
операции растёт вместе с длиной. Проверяется удвоением: при росте длины вдвое
время insert(0) растёт вчетверо, а время append — вдвое. Цена одного
append при этом не меняется вовсе: около двадцати наносекунд и на двух с
половиной тысячах элементов, и на двадцати тысячах.
Стандартный ответ — collections.deque, и оба его конца действительно дёшевы.
Платят за это индексом: на ста тысячах элементов d[50000] стоит 2418,3
наносекунды против 9,0 у списка. Список, из которого постоянно снимают первый
элемент, — это deque, написанный неправильно; deque, в который лезут по
индексу в середину, — список, написанный неправильно.
Что из этого делать
Строить список сразу нужной длины, если она известна. Не снимать с начала в
цикле — брать deque. Пересобирать список, который сильно уменьшился и живёт
дальше. И не полагаться ни на сжатие, ни на его отсутствие: это поведение уже
менялось один раз без единой строки в «Что нового».
У списка два числа длины, и в этом вся статья.
Первое знают все: len(L) — сколько в списке элементов. Второе не показывает
ни одно публичное имя: сколько мест под указатели уже выделено. Их обычно
называют длиной и ёмкостью, и почти всё поведение списка объясняется вторым
числом, а не первым.
Пока ёмкость невидима, объяснить нечего. Почему два списка из десяти
одинаковых чисел занимают разное место. Почему append в среднем дёшев, хотя
массив при росте иногда приходится переносить целиком. Почему после девятисот удалений из списка в тысячу
элементов он может продолжать занимать место под тысячу — и когда именно так
бывает.
Статья идёт снизу вверх: сначала два числа и как они связаны, потом что происходит при удалении, и только потом время. Время последним не потому, что оно неважно, а потому, что без первых двух частей оно читается как набор несвязанных фактов.
Часть I. Длина и ёмкость
Зачем списку запас
Список в CPython — это три уровня, и их стоит разделить сразу: сам объект
PyListObject, отдельный непрерывный массив указателей и сами объекты, которые
лежат где угодно в памяти. Из этого следует и то, что считает getsizeof
(заголовок плюс массив, без объектов), и почему сдвиг при удалении из начала
двигает указатели, а не элементы.
Удлинить массив на месте иногда можно: realloc расширяет выделение, не
переезжая, если за его концом есть свободное место. Чего нельзя — рассчитывать
на это.
Проверить это можно адресом самого массива — полем ob_item до и после. Ёмкость
меняется не на единицу, а скачками (разбор скачков ниже), и на каждом из них
прогон сравнивает два адреса: тот же — значит, ничего не копировалось. На 3.13.7
со включённым GIL (на свободнопоточных сборках эта проверка не делается —
ctypes читает поле по смещению, а раскладка объекта там другая):
6. ПЕРЕЕЗЖАЕТ ЛИ МАССИВ ПРИ РОСТЕ
---------------------------------
Ходовое объяснение запаса: «массив нельзя удлинить на месте».
Проверяется это адресом самого массива: ob_item до скачка ёмкости
и после. Если адрес тот же — realloc расширил выделение на месте,
ничего не копируя.
600 append: скачков 24, массив остался на месте 13, переехал 11
20000 append: скачков 53, массив остался на месте 41, переехал 12
Встречаются оба исхода — вот всё, что отсюда следует. Пропорция не воспроизводится: на 3.12.3 и 3.14.7 тот же прогон даёт двенадцать на двенадцать, и от запуска к запуску она тоже гуляет, потому что зависит от состояния аллокатора и истории процесса. Когда свободного места за концом нет, выделение переезжает вместе со всем содержимым, и заранее неизвестно, какой это будет раз.
Поэтому дело не в том, что расширение невозможно, а в том, СКОЛЬКО РАЗ за него
приходится платить. Без запаса ёмкость менялась бы на каждом append; с запасом
она меняется двадцать четыре раза на первые шестьсот — и копированием обходится
не каждый из этих раз.
Выход — брать место с запасом. Комментарий над функцией, которая этим занимается, говорит и зачем, и с каким результатом:
This over-allocates proportional to the list size, making room for
additional growth. The over-allocation is mild, but is enough to give
linear-time amortized behavior over a long sequence of appends() in the
presence of a poorly-performing system realloc().
Выделяется с запасом, пропорциональным размеру списка, чтобы оставить место для дальнейшего роста. Запас небольшой, но его хватает, чтобы на длинной последовательности appends() поведение было линейным в среднем даже при плохо работающем системном realloc()
«В среднем» здесь не смягчение: отдельный append, попавший на границу, стоит
дорого — он копирует весь массив. Но такие границы расставлены так, что на
длинной череде добавлений стоимость размазывается в постоянную.
Запас — это и есть второе число. Его не видно ни через len, ни через какой
угодно метод списка. Зато его видно через размер объекта, и вот почему.
Ёмкость читается из getsizeof, и это не обходной путь
list.__sizeof__ в CPython считает размер по числу выделенных мест, а не по
длине:
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);
}Отсюда способ, которым ёмкость читается во всех замерах этой статьи:
def capacity(seq):
return (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P")Делитель — размер указателя, а не восьмёрка: на 64-битной сборке это восемь, но писать восьмёрку значит зашить разрядность в формулу.
Он не требует ctypes — а обычный способ прочитать то же поле требует: он
адресуется к полям объекта по id(obj). В прогоне этот способ стоит рядом с
ctypes-чтением того же поля, и прогон их СРАВНИВАЕТ, а не объявляет
совпавшими: на сборках с GIL они сошлись, а на свободнопоточных ob_size по
тому же смещению читается нулём — раскладка объекта там другая. Именно поэтому
во всех замерах ёмкость берётся из getsizeof.
То, что getsizeof считает по allocated, — свойство CPython, а не
обещание языка. Документация функции говорит только, что учитывается
«потребление памяти, непосредственно относящееся к объекту», и что под этим
понимать, решает реализация типа.
Одинаковые списки, разное место
Пять способов построить список из десяти элементов. Длина у всех десять; размер — 136 байт у трёх и 184 у двух. Разделяет их одно: знал ли интерпретатор длину заранее.
Литерал и [0] * 10 знают её из самой записи: сколько элементов перечислено и
сколько раз повторено, известно ещё до того, как список начал заполняться. У
range для этого есть __len__, и конструктор списка им пользуется. Место
берётся ровно под десять элементов, запаса нет.
Списковое включение и list от генератора не знают: генератор о своей длине
ничего не сообщает, и элементы добавляются по одному. Значит, работает тот же
механизм запаса, что и у append, и на десяти элементах он даёт шестнадцать
мест.
184 байта против 136 — сорок восемь байт на список, шесть пустых мест по восемь.
Мелочь. Но это мелочь, умножающаяся на число
списков, и её стоит знать там, где списков миллионы: если включение только
перебирает range, замена его на list(range(...)) убирает эту мелочь
целиком, ничего не меняя в результате. Там, где включение что-то вычисляет,
такой замены нет — и тогда запас — это цена вычисления, а не небрежности.
Формула роста
Запас берётся не «примерно», а по формуле, и формула в исходнике одна:
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;Читается она так: новая длина плюс её восьмая часть плюс шесть, округлённое
вниз до кратного четырём. То есть запас — не константа, а ДОЛЯ: чем длиннее
список, тем больше берётся про запас, и именно поэтому число перевыделений на
длинной череде append растёт логарифмически, а не линейно.
Рядом с формулой в исходнике стоит вторая ветка — на случай, когда длина
вырастает сразу на много, как при extend:
if (newsize - Py_SIZE(self) > (Py_ssize_t)(new_allocated - newsize))
new_allocated = ((size_t)newsize + 3) & ~(size_t)3;Смысл её в том, что запас не берётся, если прыжок и так больше запаса. При
append длина растёт на единицу, и эта ветка не срабатывает никогда — поэтому
дальше речь только о первой формуле.
Прогон проверяет её на каждом скачке ёмкости: на первых шестистах
append их двадцать четыре, и совпали все двадцать четыре на пяти сборках:
3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t и 3.14.7t — во всех числах, до
последнего.
2. ЧТО ДЕЛАЕТ append: ЁМКОСТЬ РАСТЁТ СКАЧКАМИ
---------------------------------------------
скачок происходит на длине, ёмкость становится равной:
длина: 1 5 9 17 25 33 41 53 65 77 93 109 129 149
ёмкость: 4 8 16 24 32 40 52 64 76 92 108 128 148 172
всего скачков на первых 600 append: 24
после 600 append: длина 600, ёмкость 672, запас 72
Ровно эта последовательность выписана и в комментарии рядом с формулой:
The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...
(Последовательность роста такая: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...)
Здесь три разных утверждения, и путать их нельзя. Первое — свойство
практическое: append в среднем дёшев, и это обещано комментарием в
исходнике. Второе — как именно CPython этого добивается: вот эта формула
роста, деталь реализации, которой нет в документации языка. Третье —
наблюдение: двадцать четыре изменения ёмкости на первых
шестистах append, одинаково на пяти сборках. Опираться в коде можно только на первое.
Часть II. Что список отдаёт обратно
«Список никогда не отдаёт память» — неверно, и неверно дважды
Утверждение ходит в двух видах, и оба не выдерживают проверки. Одна оговорка вперёд: речь дальше про ЁМКОСТЬ, то есть про размер массива указателей. Вернулась ли при этом память операционной системе — вопрос не списка, а распределителя памяти: освободившийся кусок он обычно держит у себя. Сколько памяти процесс занимает по данным системы (RSS), по этим числам предсказать нельзя.
Первый вид: «ёмкость только растёт». Не только. Условие, по которому массив
ОСТАВЛЯЮТ как есть, стоит в той же list_resize, и сжатие начинается там, где
оно не выполнено:
if (allocated >= newsize && newsize >= (allocated >> 1)) {
assert(self->ob_item != NULL || newsize == 0);
Py_SET_SIZE(self, newsize);
return 0;
}Пока новая длина не меньше половины ёмкости, массив остаётся прежним и
меняется только длина. Как только длина падает ниже половины — управление
уходит дальше, и массив перевыделяется под меньший размер. Прогон это и
показывает: тысяча pop() с конца даёт двенадцать уменьшений ёмкости.
Второй вид: «раз отдаёт, то отдаёт всегда». Тоже нет — и вот здесь начинается то, ради чего статья написана.
Две операции с одним смыслом и разным следом
Из списка в тысячу элементов удаляется девятьсот — из начала, двумя способами. Длина после одинаковая: сто. Память — нет.
На 3.12 обе операции оставляют ёмкость 184, то есть обе сжали массив. На 3.13 и
3.14 L.pop(0) оставляет те же 184, а del L[0] оставляет 1000: список из ста
элементов продолжает занимать место под тысячу, 8056 байт против 1528.
То же и с другого конца — всё на том же 3.13.7, и это половина, которой не ждёшь:
3. ТО ЖЕ САМОЕ ИЗ СЕРЕДИНЫ И С КОНЦА
------------------------------------
операция, повторённая 900 раз над списком из 1000:
операция длина ёмкость
L.pop(0) 100 184
del L[0] 100 1000
L.pop(len(L)//2) 100 184
del L[len(L)//2] 100 1000
L.pop() 100 184
del L[-1] 100 1000
L.pop() ёмкость отдаёт. del L[-1] — нет. Две строки, которые в обзоре кода
выглядят одинаково безобидно.
Почему так: два разных пути в исходнике
Причина не выведена из чисел, а прочитана. До 3.13 удаление по индексу ничего не делало само, а передавало работу общему пути присваивания срезу:
static int
list_ass_item(PyListObject *a, Py_ssize_t i, PyObject *v)
{
if (!valid_index(i, Py_SIZE(a))) {
PyErr_SetString(PyExc_IndexError,
"list assignment index out of range");
return -1;
}
if (v == NULL)
return list_ass_slice(a, i, i+1, v);list_ass_slice сдвигает хвост и вызывает 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 здесь не вызывается вовсе: длина уменьшается на месте, массив
остаётся какой был. Ни list_ass_slice, ни list_resize на этом пути больше
не встречаются.
Изменение пришло вместе со свободнопоточной сборкой — отсюда и атомарные записи,
и суффикс _lock_held в имени: функция рассчитывает, что список уже заблокирован
тем, кто её позвал. Поведение ёмкости при этом побочный эффект, а не цель, и
это видно по тому, что в «Что нового в Python 3.13» о нём нет ни строки: ни в
разделе про изменения языка, ни в разделе про оптимизации.
Всё это — устройство CPython, а не правило языка. Ни одна версия не обещает,
когда список сжимается: обещано только то, что del L[i] уберёт элемент.
Писать код, который рассчитывает на сжатие после pop, так же неверно, как
рассчитывать на его отсутствие после del.
Что освобождает ёмкость наверняка
Раз на поведение операции полагаться нельзя, полезно знать, что работает
независимо от версии. Прогон перебирает варианты над списком, у которого после
девятисот del ёмкость осталась тысячей:
4. ЧТО ОСВОБОЖДАЕТ ЁМКОСТЬ НАВЕРНЯКА
------------------------------------
после 900 × del L[0]: длина 100, ёмкость 1000
L.copy() от него: длина 100, ёмкость 100
list(L) от него: длина 100, ёмкость 100
L[:] от него: длина 100, ёмкость 100
append + pop над ним: длина 100, ёмкость 116
Три первых способа — это одно и то же действие: построить новый список, которому длина известна заранее. Место берётся ровно под сто элементов, а старый массив освобождается вместе со старым списком.
Последняя строка — не рецепт, а предупреждение: append с последующим pop
ёмкость меняет, но приводит её не к длине, а к тому, что даст формула. Сто
шестнадцать вместо ста — это снова запас.
Практическое правило одно: если список сильно уменьшился и должен жить дальше,
его надо ПЕРЕСОБРАТЬ. L = L.copy() — не суеверие: пересборка любым из трёх
способов отвечает одинаково на всех проверенных сборках, а операции удаления —
нет.
Часть III. Цена работы с началом
Квадратичность, предъявленная, а не названная
«insert(0) квадратичен» — верно, но само по себе это слова. Проверяется оно
удвоением: если время растёт как квадрат, удвоение длины даёт вчетверо больше
времени.
3. КВАДРАТИЧНОСТЬ, ПРЕДЪЯВЛЕННАЯ, А НЕ НАЗВАННАЯ
------------------------------------------------
наполнение через insert(0): длина удваивается — время вчетверо
длина всего, мкс нс на операцию отношение к предыдущей строке
2500 583 233.4 —
5000 2129 425.8 ×3.65
10000 9066 906.6 ×4.26
20000 36773 1838.7 ×4.06
Для сравнения — та же таблица для append, где отношение около двух:
длина всего, мкс нс на операцию отношение к предыдущей строке
2500 49 19.7 —
5000 101 20.3 ×2.06
10000 201 20.1 ×1.98
20000 441 22.0 ×2.19
Правая колонка и есть вся разница. У append цена одной операции постоянна:
около двадцати наносекунд и на двух с половиной тысячах элементов, и на
двадцати тысячах. У insert(0) она растёт вместе с длиной, потому что каждая вставка
сдвигает всё, что уже есть.
Все числа этого раздела сняты одним запуском 3.13.7 и сравниваются только между собой. Абсолютные значения — свойство машины замера (Intel Xeon 2,80 ГГц, 2 vCPU), а не языка; воспроизводится и переносится на другое железо отношение, а не наносекунда.
pop(0) против del L[0]: та же работа, разная цена
Изменение из части II видно не только в байтах. Внутри одного запуска 3.13.7:
2. ОПУСТОШИТЬ СПИСОК: С КОНЦА, С НАЧАЛА, ДВУМЯ СПОСОБАМИ
--------------------------------------------------------
операция, повторённая 10000 раз всего, мкс нс на операцию
L.pop() 236 23.6
del L[-1] 213 21.3
L.pop(0) 120766 12076.6
del L[0] 17342 1734.2
d.pop() 272 27.2
d.popleft() 269 26.9
L.pop(0) / del L[0] = ×7.0 — одна и та же работа по смыслу,
разные пути в реализации. Что при этом происходит с ёмкостью — в shrink.py.
L.pop(0) дороже del L[0] в семь раз. Обе операции удаляют первый
элемент и обе сдвигают хвост; различает их всё тот же путь в реализации —
общий путь присваивания срезу против цикла на месте.
Обратите внимание на первые две строки той же таблицы: с КОНЦА списка те же два пути стоят 23,6 и 21,3 наносекунды, то есть различаются на проценты. У конца сдвигать нечего, и видно только накладные расходы вызова — они у обоих путей почти одинаковы. Семикратная разница возникает там, где на каждый вызов приходится сдвиг всего хвоста: различают эти два пути не проверки перед работой, а то, что каждый из них делает с хвостом.
Чего этим числом утверждать нельзя — что вся семикратная разница приходится на
list_resize. Пути расходятся не в одном месте: pop ещё и ВОЗВРАЩАЕТ снятый
элемент, то есть отдаёт наружу ссылку и следит за её временем жизни, а del
ничего не возвращает. Что из этого сколько стоит, замер не раскладывает: он
показывает суммарную цену двух разных путей, а list_resize на одном из них —
названная по исходнику причина различия, а не измеренное слагаемое.
И второе, чего этим числом утверждать нельзя, — что «del стал быстрее». Это было бы
сравнением времени между версиями, а оно здесь ничего не значит: у сборок разные
компиляторы и разные флаги, и разделить «стал быстрее язык» и «стала быстрее
сборка» нечем. Утверждать можно ровно то, что измерено внутри одного запуска:
на 3.13.7 две операции с одинаковым смыслом стоят по-разному и оставляют объект
в разном состоянии.
Чем за это платит deque
Стандартный ответ на «нужно работать с обоими концами» — collections.deque, и
обещание там официальное:
Deques support thread-safe, memory efficient appends and pops from either
side of the deque with approximately the same O(1) performance in either
direction
Двусторонние очереди поддерживают потокобезопасные и экономные по памяти добавления и снятия с любой стороны с примерно одинаковой производительностью O(1) в обе стороны
Замер это подтверждает: у deque appendleft стоит 29,4 наносекунды против
29,3 у d.append, а popleft — 26,9 против 27,2 у d.pop. Против девятисот
наносекунд на insert(0) это другой класс.
Но в той же документации стоит и вторая половина:
Indexed access is O(1) at both ends but slows to O(n) in the middle
(Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине). Вот во что это обходится на списке в сто тысяч элементов:
4. ЧЕМ ЗА ЭТО ПЛАТИТ deque: ЦЕНА ИНДЕКСА
----------------------------------------
Обе стороны у deque дёшевы — но индекс перестал быть постоянным.
выражение, повторённое 1000 раз в витке нс на операцию
L[0] 7.5
L[50000] 9.0
L[99999] 9.1
d[0] 11.6
d[50000] 2418.3
d[99999] 18.1
У списка три числа почти совпали — индекс не зависит от позиции. У deque
середина дороже краёв в сотни раз: это не список с двумя дешёвыми концами, а
связка БЛОКОВ — не связный список из отдельных элементов и не массив, а
цепочка кусков по нескольку элементов, до середины которой надо дойти.
Отсюда практический критерий, и он не про «что быстрее», а про то, ЧТО ВЫ
ДЕЛАЕТЕ с контейнером. Нужен доступ по произвольному индексу — список, и тогда
работа с началом обойдётся дорого. Нужны оба конца и обход подряд — deque, и
тогда дорого обойдётся индекс. Список, из которого постоянно снимают первый
элемент, — это deque, написанный неправильно.
Часть IV. Практика
Практика · что напечатает
import struct
import sys
def capacity(seq):
return (sys.getsizeof(seq) - sys.getsizeof([])) // struct.calcsize("P")
a = list(range(1000))
b = list(range(1000))
for _ in range(900):
a.pop(0)
for _ in range(900):
del b[0]
print(len(a), capacity(a))
print(len(b), capacity(b))Практика · оцените
Часть V. Что с этим делать
Четыре правила, которые следуют из замеров
Строить список сразу нужной длины, если она известна. list(range(n)),
литерал, [0] * n берут место ровно под элементы; включение и генератор — с
запасом. На одном списке это сорок восемь байт, на миллионе — сорок восемь
мегабайт.
Не работать с началом списка в цикле. Цена одной такой операции растёт
вместе с длиной: на десяти тысячах элементов вставка в начало стоит девятьсот
наносекунд против двадцати у append, а снятие с начала — 1734,2 наносекунды
через del L[0] и 12 076,6 через L.pop(0). Если нужны оба конца — collections.deque; если нужен ещё
и индекс в середину — значит, нужен другой алгоритм, а не другой контейнер.
Пересобирать список, который сильно уменьшился и живёт дальше. L.copy()
приводит ёмкость к длине на всех пяти проверенных сборках, а del не приводит её ни на одной из
четырёх сборок от 3.13 и выше; на 3.12.3 он её ещё уменьшал. И отдельно: список отдаёт ЁМКОСТЬ, а не память
операционной системе — освободившийся кусок распределитель памяти обычно
держит у себя, так что занятая процессом память (RSS) после пересборки может не
измениться.
Не полагаться ни на сжатие, ни на его отсутствие. Ни одна версия не обещает, когда список отдаёт ёмкость. То, что здесь измерено, — поведение пяти конкретных сборок, и оно уже менялось один раз без единой строки в «Что нового».
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.12 | Удаление по индексу идёт через list_ass_slice, который вызывает list_resize. del L[i] и L.pop(i) оставляют объект в ОДИНАКОВОМ состоянии: после девятисот удалений из списка в тысячу ёмкость 184 у обоих. | |
| 3.13 | Появляется list_ass_item_lock_held, и list_resize на пути удаления по индексу больше не вызывается. del L[i] перестаёт уменьшать ёмкость вовсе — с любого конца, — а L.pop(i) продолжает её уменьшать. В «Что нового» об этом нет ни строки. | |
| 3.14 | Поведение то же, что в 3.13: ёмкость после del не уменьшается — и так же в свободнопоточных сборках 3.13.7t и 3.14.7t, где у списка свои пути выделения. Формула роста не менялась ни разу — прогон bench/lists/growth.py даёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на всех пяти сборках. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.
Байты и ёмкость — сравнимы между версиями, записи на всех пяти сборках, включая две свободнопоточных:
Время — одна запись, одна сборка:
Опора под задачи раздела «Практика»:
Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3) и свободнопоточные 3.13.7t, 3.14.7t; Intel Xeon 2,80 ГГц, 2 vCPU, 64 бита. Ёмкость и байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги, и разделить «стал быстрее язык» и «стала быстрее сборка» этими замерами нельзя. Время снято только на 3.13.7 со включённым GIL.
Фрагменты Objects/listobject.c приводятся по тегам v3.12.3 и v3.13.7
дословно.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
На самом деле
- Ёмкость отдаёт, и условие записано в
list_resizeодной строкой:if (allocated >= newsize && newsize >= (allocated >> 1))— пока длина не упала ниже половины ёмкости, массив остаётся прежним, а как только упала, он перевыделяется под меньший размер. Тысячаpop()с конца списка в тысячу элементов даёт двенадцать таких уменьшений. Неверна не только эта формулировка, но и обратная: начиная с 3.13del L[i]ёмкость не уменьшает вовсе. И отдельно: уменьшение ёмкости — это не возврат памяти операционной системе, аллокатор обычно держит освободившийся кусок у себя. - На 3.12 действительно одно и то же. На 3.13 и 3.14 — нет: после девятисот удалений из списка в тысячу элементов
pop(0)оставляет ёмкость 184, аdel— 1000, то есть 1528 байт против 8056. Длина в обоих случаях сто, и по ней разницы не видно. Причина в том, что удаление по индексу перестало проходить черезlist_ass_slice: с 3.13 его делаетlist_ass_item_lock_held, гдеlist_resizeне вызывается. То же расхождение и во времени внутри одного запуска 3.13.7:pop(0)дорожеdel L[0]в семь раз. - Показывает размер массива указателей по числу ВЫДЕЛЕННЫХ мест, а не по длине, и не включает сами объекты, на которые указатели ведут. Документация функции говорит это прямо: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to. Отсюда и способ прочитать ёмкость, которой нет публичного имени:
(sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P"). - Только если построены способом, который знает длину заранее. Список из десяти элементов занимает 136 байт, если это литерал,
[0] * 10илиlist(range(10)), и 184 байта, если это списковое включение илиlistот генератора: у последних двух длина заранее неизвестна, элементы добавляются по одному, и место берётся с запасом — шестнадцать мест вместо десяти. - Иногда его удлиняют на месте:
reallocрасширяет выделение, если за концом есть свободное место, — на 3.13.7 с GIL прогон насчитал 13 таких скачков ёмкости из 24 на первых шестистахappend, а на 3.12.3 и 3.14.7 — 12 из 24; пропорция зависит от состояния аллокатора, воспроизводится только то, что встречаются оба исхода. Но рассчитывать на это нельзя, и когда места нет, массив переезжает целиком. Дёшевappendпотому, что место берётся с запасом по формуле(n + (n >> 3) + 6) & ~3, то есть запас пропорционален длине. На первых шестистахappendмассив перевыделяется двадцать четыре раза, а не шестьсот. Комментарий в исходнике называет это прямо: запас нужен, чтобы дать linear-time amortized behavior over a long sequence of appends(). - Это другая структура с другой платой. Оба конца действительно дёшевы —
appendleftстоит столько же, сколькоappend. Но индекс перестаёт быть постоянным: на ста тысячах элементовd[50000]стоит 2418,3 наносекунды против 9,0 уL[50000], то есть в сотни раз дороже. Документация обещает и то и другое сразу: Indexed access is O(1) at both ends but slows to O(n) in the middle. - Нельзя. В документации языка её нет вовсе; она записана комментарием в
Objects/listobject.cи остаётся деталью реализации CPython. То, что она не менялась между 3.12 и 3.14, — наблюдение из пяти прогонов, а не обещание: выводbench/lists/growth.pyдаёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t. Опираться можно на то, чтоappendв среднем дёшев; на конкретные числа ёмкости — нет.
По версиям
- 3.12
- Удаление по индексу идёт через
list_ass_slice, который вызываетlist_resize.del L[i]иL.pop(i)оставляют объект в ОДИНАКОВОМ состоянии: после девятисот удалений из списка в тысячу ёмкость 184 у обоих.< - 3.13
- Появляется
list_ass_item_lock_held, иlist_resizeна пути удаления по индексу больше не вызывается.del L[i]перестаёт уменьшать ёмкость вовсе — с любого конца, — аL.pop(i)продолжает её уменьшать. В «Что нового» об этом нет ни строки.< - 3.14
- Поведение то же, что в 3.13: ёмкость после
delне уменьшается — и так же в свободнопоточных сборках 3.13.7t и 3.14.7t, где у списка свои пути выделения. Формула роста не менялась ни разу — прогонbench/lists/growth.pyдаёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на всех пяти сборках.<
Что разобрано
- Часть I. Длина и ёмкость
- Зачем списку запас
- Ёмкость читается из `getsizeof`, и это не обходной путь
- Одинаковые списки, разное место
- Формула роста
- Часть II. Что список отдаёт обратно
- «Список никогда не отдаёт память» — неверно, и неверно дважды
- Две операции с одним смыслом и разным следом
- Почему так: два разных пути в исходнике
- Что освобождает ёмкость наверняка
- Часть III. Цена работы с началом
- Квадратичность, предъявленная, а не названная
- `pop(0)` против `del L[0]`: та же работа, разная цена
- Чем за это платит `deque`
- Часть IV. Практика
- Часть V. Что с этим делать
- Четыре правила, которые следуют из замеров
- История версий
- Чем измерено
Частые заблуждения
Список никогда не отдаёт память обратно
Ёмкость отдаёт, и условие записано в list_resize одной строкой: if (allocated >= newsize && newsize >= (allocated >> 1)) — пока длина не упала ниже половины ёмкости, массив остаётся прежним, а как только упала, он перевыделяется под меньший размер. Тысяча pop() с конца списка в тысячу элементов даёт двенадцать таких уменьшений. Неверна не только эта формулировка, но и обратная: начиная с 3.13 del L[i] ёмкость не уменьшает вовсе. И отдельно: уменьшение ёмкости — это не возврат памяти операционной системе, аллокатор обычно держит освободившийся кусок у себя.
del L[0] и L.pop(0) — одно и то же, просто разная запись
На 3.12 действительно одно и то же. На 3.13 и 3.14 — нет: после девятисот удалений из списка в тысячу элементов pop(0) оставляет ёмкость 184, а del — 1000, то есть 1528 байт против 8056. Длина в обоих случаях сто, и по ней разницы не видно. Причина в том, что удаление по индексу перестало проходить через list_ass_slice: с 3.13 его делает list_ass_item_lock_held, где list_resize не вызывается. То же расхождение и во времени внутри одного запуска 3.13.7: pop(0) дороже del L[0] в семь раз.
sys.getsizeof(L) показывает, сколько места занимают элементы списка
Показывает размер массива указателей по числу ВЫДЕЛЕННЫХ мест, а не по длине, и не включает сами объекты, на которые указатели ведут. Документация функции говорит это прямо: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to
(Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Отсюда и способ прочитать ёмкость, которой нет публичного имени: (sys.getsizeof(L) - sys.getsizeof([])) // struct.calcsize("P").
Два списка с одинаковыми элементами занимают одинаковое место
Только если построены способом, который знает длину заранее. Список из десяти элементов занимает 136 байт, если это литерал, [0] * 10 или list(range(10)), и 184 байта, если это списковое включение или list от генератора: у последних двух длина заранее неизвестна, элементы добавляются по одному, и место берётся с запасом — шестнадцать мест вместо десяти.
append дёшев, потому что массив просто удлиняется
Иногда его удлиняют на месте: realloc расширяет выделение, если за концом есть свободное место, — на 3.13.7 с GIL прогон насчитал 13 таких скачков ёмкости из 24 на первых шестистах append, а на 3.12.3 и 3.14.7 — 12 из 24; пропорция зависит от состояния аллокатора, воспроизводится только то, что встречаются оба исхода. Но рассчитывать на это нельзя, и когда места нет, массив переезжает целиком. Дёшев append потому, что место берётся с запасом по формуле (n + (n >> 3) + 6) & ~3, то есть запас пропорционален длине. На первых шестистах append массив перевыделяется двадцать четыре раза, а не шестьсот. Комментарий в исходнике называет это прямо: запас нужен, чтобы дать linear-time amortized behavior over a long sequence of appends()
(линейное в среднем поведение на длинной последовательности appends()).
collections.deque — это список, у которого дёшевы оба конца
Это другая структура с другой платой. Оба конца действительно дёшевы — appendleft стоит столько же, сколько append. Но индекс перестаёт быть постоянным: на ста тысячах элементов d[50000] стоит 2418,3 наносекунды против 9,0 у L[50000], то есть в сотни раз дороже. Документация обещает и то и другое сразу: Indexed access is O(1) at both ends but slows to O(n) in the middle
(Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине).
Формула роста списка — часть языка, на неё можно опираться
Нельзя. В документации языка её нет вовсе; она записана комментарием в Objects/listobject.c и остаётся деталью реализации CPython. То, что она не менялась между 3.12 и 3.14, — наблюдение из пяти прогонов, а не обещание: вывод bench/lists/growth.py даёт одну и ту же последовательность ёмкостей и те же двадцать четыре скачка на 3.12.3, 3.13.7, 3.14.7 и свободнопоточных 3.13.7t, 3.14.7t. Опираться можно на то, что append в среднем дёшев; на конкретные числа ёмкости — нет.
Проверьте себя
len(L) равен 100. Сколько памяти занимает список?
Источники и что читать дальше
7 ИСТОЧНИКОВ
- Objects/listobject.c — list_resize и формула ростаИсходный код CPython. Место, из которого берётся весь рост списка. Комментарий над функцией называет и цель, и последовательность: «This over-allocates proportional to the list size, making room for additional growth. The over-allocation is mild, but is enough to give linear-time amortized behavior over a long sequence of appends()» (Выделяется с запасом, пропорциональным размеру списка, чтобы оставить место для дальнейшего роста. Запас небольшой, но его хватает, чтобы на длинной последовательности appends() поведение было линейным в среднем). Там же — сама формула `new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;` и условие, при котором массив ОСТАВЛЯЮТ как есть: `if (allocated >= newsize && newsize >= (allocated >> 1))`; сжатие начинается там, где оно не выполнено. Читалось по тегу v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
- Objects/listobject.c в 3.12.3 — удаление по индексу через list_ass_sliceИсходный код CPython. Для сравнения с 3.13: до неё `list_ass_item` при удалении не делал ничего сам, а передавал работу общему пути присваивания срезу — `if (v == NULL) return list_ass_slice(a, i, i+1, v);`. Именно `list_ass_slice` после сдвига хвоста вызывает `list_resize`, и оттуда бралось уменьшение ёмкости, которого в 3.13 больше нет.https://github.com/python/cpython/blob/v3.12.3/Objects/listobject.c
- Objects/listobject.c — list_pop_impl: каким путём идёт popИсходный код CPython. Нужен затем, чтобы «pop идёт общим путём присваивания срезу» не оставалось словом автора. Функция сама делит два случая: если снимают ПОСЛЕДНИЙ элемент, она зовёт `list_resize` напрямую — `if (index == Py_SIZE(self) - 1) { status = list_resize(self, Py_SIZE(self) - 1);`; во всех остальных случаях, включая `pop(0)`, работу делает общий путь — `status = list_ass_slice(self, index, index+1, (PyObject *)NULL);`. И там же видно второе расхождение с `del`: перед этим стоит `Py_INCREF(v)`, потому что снятый элемент возвращают наружу. Читалось в `Objects/listobject.c` на ветке 3.13.https://github.com/python/cpython/blob/3.13/Objects/listobject.c
- Objects/listobject.c в 3.13.7 — list_ass_item_lock_heldИсходный код CPython. Функция, в которой исчез вызов `list_resize`. Удаление она делает сама: цикл `FT_ATOMIC_STORE_PTR_RELAXED(a->ob_item[idx], a->ob_item[idx + 1]);` и затем `Py_SET_SIZE(a, size - 1);`. Ни `list_resize`, ни `list_ass_slice` на этом пути не встречаются — отсюда и то, что ёмкость после `del` не уменьшается.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
- sys.getsizeof — что именно возвращаетсяОфициальная документация. Формулировка, из-за которой размер списка читают неправильно: «Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to» (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). У списка «непосредственно относящееся» — это массив указателей, и считается он по числу ВЫДЕЛЕННЫХ мест, а не по длине.https://docs.python.org/3/library/sys.html#sys.getsizeof
- collections.deque — что обещано про сложностьОфициальная документация. Единственное место, где официально обещана стоимость операций с обоими концами: «Deques support thread-safe, memory efficient appends and pops from either side of the deque with approximately the same O(1) performance in either direction» (Двусторонние очереди поддерживают потокобезопасные и экономные по памяти добавления и снятия с любой стороны с примерно одинаковой производительностью O(1) в обе стороны). Там же сказано и то, чем за это платят: «Indexed access is O(1) at both ends but slows to O(n) in the middle» (Доступ по индексу — O(1) на обоих концах, но замедляется до O(n) в середине).https://docs.python.org/3/library/collections.html#collections.deque
- Что нового в Python 3.13Официальная документация. Приводится как источник ОТСУТСТВИЯ записи: изменения в поведении `del L[i]` относительно ёмкости списка в документе нет. Проверялось поиском по разделам Other Language Changes и Optimizations. Отсюда правило этой статьи: об изменении говорится как о наблюдении с указанием версий, а не как о задокументированном решении.https://docs.python.org/3/whatsnew/3.13.html