Что такое resize и зачем он нужен
Hash-таблица фиксированного размера не может вместить произвольное число элементов. Когда вставленных пар становится больше threshold (Python: 2/3 от capacity), таблица должна расти — иначе деградирует до O(n). Эта операция называется resize (или rehash, как часто говорят, потому что приходится перехешировать).
Resize — самая дорогая операция в hash-таблице. Стоит O(n). Но amortized analysis (см. урок 5 модуля 8) скрывает её цену: на 1M вставок происходит ~10 resize’ов, в среднем O(1) на каждую вставку.
В этом уроке посмотрим на resize изнутри: что именно делает Python, как растёт capacity, и измерим стоимость.
Алгоритм resize в Python dict
Псевдокод resize (вольная адаптация Objects/dictobject.c):
def _resize(self):
# 1. Решаем новый размер
if self.n < 50_000:
new_capacity = self.capacity * 4
else:
new_capacity = self.capacity * 2
# округляем до следующей степени двойки
new_capacity = next_power_of_two(max(new_capacity, 8))
# 2. Allocate новый массив
new_indices = [-1] * new_capacity
new_entries = []
# 3. Перекладываем все существующие entries
for entry in self.entries:
if entry is not None and not entry.is_tombstone:
# хеш уже посчитан и закеширован — не пересчитываем
j = entry.me_hash & (new_capacity - 1)
perturb = entry.me_hash
# пробируем в новой таблице
while new_indices[j] != -1:
j = (5 * j + 1 + perturb) & (new_capacity - 1)
perturb >>= 5
new_indices[j] = len(new_entries)
new_entries.append(entry)
# 4. Заменяем массивы
self.indices = new_indices
self.entries = new_entries
self.capacity = new_capacity
Ключевые моменты:
-
Хеш не пересчитывается. Он закеширован в каждом entry с момента вставки. Это огромная экономия — для строк-ключей повторное хеширование стоило бы O(длины) на каждый ключ. Вместо этого просто меняется маска:
hash & (new_capacity - 1). -
Tombstone’ы удаляются. Resize — единственная операция, которая физически освобождает место от удалённых entries. После многократных insert/delete dict может «застрять» с большим числом tombstone’ов; resize чистит таблицу.
-
Probing-формула остаётся той же, просто размер маски меняется.
Почему ×4, а не ×2
Python для маленьких dict использует growth rate ×4. Для больших — ×2. Почему?
Маленькие dict растут быстрее, чтобы реже триггерить resize и сразу иметь большой запас.
Логика: маленький dict живёт коротко и часто, абсолютная цена ×4 памяти — мизерная (десятки КБ). Большой dict существует долго, занимает мегабайты — экономия памяти важнее, чем экономия одного-двух resize’ов.
Измеряем стоимость resize через timeit
Сравним среднее время вставки в dict разных размеров:
import timeit
# 10K вставок — за пределы ~6-7 resize'ов
t = timeit.timeit(
'd = {}; \n'
'for i in range(10_000): d[i] = i',
number=100
)
print(f"10K inserts (100 runs): {t/100*1000:.2f} ms per run")
# ~0.5-0.7 ms per run
# 1M вставок — пройдёт через ~10 resize'ов
t = timeit.timeit(
'd = {}; \n'
'for i in range(1_000_000): d[i] = i',
number=10
)
print(f"1M inserts (10 runs): {t/10*1000:.1f} ms per run")
# ~50-100 ms per run
# Среднее на одну вставку
# ~70 ns в обоих случаях — это и есть amortized O(1)
Что любопытно: per-insert время не зависит от размера. И на 10K, и на 1M вставок среднее одинаковое (~70 ns). Это и есть свидетельство amortized O(1).
Latency hike: одна большая вставка раз в N
Если измерить latency каждой отдельной вставки, видна интересная картина:
import time
n = 1_000_000
d = {}
latencies = []
for i in range(n):
start = time.perf_counter_ns()
d[i] = i
latencies.append(time.perf_counter_ns() - start)
# найдём топ-10 самых медленных вставок
sorted_lat = sorted(enumerate(latencies), key=lambda x: -x[1])[:10]
for i, lat in sorted_lat:
print(f"insert #{i}: {lat:,} ns")
Типичный output на M-class CPU:
insert #524288: 12,345,678 ns <- 12 ms — это resize при росте до 1M capacity
insert #262144: 6,123,456 ns <- 6 ms — resize при росте до 512K
insert #131072: 3,234,567 ns
insert #65536: 1,567,890 ns
insert #43690: 789,012 ns <- здесь growth rate переключился с ×4 на ×2
...
Видно: ~10 всплесков по 1-12 мс, каждый соответствует resize. Остальные ~999 990 вставок занимают по 50-100 ns. Эти редкие всплески не видны в timeit, потому что timeit усредняет.
Это важно для prod-кода: если у вас high-throughput сервис с tail latency требованиями, такие всплески могут нарушать SLA. Решение — pre-allocate dict или использовать структуру с предсказуемой latency.
Реальная picture вставок в dict — 99.99% быстрые, доля процента медленных из-за resize.
Кеширование хеша — ключ к быстрому resize
Без кеширования хеша resize был бы катастрофически дорогим:
# гипотетический сценарий БЕЗ кешированного хеша
def resize_no_cache(old_entries, new_capacity):
new_indices = [-1] * new_capacity
for entry in old_entries:
h = hash(entry.key) # ПЕРЕХЕШИРОВАНИЕ
...
Если ключ — длинная строка (например, UUID 36 символов), hash() стоит ~70 ns. На 1M ключей это 70 ms только на хешах. С кешированным хешем — 0 ns на этот шаг.
Python кеширует хеш в каждом entry в поле me_hash. При resize старые entries просто перекидываются в новый массив с пересчётом маски (hash & new_mask), но без hash().
Стоимость хеширования настолько важна, что строки в Python тоже кешируют свой хеш в поле ob_hash у PyUnicodeObject. Повторный hash() для той же строки — мгновенный.
s = "hello world" * 100 # 1100-байт строка
import time
# первый hash — медленный
start = time.perf_counter_ns()
hash(s)
print(f"first: {time.perf_counter_ns() - start} ns")
# ~700 ns
# второй — мгновенный
start = time.perf_counter_ns()
hash(s)
print(f"second: {time.perf_counter_ns() - start} ns")
# ~50 ns (только loop overhead)
Сложность resize в числах
На M-class CPU. Здесь только сам resize, без обычных вставок.
50 ms из 100 ms на 1M вставок — это половина общего времени уходит на resize’ы. Без amortization это была бы катастрофа. С amortization — нормальная работа.
Pre-allocation в Python (трюки)
В Java и Go есть прямой API: new HashMap<>(initial_capacity). В Python такого нет, но есть трюки:
# 1. dict comprehension — Python может выделить буфер сразу
n = 1_000_000
d = {i: i for i in range(n)} # ~80 ms
# vs
d = {}
for i in range(n):
d[i] = i # ~100 ms (на ~25% медленнее)
# 2. dict.fromkeys — то же самое
d = dict.fromkeys(range(n), 0) # ~80 ms
# 3. trick через копирование с заранее известным dict
d = dict(zip(range(n), range(n))) # ~100 ms
Comprehension и fromkeys дают экономию ~20% — это значимо для горячих участков кода.
Попробуй сам
import time
import sys
# 1. Постройте «timeline resize'ов»
d = {}
prev_size = sys.getsizeof(d)
resize_log = []
for i in range(2_000_000):
start = time.perf_counter_ns()
d[i] = i
elapsed = time.perf_counter_ns() - start
new_size = sys.getsizeof(d)
if new_size != prev_size:
resize_log.append((i, prev_size, new_size, elapsed))
prev_size = new_size
print(f"\nTotal resizes: {len(resize_log)}")
print(f"{'after_n':>10s} {'old_size':>10s} {'new_size':>10s} {'cost_ns':>15s}")
for i, old, new, cost in resize_log:
print(f"{i:>10d} {old:>10d} {new:>10d} {cost:>15,d}")
# 2. Сравните pre-allocated и наивный подходы
n = 1_000_000
start = time.perf_counter()
d1 = {}
for i in range(n):
d1[i] = i * 2
t1 = time.perf_counter() - start
start = time.perf_counter()
d2 = {i: i * 2 for i in range(n)}
t2 = time.perf_counter() - start
start = time.perf_counter()
d3 = dict.fromkeys(range(n), 0)
for i in range(n):
d3[i] = i * 2
t3 = time.perf_counter() - start
print(f"naive: {t1*1000:.0f} ms")
print(f"comprehension: {t2*1000:.0f} ms")
print(f"fromkeys+set: {t3*1000:.0f} ms")
# 3. Эффект tombstone'ов: вставка + удаление + вставка
n = 1_000_000
d = {}
# (a) вставить N, удалить все
for i in range(n):
d[i] = i
for i in range(n):
del d[i]
# теперь dict пустой, но capacity ещё большой
print(f"After clean: {len(d)} elements, {sys.getsizeof(d)} bytes")
# (b) вставить N снова
start = time.perf_counter()
for i in range(n):
d[i] = i
t = time.perf_counter() - start
print(f"Re-insert after clean: {t*1000:.0f} ms")
# обычно так же, как первый раз — resize при необходимости освобождает tombstone'ы
Главные практические выводы: (1) на 1M+ вставок ~50% времени уходит на resize’ы, поэтому pre-allocation через comprehension экономит 20%; (2) хеш ключа кешируется в каждом entry — resize не вызывает hash() повторно; (3) tombstone’ы от удалений освобождаются только при resize; (4) если ваш сервис чувствителен к tail latency, помните о редких всплесках 1-25 мс из-за resize.
В следующем уроке заглянем глубже в clustering и коллизии — что происходит, если ключи специально подобраны с одинаковыми хешами.