Deep Engineering
Поиск
Ядро·Опубликовано·3.6 · 3.13·40 МИН

Словари Python изнутри: хеш-таблица CPython

Хеширование, открытая адресация и переписывание словаря в 2016 году, которое сделало порядок вставки побочным эффектом реализации — а через один релиз сделало его гарантией языка.

Полное техническое изложение

TL;DR

  • Словарь CPython — это два массива: разреженный массив индексов dk_indices и плотный, упорядоченный по вставке массив записей dk_entries.
  • Поиск — открытая адресация с пробированием через perturb, а не цепочки в бакетах. Коэффициент заполнения ограничен ⅔.
  • Сохранение порядка появилось в 3.6 как деталь реализации этой раскладки и стало гарантией языка в 3.7.
  • Разделение ключей (key-sharing dict, PEP 412) — причина, по которой словари экземпляров дешевле, чем кажутся.

Зачем это знать?

Потому что словари — это субстрат языка, а не просто один из контейнеров. Каждое обращение к атрибуту, каждое пространство имён модуля, каждый набор keyword-аргументов — это операция со словарём. Если в вашем сервисе горячий цикл, то словарь в нём почти наверняка есть, даже если вы его не писали руками.

Практическое следствие: у CPython не одна реализация поиска по словарю, а несколько специализаций, и переключение между ними видно в цифрах. Самое наглядное доказательство — оптимизация из 3.11: если все ключи словаря строковые, интерпретатор перестаёт хранить в таблице их хеши, и sys.getsizeof(dict.fromkeys("abcdefg")) падает с 352 до 272 байт. Добавьте в такой словарь один нестроковый ключ — и таблица переходит в общий режим. Ни один из этих переходов не виден в исходном коде на Python: чтобы объяснить регрессию, надо знать про раскладку.

Модель в голове

Представьте гардероб. Разреженный массив dk_indices — это доска с номерками на стене: почти пустая, расширять её дёшево. Плотный массив dk_entries — это вешалка с самими пальто, развешанными строго в порядке поступления. Хеш говорит, какой номерок читать; номерок говорит, куда идти по вешалке.

Расширение таблицы перерисовывает доску и никогда не перевешивает пальто. Именно поэтому порядок обхода выживает при расширении — и именно поэтому это свойство изначально было побочным эффектом раскладки, а не обещанием языка:

The order-preserving aspect of this new implementation is considered an implementation detail and should not be relied upon.

What's New in Python 3.6 — New dict implementation

Дословно: «сохранение порядка в этой новой реализации считается деталью реализации, и полагаться на неё не следует». Так авторы CPython описали побочный эффект компактного словаря в 3.6 — за один релиз до того, как это же поведение стало официальной частью языка.

Устройство: две таблицы вместо одной

«Разреженный» и «плотный» — это не эпитеты

Два слова, без которых дальше ничего не сойдётся.

Разреженный массив — тот, в котором свободных ячеек намеренно больше, чем нужно. Хеш-таблице с открытой адресацией это не роскошь, а условие работы: ключ ищется по формуле «взять хеш, отрезать младшие биты, посмотреть в слот», и если свободных слотов не останется, поиск свободного места и разрешение коллизий выродятся в перебор всей таблицы. Поэтому CPython не даёт таблице заполниться больше чем на две трети (USABLE_FRACTION) — минимум треть слотов всегда пустует, и это не потери, а рабочий запас.

Плотный массив — противоположность: заполняется подряд с нулевой позиции, без пропусков, и растёт только с хвоста. Пробелы в нём появляются лишь как след удалённых ключей и живут до ближайшей пересборки.

Компактный словарь состоит из обоих сразу — и весь фокус в том, какой из них сделан разреженным.

Зачем вообще нужен dk_indices

До Python 3.6 массив был один. Вот та самая структура из CPython 3.5:

Objects/dict-common.h (CPython 3.5)
struct _dictkeysobject {
    Py_ssize_t dk_refcnt;
    Py_ssize_t dk_size;
    dict_lookup_func dk_lookup;
    Py_ssize_t dk_usable;
    PyDictKeyEntry dk_entries[1];
};

dk_entries здесь — и есть хеш-таблица: слот номер i — это dk_entries[i], целая запись из хеша, ключа и значения. То есть разреженным был массив 24-байтовых записей, и обязательная пустая треть стоила по 24 байта за слот.

Идея компактного словаря (письмо Реймонда Хеттингера в python-dev, декабрь 2012) в одной фразе: пусть разреженным будет дешёвый массив, а дорогой — плотным. Разреженность нужна самой хеш-таблице, а не записям, поэтому хеш-таблицу вынесли в отдельный массив маленьких целых чисел, и вся «воздушная подушка» стала стоить по одному байту за слот вместо двадцати четырёх.

Считается это прямо на пальцах. Таблица на 8 слотов, в ней 5 живых ключей:

Python 3.5Python 3.6+
хеш-таблица8 × 24 = 192 Бdk_indices: 8 × 1 = 8 Б
записи— (они же и есть таблица)dk_entries: 5 × 24 = 120 Б
итого192 Б, из них 72 Б впустую128 Б, впустую — 8 байт индексов

На таблице в 65 536 слотов (43 690 записей) — 1 572 864 байта против 1 179 632: те самые «на 20–25% меньше памяти», которыми release notes 3.6 описывают новую реализацию.

А теперь ответ на вопрос «зачем эта прослойка вообще»: без неё поиск ключа стал бы линейным. dk_entries отсортирован по времени вставки, а не по хешу, — по нему нельзя вычислить, где лежит ключ, можно только перебрать все записи подряд. dk_indices — это и есть хеш-таблица: адрес слота получается из хеша одной операцией hash & mask, и в слоте сразу лежит номер нужной записи. Одно обращение вместо перебора, O(1) вместо O(n).

Порядок вставки при этом получился побочным эффектом: раз записи складываются в плотный массив подряд, обход этого массива и выдаёт ключи в порядке добавления. Никакой отдельной структуры для порядка в словаре нет.

Две структуры рядом

dk_indices — разреженный массив, тот самый, размер которого равен dk_size и всегда является степенью двойки. В слоте лежит не ключ и не значение, а маленькое целое число — индекс записи во втором массиве. Или одно из служебных значений: DKIX_EMPTY (−1) для пустого слота и DKIX_DUMMY (−2) для слота, из которого ключ удалили. Ширина одного слота подобрана под размер таблицы — из комментария к dk_indices в pycore_dict.h:

Include/internal/pycore_dict.h
/* The size in bytes of an indice depends on dk_size:
   - 1 byte if dk_size <= 0xff (char*)
   - 2 bytes if dk_size <= 0xffff (int16_t*)
   - 4 bytes if dk_size <= 0xffffffff (int32_t*)
   - 8 bytes otherwise (int64_t*) */
char dk_indices[];

Вот почему «расширять доску дёшево»: словарь из восьми слотов тратит на dk_indices 8 байт, а не восемь полноценных записей. Даже при таблице на 65 тысяч слотов это два байта на слот.

dk_entries — плотный массив самих записей, в порядке вставки. И вот что в записи лежит на самом деле:

Include/internal/pycore_dict.h
typedef struct {
    /* Cached hash code of me_key. */
    Py_hash_t me_hash;
    PyObject *me_key;
    PyObject *me_value;
} PyDictKeyEntry;

Три поля по машинному слову — на 64-битной сборке ровно 24 байта на запись. Ключ и значение — это указатели: сами объекты живут где-то ещё в куче, словарь хранит только адреса и держит на них ссылки. А me_hash — кеш: хеш ключа считается один раз при вставке и сохраняется рядом, чтобы не вызывать hash() заново при каждом сравнении в цепочке проб и при каждом расширении таблицы.

Как эти массивы заполняются

Вставка нового ключа — это всего четыре события, и порядок у них не тот, который кажется естественным. Вот тело insert_combined_dict из dictobject.c, сокращённое до записей в память:

Objects/dictobject.c — insert_combined_dict()
Py_ssize_t hashpos = find_empty_slot(mp->ma_keys, hash);
dictkeys_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries);
 
if (DK_IS_UNICODE(mp->ma_keys)) {
    PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
    STORE_KEY(ep, key);
    STORE_VALUE(ep, value);
}
else {
    PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
    STORE_KEY(ep, key);
    STORE_VALUE(ep, value);
    STORE_HASH(ep, hash);
}
STORE_KEYS_USABLE(mp->ma_keys, mp->ma_keys->dk_usable - 1);
STORE_KEYS_NENTRIES(mp->ma_keys, mp->ma_keys->dk_nentries + 1);

Читается это так:

  1. find_empty_slot ищет свободный слот в dk_indices — по хешу и, при коллизии, по последовательности проб (о ней ниже). Ключ и значение в этом поиске не участвуют вообще: слот выбирают биты хеша.
  2. dictkeys_set_index пишет в найденный слот текущее значение dk_nentries — номер записи, которой ещё нет. Индекс в доску попадает раньше, чем создана сама запись.
  3. Только теперь заполняется запись в dk_entries по индексу dk_nentries: два указателя, а в общем режиме ещё и хеш.
  4. dk_usable уменьшается на единицу, dk_nentries — увеличивается. Первый счётчик — это остаток до расширения (USABLE_FRACTION), второй — граница заполненной части плотного массива.

Ничего больше при вставке в память не пишется. Ни ключ, ни значение никуда не копируются: STORE_KEY и STORE_VALUE сохраняют адреса уже существующих объектов и увеличивают их счётчики ссылок.

Ниже — те же четыре шага в интерактивном виде: кнопка «Воспр.» проигрывает вставку по фазам, переключатель вверху меняет раскладку записи с общей на строковую.

Рис. 1 · Что и в каком порядке пишется в dk_indices и dk_entries
ШАГ 00 / 12
ИСХОДНОЕ СОСТОЯНИЕ
PyDict_MINSIZE == 8
PyDictKeysObject · заголовок
dk_kind
GENERAL
dk_log2_size
3 → 8 слотов
dk_nentries
0
dk_usable
5
dk_indices · разреженный массив1 байт на слот (dk_size ≤ 0xff) · всего 8 байт
0
−1
1
−1
2
−1
3
−1
4
−1
5
−1
6
−1
7
−1

В слоте лежит не ключ и не значение, а индекс записи в dk_entries — маленькое целое. Пустой слот хранит DKIX_EMPTY (−1), а слот после удаления — DKIX_DUMMY (−2). Именно поэтому расширять эту доску дёшево: при восьми слотах она занимает 8 байт, а не 8 полноценных записей.

dk_entries · плотный массив, порядок вставкиPyDictKeyEntry = 24 байт на запись
#
me_hash · 8 байт
me_key · указатель, 8 байт
me_value · указатель, 8 байт
— пусто —
запись = 24 байт
Текстовый эквивалент · шаг 00

Пустой словарь. dk_indices — 8 слотов по одному байту, во всех DKIX_EMPTY (−1). dk_entries — плотный массив на USABLE_FRACTION(8) = 5 записей по 24 байт; пока не заполнено ни одной. Всё, что произойдёт дальше, — это два записанных числа и три (в другом режиме — два) указателя.

Второй вид записи: когда все ключи — строки

У записи есть и вторая форма. Если все ключи таблицы строковые, CPython использует другую структуру:

Include/internal/pycore_dict.h
typedef struct {
    PyObject *me_key;   /* The key must be Unicode and have hash. */
    PyObject *me_value;
} PyDictUnicodeEntry;

Поля хеша здесь нет вообще — 16 байт вместо 24. Хеш при этом не теряется: у строкового объекта он уже закеширован внутри самого объекта, так что держать вторую копию в таблице незачем. Какой из двух вариантов используется, записано в поле dk_kind (DICT_KEYS_GENERAL или DICT_KEYS_UNICODE; есть и третий, DICT_KEYS_SPLIT, — о нём ниже). Это и есть механизм экономии, о которой говорит changelog 3.11: sys.getsizeof(dict.fromkeys("abcdefg")) — 272 байта вместо прежних 352. Переключиться между режимами можно в визуализации выше.

Именно порядок в dk_entries и даёт словарю то, что с Python 3.7 стало официальной гарантией языка, а не деталью реализации: обход dict идёт в порядке вставки ключей.

Есть и третий вариант — split table (PEP 412, "key-sharing dictionary"): если у множества объектов одного класса одинаковый набор строковых атрибутов, они могут разделять один и тот же dk_indices/ключевую часть, а значения хранить каждый в своём ma_values. Это оптимизация для __dict__ экземпляров, а не для словарей общего назначения — в основном тексте статьи речь про обычный, "combined" словарь.

Шаг 1: от ключа к числу

Первое, что происходит при d[key] = value или d[key], — вызов hash(key). Дальше многое зависит от типа ключа:

  • Для int — хеш почти всегда совпадает со значением: hash(n) == n для большинства целых (с редукцией по модулю простого числа Мерсенна 2⁶¹−1 на 64-битных сборках — см. sys.hash_info). Исключение: -1. CPython резервирует -1 как код ошибки для C-функций, возвращающих Py_hash_t, поэтому несколько его хеш-функций (для bytes, float, указателей — см. Python/pyhash.c) явно подменяют вычисленное -1 на -2. Для int действует то же соглашение: hash(-1) == -2.
  • Для strbytes) — с PEP 456 это SipHash13, причём с seed, случайным для каждого запуска процесса (PYTHONHASHSEED). Это осознанное решение о безопасности: до PEP 456 предсказуемый хеш строк давал классическую DoS-атаку "hash flooding" через specially crafted ключи. Практическое следствие: hash("cpython") в двух разных запусках python3 — это, как правило, два разных числа.

Ниже — тот же самый первый шаг, key → hash(key), в интерактивном виде. Для int показано настоящее значение hash(); для str — намеренно упрощённая демонстрационная функция (не SipHash), потому что настоящий хеш строки невоспроизводим между запусками по построению.

42→ hash() →42→ & mask(7) →слот 2

Шаг 2: от числа к слоту

Хеш — это произвольное целое число, а таблица — массив из dk_size слотов, где dk_size всегда степень двойки. Начальный кандидат на слот — самая дешёвая операция, которая тут вообще возможна:

Objects/dictobject.c
mask = dk_size - 1;
i = hash & mask;

& mask вместо % dk_size — обычный трюк для степеней двойки: младшие биты hash и есть остаток от деления, без деления как такового.

Коллизии: последовательность проб CPython

Два разных ключа почти неизбежно дадут одинаковый i рано или поздно. Здесь у CPython не линейное пробирование и не что-то на основе двойного хеширования из учебника — своя рекуррентная формула, прямо из комментария в dictobject.c:

Objects/dictobject.c
perturb = hash;
i = hash & mask;
 
while (slot_at(i)->occupied) {
    perturb >>= PERTURB_SHIFT;      /* PERTURB_SHIFT == 5 */
    i = (i*5 + perturb + 1) & mask;
}

Идея: обычное линейное пробирование (i = i + 1) слишком регулярно — при частичном заполнении таблицы оно даёт длинные цепочки коллизий, коррелирующие с самим mask. Добавка perturb, которая на каждом шаге сдвигается вправо на 5 бит, постепенно "подмешивает" в последовательность проб старшие биты исходного хеша — те самые биты, которые & mask в чистом виде вообще не использует. В сумме получается псевдослучайный, но полностью детерминированный обход всех dk_size слотов.

Проверьте формулу сами — таблица ниже пересчитывает реальную последовательность проб CPython для введённого hash и отмеченных как занятые слотов:

Таблица из 8 слотов (mask = 7). Кликните по занятым слотам ниже, чтобы создать коллизии — цепочка проб пересчитается по настоящей формуле CPython.

  1. i₀ = hash & mask = 37 & 7 = 5
  2. проба 1: perturb = 1, i = (i·5 + perturb + 1) & mask = 3
  3. → ключ занимает слот 3 после 1 проб(ы).

Рост таблицы: когда и во сколько раз

Хеш-таблица, заполненная под завязку, вырождается в линейный поиск — CPython не доводит до этого и держит запас. Три числа из dictobject.c полностью определяют, когда и как таблица растёт:

Objects/dictobject.c
#define PyDict_MINSIZE 8
#define USABLE_FRACTION(n) (((n) << 1)/3)   /* resize при заполнении на 2/3 */
#define GROWTH_RATE(d) ((d)->ma_used*3)

Свежий словарь начинается с dk_size = 8 и dk_usable = USABLE_FRACTION(8) = 5 — ровно то, что описывает комментарий в исходнике: "8 allows dicts with no more than 5 active entries". Каждая успешная вставка уменьшает dk_usable на 1; когда он должен был бы стать отрицательным, перед вставкой запускается resize: новый минимальный размер — GROWTH_RATE(mp), то есть ma_used * 3, а фактический новый dk_size — ближайшая степень двойки, не меньшая этого значения. Если удалений не было, ma_used * 3 почти всегда попадает в диапазон "следующая степень двойки после удвоения", отсюда и комментарий в исходнике: "dicts double in size when growing without deletions".

Интерактивная визуализация

Пройдите по шагам шесть вставок в таблицу из 8 слотов. Обратите внимание на пробирование при коллизии на шаге 3 и на расширение на шаге 6 — массив записей копируется дословно, ни одна запись не меняет позицию.

Ключи здесь целые, а не строковые, и это сделано намеренно: hash(n) == n, поэтому каждый номер слота ниже можно проверить в своём интерпретаторе, а хеш строки невоспроизводим между запусками (см. шаг 1).

Рис. 2 · Вставка и расширение компактного словаряШАГ 00 / 07
dk_indices · 8 слотов
0
·
1
·
2
·
3
·
4
·
5
·
6
·
7
·
dk_entries · порядок вставки сохранён
#
Хеш
Ключ
Значение
— пусто —
dk_size = 8
Текстовый эквивалент · шаг 00

Пустой словарь. dk_indices — 8 слотов, во всех DKIX_EMPTY (показаны как ·). dk_entries пуст. Полезная ёмкость таблицы из 8 слотов — USABLE_FRACTION(8) = 5 записей.

Тот же словарь целиком, если хочется убедиться самому:

check_order.py
import sys
 
d = {}
for k in (3, 6, 11, 1, 7):
    d[k] = "…"
print(sys.getsizeof(d))      # таблица ещё на 8 слотов
 
d[2] = "…"                   # шестая вставка пересекает порог 2/3
print(sys.getsizeof(d))      # выросла: dk_indices теперь на 16 слотов
print(list(d))               # [3, 6, 11, 1, 7, 2] — порядок вставки уцелел

Обновление и удаление: три разных пути

Про вставку нового ключа сказано всё, но d[k] = v — это ещё и обновление уже существующего ключа, а del d[k] устроен совсем иначе. Три пути ведут себя по-разному, и разница видна невооружённым глазом.

Обновление: ничего не двигается

Если поиск нашёл ключ, insertdict до создания записи вообще не доходит:

Objects/dictobject.c — insertdict(), ветка «ключ уже есть»
if (old_value != value) {
    uint64_t new_version = _PyDict_NotifyEvent(
            interp, PyDict_EVENT_MODIFIED, mp, key, value);
    assert(old_value != NULL);
    assert(!_PyDict_HasSplitTable(mp));
    if (DK_IS_UNICODE(mp->ma_keys)) {
        PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[ix];
        STORE_VALUE(ep, value);
    }
    else {
        PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
        STORE_VALUE(ep, value);
    }
    mp->ma_version_tag = new_version;
}
Py_XDECREF(old_value);

Переписывается одно полеme_value, а старое значение получает Py_DECREF. Ни dk_indices, ни me_key, ни me_hash не трогаются, dk_nentries и dk_usable не меняются. Отсюда практическое следствие, о котором часто спорят: присваивание существующему ключу не меняет порядок обхода. Ключ остаётся на своей позиции в dk_entries, а значит и в итерации.

Удаление: надгробие вместо записи

delitem_common трогает обе структуры. Ниже — её тело, сокращённое до записей в память:

Objects/dictobject.c — delitem_common()
Py_ssize_t hashpos = lookdict_index(mp->ma_keys, hash, ix);
STORE_USED(mp, mp->ma_used - 1);
mp->ma_keys->dk_version = 0;
dictkeys_set_index(mp->ma_keys, hashpos, DKIX_DUMMY);
 
PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
old_key = ep->me_key;
STORE_KEY(ep, NULL);
STORE_VALUE(ep, NULL);
STORE_HASH(ep, 0);

В слоте вместо индекса появляется DKIX_DUMMY (−2) — «надгробие». Пометить слот как пустой нельзя, и в исходнике прямо написано почему:

Dummy slots cannot be made Unused again else the probe sequence in case of collision would have no way to know they were once active.

Objects/dictobject.c, комментарий о состояниях слота

Если бы слот стал DKIX_EMPTY, поиск ключа, который когда-то из-за коллизии перепрыгнул через этот слот дальше по цепочке проб, останавливался бы на нём — и ключ, лежащий в словаре, перестал бы находиться.

Запись в dk_entries при этом обнуляется на месте: на её позиции остаётся дыра. И — самое важное для следующего раздела — счётчик свободной ёмкости не восстанавливается. Комментарий в исходнике объясняет и это: раз в dk_indices остаются надгробия, увеличить dk_usable нельзя, хотя живых ключей стало меньше.

Рис. 3 · Обновление, удаление и повторная вставкаКАДР 00 / 04
ТРИ ВСТАВКИ
d = {1: 1, 2: 2, 3: 3}
ma_used
3
dk_size
8
dk_usable
2
dk_nentries
3
getsizeof
224 Б
dk_indices · 8 слотовнадгробий (−2): 0
0
−1
1
0
2
1
3
2
4
−1
5
−1
6
−1
7
−1
dk_entries · заполненная частьдыр от удалённых ключей: 0
#
me_hash
me_key
me_value
0
1
1
1
1
2
2
2
2
3
3
3
list(d) → [1, 2, 3]
CPython 3.13.13 · замер через ctypes
Текстовый эквивалент · кадр 00

Исходное состояние: три ключа в таблице из 8 слотов. Ключи целые, поэтому hash(n) = n и слот — это n & 7. В слотах 1, 2, 3 лежат индексы 0, 1, 2, записи в dk_entries идут подряд в порядке вставки.

Отдельно стоит запомнить последний кадр: удалить ключ и вставить его обратно — не то же самое, что обновить значение. Слот в dk_indices переиспользуется (find_empty_slot останавливается и на пустом слоте, и на надгробии), но запись создаётся новая, в хвосте, — и ключ уезжает в конец порядка обхода.

Так растёт ли dk_indices бесконечно?

Из предыдущего раздела складывается тревожная картина: каждая вставка тратит ёмкость, каждое удаление оставляет мусор и ничего не возвращает. Если долго вставлять и удалять, таблица должна пухнуть без предела.

Не должна — и вот почему. Ёмкость dk_usable действительно утекает, но когда она доходит до нуля, очередная вставка запускает пересборку, а размер новой таблицы считается от числа живых ключейGROWTH_RATE(mp) — это ma_used * 3, и ma_used учитывает только живые. Мёртвые записи и надгробия в этот расчёт не входят и при копировании просто исчезают. Поэтому размер таблицы определяется тем, сколько ключей в словаре сейчас, а не тем, сколько операций над ним сделали.

Рис. 4 · Долгий цикл вставок и удаленийКАДР 00 / 13
ПУСТОЙ СЛОВАРЬ
d = {}
ma_used
0
dk_size
1
dk_usable
0
dk_nentries
0
getsizeof
64 Б
dk_indices · 1 слотнадгробий (−2): 0
0
−1
dk_entries · заполненная частьдыр от удалённых ключей: 0
#
me_hash
me_key
me_value
— массив ещё не выделен —
list(d) → []
CPython 3.13.13 · замер через ctypes
Текстовый эквивалент · кадр 00

Пустой словарь не владеет таблицей вообще: ma_keys указывает на разделяемый пустой объект ключей, поэтому sys.getsizeof(d) — 64 байта, и ни dk_indices, ни dk_entries ещё не выделены.

Из этого следуют три вещи, каждую из которых легко проверить самому.

Первое: при постоянном цикле «вставил — удалил» словарь не растёт. Миллион пар вставка/удаление при сотне живых ключей меняет размер ровно один раз, в самом начале:

churn.py
import sys
 
d = {i: i for i in range(100)}
prev, changes = sys.getsizeof(d), []
for i in range(1_000_000):
    d[("k", i)] = i
    del d[("k", i)]
    if sys.getsizeof(d) != prev:
        changes.append((i, prev, sys.getsizeof(d)))
        prev = sys.getsizeof(d)
 
print(len(d), changes)   # 100 [(70, 4688, 9304)]
print(sys.getsizeof(d))  # 9304 — и дальше константа

Второе: таблица умеет и сжиматься. Словарь на тысячу ключей, из которых осталось десять, после первой же пересборки ужимается почти в шестьдесят раз:

shrink.py
import sys
 
d = {i: i for i in range(1000)}
for i in range(990):
    del d[i]
print(sys.getsizeof(d))      # 36952 — удаления не вернули ничего
 
for n in range(1, 400):      # цикл вставка/удаление: живых по-прежнему 10
    d[("n", n)] = n
    del d[("n", n)]
print(sys.getsizeof(d))      # 632

Третье: сами по себе удаления не освобождают память. Пересборка бывает только при вставке, поэтому словарь, из которого всё удалили и в который больше не пишут, продолжает держать старую таблицу:

no_shrink.py
import sys
 
d = {i: i for i in range(1000)}
for i in range(1000):
    del d[i]
print(len(d), sys.getsizeof(d))   # 0 36952 — пусто, но память занята
 
d.clear()
print(sys.getsizeof(d))           # 64 — вот теперь освободилась

Практический вывод ровно один: если из большого словаря удалили почти всё и дальше он будет жить долго, а вставок в него не предвидится — вызовите clear() или соберите новый словарь. Во всех остальных случаях механизм сам себя чистит.

Кадры на Рис. 3 и Рис. 4 — не модель, а замер: состояние реального словаря, прочитанное прямо из памяти интерпретатора. Скрипт ниже воспроизводит его целиком (ctypes тут читает приватные структуры CPython — это годится для того, чтобы посмотреть, и категорически не годится для рабочего кода).

dict_state.py
import ctypes
import sys
 
class DictKeys(ctypes.Structure):
    _fields_ = [("dk_refcnt", ctypes.c_ssize_t),
                ("dk_log2_size", ctypes.c_uint8),
                ("dk_log2_index_bytes", ctypes.c_uint8),
                ("dk_kind", ctypes.c_uint8),
                ("dk_version", ctypes.c_uint32),
                ("dk_usable", ctypes.c_ssize_t),
                ("dk_nentries", ctypes.c_ssize_t)]
 
class Dict(ctypes.Structure):
    _fields_ = [("ob_refcnt", ctypes.c_ssize_t),
                ("ob_type", ctypes.c_void_p),
                ("ma_used", ctypes.c_ssize_t),
                ("ma_version_tag", ctypes.c_uint64),
                ("ma_keys", ctypes.POINTER(DictKeys)),
                ("ma_values", ctypes.c_void_p)]
 
def state(d):
    obj = Dict.from_address(id(d))
    keys = obj.ma_keys.contents
    size = 1 << keys.dk_log2_size
    base = ctypes.addressof(keys) + ctypes.sizeof(DictKeys)
    slots = list((ctypes.c_int8 * size).from_address(base))
    return (f"used={obj.ma_used} size={size} usable={keys.dk_usable} "
            f"nentries={keys.dk_nentries} bytes={sys.getsizeof(d)}\n  {slots}")
 
d = {1: 1, 2: 2, 3: 3}
print("три вставки  ", state(d))
d[1] = 100
print("обновление   ", state(d))   # счётчики не изменились
del d[2]
print("удаление     ", state(d))   # в слоте 2 появилось -2, usable прежний
d[2] = 22
print("вставка назад", state(d), list(d))   # [1, 3, 2] — ключ уехал в конец

Под капотом: история версий

Самый часто перевираемый факт о словарях — когда на порядок вставки стало можно полагаться. Это не одно событие, а два, разнесённые ровно на один релиз:

ВерсияИзменениеСтатус порядка
3.5Разреженный массив записей: (хеш, ключ, значение) лежат прямо в слотах. Порядок обхода следует слотам хеша.Произвольный
3.6Компактный словарь (bpo-27350, INADA Naoki по идее Raymond Hettinger): раздельные dk_indices и dk_entries, памяти на 20–25% меньше.Упорядочен — деталь реализации
3.7Сохранение порядка вставки объявлено частью спецификации языка.Упорядочен — гарантия языка
3.11Словари со сплошь строковыми ключами перестали хранить хеши: sys.getsizeof(dict.fromkeys("abcdefg")) — 272 байта вместо 352 (bpo-46845).Гарантия та же
3.13Экспериментальная сборка без GIL (PEP 703). Раскладка словаря в ней не менялась.Гарантия та же

Что из этого — гарантия языка, а что — деталь реализации

Стоит разделять два уровня:

  • Гарантия языка (можно полагаться в проде): словари сохраняют порядок вставки — с Python 3.7 это часть спецификации, а не побочный эффект (см. changelog 3.7). Тип ключей int/str/произвольный hashable, dict[key] — амортизированный O(1).
  • Деталь реализации CPython (может измениться в другой версии или в PyPy/GraalPy): конкретные числа 8 / ⅔ / ×3, формула PERTURB_SHIFT = 5, само разделение на dk_indices/dk_entries, key-sharing dict из PEP 412, отказ от хранения хешей для строковых ключей в 3.11. Код, который явно завязан на эти числа (а не просто на публичное поведение словаря), — красный флаг при код-ревью.

Частые заблуждения

Утверждение

«Словари упорядочены, потому что CPython сортирует ключи».

На самом деле

Ничего не сортируется. Порядок — это физический порядок плотного массива dk_entries, куда пишут только в конец. Удалите ключ и добавьте снова — он уедет в конец; сортировка так не сделала бы.

Утверждение

«Порядок вставки был гарантирован языком всегда, как минимум с 3.6».

На самом деле

В 3.6 это была особенность конкретной реализации CPython — changelog прямо просил на неё не полагаться (цитата выше), и другие интерпретаторы могли вести себя иначе. Частью спецификации языка порядок стал только в 3.7.

Утверждение

«Коллизия — это связный список, как в учебной хеш-таблице».

На самом деле

CPython использует открытую адресацию. При коллизии вычисляется i = (i*5 + perturb + 1) & mask с подмешиванием старших битов полного хеша, поэтому последовательности проб различаются даже у ключей, попавших в один и тот же начальный слот.

Утверждение

«Раз hash("строка") детерминирован внутри программы, его можно сравнивать между запусками или сохранять на диск».

На самом деле

Хеш строк и bytes — SipHash с seed, случайным для каждого запуска процесса (PEP 456, PYTHONHASHSEED). Внутри одного запуска он стабилен, между запусками — как правило, разный.

Проверка знаний

Вопрос 1 из 4

Словарь с 5 ключами вырос (resize) до 16 слотов — без единого удаления по пути. Что верно для самого следующего обхода?

Среднийскоро
Хеш-таблицы: общая механика
Продвинутыйскоро
Хеширование и протокол __hash__
Экспертныйскоро
Множества Python изнутри

Источники и что читать дальше

12 ИСТОЧНИКОВ

  1. Objects/dictobject.c — реализация словаряИсходный код CPython. Исходники CPython на закреплённом теге. Пробирование, USABLE_FRACTION, GROWTH_RATE и проектные комментарии, на которые опирается вся статья. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Objects/dictobject.c
  2. Include/internal/pycore_dict.h — раскладка структур словаряИсходный код CPython. Определения PyDictKeyEntry и PyDictUnicodeEntry, константы DKIX_*, перечисление dk_kind и правило ширины слота в dk_indices. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Include/internal/pycore_dict.h
  3. Objects/dict-common.h (CPython 3.5) — словарь до компактной раскладкиИсходный код CPython. Структура _dictkeysobject с массивом dk_entries, который сам и был разреженной хеш-таблицей. Нужна, чтобы посчитать, сколько стоила пустая треть до 3.6. Тег CPython 3.5.0.https://github.com/python/cpython/blob/v3.5.0/Objects/dict-common.h
  4. Python/pyhash.c — хеш-функции интерпретатораИсходный код CPython. Соглашение о −1 как коде ошибки и подмене его на −2, реализация SipHash для строк и bytes. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Python/pyhash.c
  5. What's New In Python 3.6 — новая реализация dictОфициальная документация. Release notes PSF. Здесь зафиксировано «на 20–25% меньше памяти» и прямая оговорка, что порядок — деталь реализации.https://docs.python.org/3/whatsnew/3.6.html
  6. What's New In Python 3.7 — «dict objects preserve insertion order»Официальная документация. Release notes PSF. Фраза, превратившая деталь реализации в гарантию языка.https://docs.python.org/3/whatsnew/3.7.html
  7. What's New In Python 3.11 — оптимизации словаряОфициальная документация. Словари перестали хранить хеши, когда все ключи — строки: 352 → 272 байта на 64-битной сборке. Вклад INADA Naoki, bpo-46845.https://docs.python.org/3/whatsnew/3.11.html
  8. What's New In Python 3.13 — экспериментальная сборка без GILОфициальная документация. Режим free-threading помечен как экспериментальный; раскладка словаря в нём не менялась.https://docs.python.org/3/whatsnew/3.13.html
  9. sys.hash_info — параметры хешированияОфициальная документация. Ширина хеша, модуль простого числа Мерсенна и выбранный алгоритм хеширования строк для конкретной сборки.https://docs.python.org/3/library/sys.html#sys.hash_info
  10. PEP 412 — Key-Sharing DictionaryPEP. Mark Shannon, 2012. Split-table словари, из-за которых словари экземпляров дешевле, чем кажутся.https://peps.python.org/pep-0412/
  11. PEP 456 — Secure and interchangeable hash algorithmPEP. Christian Heimes, 2013. SipHash и рандомизация seed как защита от hash-flooding.https://peps.python.org/pep-0456/
  12. python-dev: исходное предложение компактного словаряСписок рассылки. Письмо Raymond Hettinger, декабрь 2012 — идея раскладки, которую четыре года спустя реализовал INADA Naoki. На него ссылается changelog 3.6.https://mail.python.org/pipermail/python-dev/2012-December/123028.html