Deep Engineering

ЗАМЕР

bench/dict/dict_bench.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/data-structures/dict
Как запустить
python3.13 bench/dict/dict_bench.py

Запись прогона

Замеры для статьи «Словари изнутри»

Скрипт Что меряет
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 (там счётчиков нет) нельзя — сравнивать можно только внутри блока.

Скрипт

169 строк
"""Раскладка словаря, пороги роста и то, что делают с ней удаления.

ЗАЧЕМ ЭТО МЕРИТЬ. Про словарь обычно знают два факта: «поиск за 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])