ЗАМЕР
bench/dict/hash_collisions.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 (там счётчиков
нет) нельзя — сравнивать можно только внутри блока.
Скрипт
185 строк"""Граница худшего случая у `dict`: что стоит за «`O(1)` вместо `O(n)`».
`O(1)` у словаря — амортизированная средняя при равномерном хеше. Худший
случай — `O(n)`, и он не гипотетический: достаточно `__hash__`, который для
всех ключей возвращает одно и то же. Тогда все ключи садятся в одну цепочку
проб, и поиск последнего вставленного ключа перебирает всю цепочку.
Скрипт меряет две вещи:
* **точно** — сколько раз вызвался `__eq__` на один поиск. Это счётчик, а не
время: число не зависит ни от машины, ни от сборки;
* **как иллюстрацию** — время поиска. Сравниваются только строки одного
прогона (правило замеров репозитория).
Плюс цена пользовательских `__hash__`/`__eq__` против встроенных ключей.
python3.13 bench/dict/hash_collisions.py
"""
import sys
import timeit
class Counter:
eq_calls = 0
hash_calls = 0
@classmethod
def reset(cls):
cls.eq_calls = 0
cls.hash_calls = 0
class Colliding:
"""Намеренно плохой `__hash__`: все объекты — в одну корзину."""
__slots__ = ("i",)
def __init__(self, i):
self.i = i
def __hash__(self):
Counter.hash_calls += 1
return 0
def __eq__(self, other):
Counter.eq_calls += 1
return isinstance(other, Colliding) and self.i == other.i
class Spread:
"""Тот же класс, но хеш различает ключи."""
__slots__ = ("i",)
def __init__(self, i):
self.i = i
def __hash__(self):
Counter.hash_calls += 1
return hash(self.i)
def __eq__(self, other):
Counter.eq_calls += 1
return isinstance(other, Spread) and self.i == other.i
def probe_counts(cls, n):
"""Сколько сравнений уходит на поиск первого и последнего ключа.
Ищется **новый равный объект**, а не тот же самый: CPython сначала
проверяет тождество (`startkey == key` идёт после `is`), и поиск того же
объекта завершился бы на первом же слоте независимо от хеша.
"""
d = {cls(i): i for i in range(n)}
out = {}
for label, i in (("первый вставленный", 0), ("последний вставленный", n - 1)):
key = cls(i)
Counter.reset()
assert d[key] == i
out[label] = (Counter.eq_calls, Counter.hash_calls)
return out
def lookup_time_ns(cls, n, repeat=5):
"""Время одного поиска последнего вставленного ключа, нс."""
d = {cls(i): i for i in range(n)}
key = cls(n - 1)
number = max(10, 200_000 // max(n, 1))
best = min(timeit.repeat(lambda: d[key], number=number, repeat=repeat))
return best / number * 1e9
def cost_of_key_kind():
"""Цена ключа: встроенные типы против пользовательских протоколов."""
n = 1000
cases = []
d_int = {i: i for i in range(n)}
last_int = n - 1 # ключ вычисляется заранее: меряем поиск
cases.append(("int", lambda: d_int[last_int]))
keys_str = [f"key-{i}" for i in range(n)]
d_str = {k: i for i, k in enumerate(keys_str)}
last_str = keys_str[-1]
cases.append(("str (кеш хеша в объекте)", lambda: d_str[last_str]))
class Plain:
__slots__ = ("i",)
def __init__(self, i):
self.i = i
objs = [Plain(i) for i in range(n)]
d_obj = {o: i for i, o in enumerate(objs)}
last_obj = objs[-1]
cases.append(("объект без __hash__/__eq__ (по id)", lambda: d_obj[last_obj]))
class PyHash:
__slots__ = ("i",)
def __init__(self, i):
self.i = i
def __hash__(self):
return hash(self.i)
def __eq__(self, other):
return isinstance(other, PyHash) and self.i == other.i
pyobjs = [PyHash(i) for i in range(n)]
d_py = {o: i for i, o in enumerate(pyobjs)}
probe_py = PyHash(n - 1) # новый равный объект: тождество не спасёт
cases.append(("объект с __hash__/__eq__ на Python", lambda: d_py[probe_py]))
out = []
for label, fn in cases:
number = 200_000
best = min(timeit.repeat(fn, number=number, repeat=5))
out.append((label, best / number * 1e9))
return out
def main():
print("PY", sys.version.split()[0])
print("Числа времени — только для сравнения строк внутри этого прогона.")
print()
print("1. Сколько сравнений (__eq__) уходит на один поиск")
print(f"{'n':>7} | {'плохой хеш: первый':>19} | {'плохой хеш: последний':>21}"
f" | {'нормальный хеш: последний':>26}")
print("-" * 84)
for n in (16, 64, 256, 1024, 4096):
bad = probe_counts(Colliding, n)
good = probe_counts(Spread, n)
print(f"{n:>7} | {bad['первый вставленный'][0]:>19} |"
f" {bad['последний вставленный'][0]:>21} |"
f" {good['последний вставленный'][0]:>26}")
print(" Плохой хеш: число сравнений равно числу ключей — это и есть O(n).")
print(" Нормальный хеш: одно сравнение независимо от размера — O(1).")
print()
print("2. Время поиска последнего ключа, нс (иллюстрация к таблице выше)")
print(f"{'n':>7} | {'плохой хеш':>14} | {'нормальный хеш':>16} | {'во сколько раз':>15}")
print("-" * 62)
base_bad = base_good = None
for n in (16, 64, 256, 1024, 4096):
t_bad = lookup_time_ns(Colliding, n)
t_good = lookup_time_ns(Spread, n)
if base_bad is None:
base_bad, base_good = t_bad, t_good
print(f"{n:>7} | {t_bad:>14.1f} | {t_good:>16.1f} | {t_bad / t_good:>14.1f}x")
print(f" рост плохого хеша от n=16 к n=4096: x{lookup_time_ns(Colliding, 4096) / base_bad:.1f}")
print(f" рост нормального за то же : x{lookup_time_ns(Spread, 4096) / base_good:.1f}")
print()
print("3. Цена самого ключа при отсутствии коллизий, нс на поиск")
for label, ns in cost_of_key_kind():
print(f" {label:<38} {ns:8.1f}")
print(" Пользовательский протокол оплачивается на каждом поиске: `__hash__`")
print(" на Python — это вызов функции там, где у int и str его нет вовсе.")
if __name__ == "__main__":
main()