MEASUREMENT
bench/dict/dict_bench.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/dict
- How to run it
python3.13 bench/dict/dict_bench.py
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
Замеры для статьи «Словари изнутри»
| Скрипт | Что меряет |
|---|---|
dict_bench.py |
раскладка dk_indices/dk_entries, ширина индекса, пороги роста, что делают удаления, режим строковых ключей |
python3.13 bench/dict/dict_bench.py
Почему этот скрипт появился поздно и что он сразу поймал
Числа статьи считались вручную и скрипта не имели вовсе — то есть перезапустить
их было нечем. Первая же сверка формулы с sys.getsizeof показала ошибку.
Ширина слота dk_indices — не всегда два байта. Она растёт ступенями
вместе с таблицей, потому что в слот надо уместить номер записи:
dk_size |
байт на слот |
|---|---|
| ≤ 255 | 1 |
| ≤ 65 535 | 2 |
| ≤ 4 294 967 295 | 4 |
| дальше | 8 |
В статье для таблицы на 65 536 слотов стояло 1 179 632 байта — это расчёт по
двум байтам на слот. На 65 536 ширина уже четыре байта, и верное число —
1 310 704. Экономия против наивной таблицы там не «20–25 %», а 16,7 %:
заявленные 20–25 % — про таблицы среднего размера (на dk_size = 256
получается 25,3 %), и с ростом индекса выигрыш сжимается.
Чтобы такое ловилось запуском, а не вычиткой, блок 1b скрипта восстанавливает
getsizeof из формулы и печатает сравнение по шести размерам. Если формула
разойдётся с интерпретатором, это будет видно сразу.
Чего здесь нет и почему
Времени. Все утверждения статьи — про раскладку и байты, а не про наносекунды; мерить время значило бы добавить числа, которые в статье ничего не подпирают.
Память здесь меряется sys.getsizeof намеренно, вопреки общему правилу
замеров: предмет статьи — размер самой хеш-таблицы, то есть ровно
tp_basicsize + ... без того, на что словарь ссылается. Именно это getsizeof
и возвращает, и именно это число видит читатель у себя в консоли.
hash_collisions.py — граница худшего случая
Добавлен 27.08.2026. В статье в полной глубине стоит «O(1) вместо O(n)» без
оговорки о худшем случае. Оговорка нужна: O(1) у словаря — амортизированная
средняя при равномерном хеше, а худший случай ровно O(n) и достижим
пользовательским __hash__.
python3.13 bench/dict/hash_collisions.py
Скрипт меряет две разные по природе вещи и печатает их отдельно:
- счётчик вызовов
__eq__на один поиск — точное число, не зависящее ни от машины, ни от сборки. Это и есть доказательство асимптотики; - время поиска — только как иллюстрация к счётчику, сравниваются строки одного прогона.
Важная деталь устройства пробы: ищется новый равный объект, а не тот же
самый. CPython в lookdict сначала проверяет тождество и только потом
вызывает __eq__; поиск того же объекта завершился бы на первом слоте при
любом хеше и ничего бы не показал.
Запись прогона: 27.08.2026, Python 3.13.7, Xeon 2.80GHz, 2 vCPU
1. Сколько сравнений (__eq__) уходит на один поиск
n | плохой хеш: первый | плохой хеш: последний | нормальный хеш: последний
------------------------------------------------------------------------------------
16 | 1 | 16 | 1
64 | 1 | 64 | 1
256 | 1 | 256 | 1
1024 | 1 | 1024 | 1
4096 | 1 | 4096 | 1
2. Время поиска последнего ключа, нс (иллюстрация к таблице выше)
n | плохой хеш | нормальный хеш | во сколько раз
--------------------------------------------------------------
16 | 2051.5 | 263.5 | 7.8x
64 | 7523.0 | 248.1 | 30.3x
256 | 30287.2 | 264.7 | 114.4x
1024 | 129091.8 | 267.6 | 482.5x
4096 | 484452.8 | 266.5 | 1817.7x
3. Цена самого ключа при отсутствии коллизий, нс на поиск
int 45.4
str (кеш хеша в объекте) 37.1
объект без __hash__/__eq__ (по id) 35.5
объект с __hash__/__eq__ на Python 143.6
Что показал прогон
Блок 1 — главный, и он без единой наносекунды. Число сравнений при плохом
хеше равно числу ключей: 16 → 16, 4096 → 4096. При нормальном хеше — одно
сравнение на любом размере. Это O(n) и O(1) буквально, счётчиком.
Асимметрия «первый — последний» объясняет, почему худший случай легко проглядеть: первый вставленный ключ находится за одно сравнение даже при полностью вырожденном хеше, потому что он лежит в начале цепочки проб. Плохой хеш проявляется на последнем вставленном.
Блок 2 подтверждает блок 1 по форме, а не по числу. Время плохого хеша при росте n в 256 раз выросло примерно в 236 раз (2051 → 484 452 нс на этом прогоне), время нормального — в 1,0 раза. Отношение внутри строки («во сколько раз») — то, что воспроизводится; абсолютные наносекунды привязаны к машине.
Блок 3 — цена протокола, а не коллизий. Здесь коллизий нет вовсе, и всё
равно объект с __hash__/__eq__ на Python обходится вчетверо дороже объекта
без них: у int и str хеширование — это код на C, у пользовательского класса
— вызов функции Python на каждом поиске.
Строка int в этом прогоне вышла медленнее строки str (45,4 против
37,1 нс), и это не ошибка замера: словарь со строковыми ключами идёт по
специализированной lookdict_unicode, а словарь с целыми — по общей
lookdict. Плюс у str хеш посчитан один раз и лежит в самом объекте.
Чего в скрипте нет
Времени вставки и роста таблицы — это в dict_bench.py. Здесь только поиск.
Числа блоков 1 и 2 сняты на классах со счётчиком внутри __hash__/__eq__,
то есть завышены на цену инкремента; сравнивать их с блоком 3 (там счётчиков
нет) нельзя — сравнивать можно только внутри блока.
Script
169 lines"""Раскладка словаря, пороги роста и то, что делают с ней удаления.
ЗАЧЕМ ЭТО МЕРИТЬ. Про словарь обычно знают два факта: «поиск за O(1)» и «с 3.7
порядок гарантирован». Ни один из них не объясняет, почему словарь, из
которого удалили всё, продолжает занимать тридцать семь килобайт, — а именно
на это натыкаются в работе.
Здесь пять блоков, и каждый отвечает на один вопрос:
1. КАК УСТРОЕНА ТАБЛИЦА — во что обходится разреженный `dk_indices` против
наивной таблицы записей и откуда берутся «на 20–25 % меньше памяти».
2. ШИРИНА ИНДЕКСА — сколько байт занимает один слот `dk_indices`. Она
зависит от `dk_size`, и наивный расчёт «по два байта всегда» даёт
неверные числа на больших таблицах.
3. РОСТ — где именно словарь пересобирается и во сколько раз.
4. УДАЛЕНИЯ — что размер не возвращается, и что вернуть его можно только
пересборкой.
5. РЕЖИМ СТРОКОВЫХ КЛЮЧЕЙ — экономия из 3.11, когда все ключи строки.
ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Времени. Все утверждения статьи про словарь — про
раскладку и байты, а не про наносекунды, и мерить время значило бы добавить
числа, которые ничего в статье не подпирают. Общее правило замеров про
несравнимость версий по времени поэтому здесь не задействовано: сравнивать
нечего.
ПОЧЕМУ ЭТОТ СКРИПТ ПОЯВИЛСЯ ПОЗЖЕ СТАТЬИ. Числа статьи считались вручную и
скрипта не имели вовсе — то есть перезапустить их было нечем. Это ровно то,
что запрещает главное правило замеров: числа в статьях берутся из скриптов,
а не из головы.
"""
import sys
import sysconfig
PTR = sysconfig.get_config_var("SIZEOF_VOID_P") or 8
def rule(title):
print()
print(title)
print("-" * len(title))
# ---------------------------------------------------------------- 1. раскладка
rule("1. Разреженный dk_indices против наивной таблицы записей")
# Запись общего вида PyDictKeyEntry — три указателя: hash, key, value.
ENTRY_GENERAL = 3 * PTR
# Запись строкового режима PyDictUnicodeEntry — два: key, value. Хеш не
# хранится, он уже закеширован в самом строковом объекте.
ENTRY_UNICODE = 2 * PTR
def index_width(dk_size):
"""Сколько байт занимает один слот dk_indices при данном dk_size.
Ширина растёт вместе с таблицей: в слот надо уместить номер записи.
Правило записано в Objects/dictobject.c рядом с DK_ENTRIES.
"""
if dk_size <= 0xFF:
return 1
if dk_size <= 0xFFFF:
return 2
if dk_size <= 0xFFFFFFFF:
return 4
return 8
def usable_fraction(dk_size):
"""USABLE_FRACTION из Objects/dictobject.c: (n << 1) / 3."""
return (dk_size << 1) // 3
# Лестница целиком: экономия не постоянна, она ступенчато падает вместе с
# шириной слота dk_indices. Три точки этого не показывают — нужна вся линия,
# иначе «на 20–25 % меньше памяти» читается как свойство словаря вообще.
for dk_size in (8, 32, 128, 256, 1024, 4096, 16384, 65536, 262144, 1048576):
usable = usable_fraction(dk_size)
width = index_width(dk_size)
naive = dk_size * ENTRY_GENERAL # запись в каждом слоте
compact = dk_size * width + usable * ENTRY_GENERAL
saved = (naive - compact) / naive * 100
print(
f" dk_size={dk_size:>6} полезных записей={usable:>6} "
f"индекс={width} Б наивно={naive:>9} Б компактно={compact:>9} Б "
f"экономия {saved:.1f} %"
)
print(" (ENTRY_GENERAL =", ENTRY_GENERAL, "Б, ENTRY_UNICODE =", ENTRY_UNICODE, "Б)")
# Формулу выше нельзя оставлять на веру: она вся держится на index_width, а
# именно там легко ошибиться на порядок — ширина индекса растёт ступенями, и
# «два байта всегда» даёт правдоподобное, но неверное число на больших
# таблицах. Поэтому она сверяется с тем, что говорит сам интерпретатор.
rule("1b. Проверка формулы против sys.getsizeof")
DICT_HEADER = 48 # PyDictObject
KEYS_HEADER = 48 # PyDictKeysObject
def predict(dk_size, entry_size):
return DICT_HEADER + KEYS_HEADER + dk_size * index_width(dk_size) + usable_fraction(dk_size) * entry_size
print(" ключи целые (общий режим):")
ok = True
for n in (5, 10, 21, 42, 85, 170):
d = {i: i for i in range(n)}
dk_size = 8
while usable_fraction(dk_size) < n:
dk_size <<= 1
got = sys.getsizeof(d)
want = predict(dk_size, ENTRY_GENERAL)
mark = "==" if got == want else "!="
ok &= got == want
print(f" {n:>4} ключей dk_size={dk_size:>5} getsizeof={got:>7} формула={want:>7} {mark}")
print(" формула совпадает с интерпретатором:", ok)
# ------------------------------------------------------------------- 2. рост
rule("2. Где словарь пересобирается")
d = {}
prev = sys.getsizeof(d)
changes = []
for i in range(200):
d[i] = i
now = sys.getsizeof(d)
if now != prev:
changes.append((len(d), prev, now))
prev = now
print(" вставок -> размер до и после пересборки:")
for n, before, after in changes:
print(f" на {n:>4}-й вставке {before:>7} -> {after:>7} Б")
# --------------------------------------------------------------- 3. удаления
rule("3. Удаления ёмкость не возвращают")
big = {i: i for i in range(1000)}
size_full = sys.getsizeof(big)
for i in range(999):
del big[i]
size_after_del = sys.getsizeof(big)
rebuilt = dict(big)
size_rebuilt = sys.getsizeof(rebuilt)
print(f" 1000 ключей {size_full:>7} Б")
print(f" остался 1 ключ, размер {size_after_del:>7} Б <- не изменился")
print(f" после dict(d) {size_rebuilt:>7} Б")
empty = {i: i for i in range(1000)}
empty.clear()
print(f" после clear() на таком же {sys.getsizeof(empty):>7} Б")
# ---------------------------------------------------- 4. режим строковых ключей
rule("4. Режим строковых ключей (3.11+)")
str_keys = dict.fromkeys("abcdefg")
mixed = dict.fromkeys("abcdefg")
mixed[1] = None
print(f" dict.fromkeys('abcdefg') {sys.getsizeof(str_keys):>5} Б")
print(f" то же плюс один нестроковый ключ {sys.getsizeof(mixed):>5} Б")
# -------------------------------------------------------------- 5. про хеши
rule("5. Хеш")
print(" sys.hash_info.algorithm :", sys.hash_info.algorithm)
print(" PYTHONHASHSEED влияет на str/bytes; для int hash(n) == n при малых n:")
print(" hash(1), hash(2), hash(-1) =", hash(1), hash(2), hash(-1))
print(" PY", sys.version.split()[0])