Learning Platform
Глоссарий Troubleshooting
Урок 03.04 · 25 мин
Продвинутый
PEP 468Compact dictInsertion orderMemory optimizationdk_indicesdk_entries

Compact dict (PEP 468) и порядок вставки

В уроке M02-03 мы разобрали dict как hash table с perturbation probe. Но мы оставили одну деталь open: каков именно layout? До Python 3.6 это был наивный sparse table (один массив (hash, key, value) triples). С Python 3.6 — compact layout (PEP 468, идея Naoki Inada, реализация Inada + Hettinger): два массива (dk_indices + dk_entries), который экономит ~30-65% памяти и бесплатно preserves insertion order. Раймонд Хеттингер представил этот дизайн в talk «Modern Dictionaries» (PyCon 2017), и это до сих пор самая elegant memory-оптимизация в современной Python-кодовой базе.

После этого урока вы должны уметь нарисовать compact dict layout от руки, объяснить почему insertion order сохраняется бесплатно, и оценить memory savings.


Old layout (до Python 3.6) — naïve sparse table

До 3.6 dict использовал один большой sparse массив записей размера ~3·N (load factor ≤ 1/3). Каждая запись содержала (hash, key, value) triple = 3 указателя × 8 байт = 24 байта на slot, занят он или пуст.

slot 0: empty (24 bytes wasted)
slot 1: (hash_a, 'apple',  ptr_1)
slot 2: empty
slot 3: empty
slot 4: (hash_b, 'banana', ptr_2)
slot 5: empty
slot 6: (hash_c, 'cherry', ptr_3)
slot 7: empty

Для dict из 3 записей при load factor 1/3 нужно ~9 slot’ов (округлено до степени двойки = 16). Это 16 × 24 = 384 байта, при том что валидных данных всего 3 × 24 = 72 байта. Эффективность ~19%.

Хуже: итерация шла в hash order (порядок probing), который зависит от рандомизированного PYTHONHASHSEED. Каждый запуск программы давал разный порядок ключей. Это часто удивляло разработчиков: «почему dict.keys() непредсказуем?» — потому что hash function рандомизирована.

Old dict (pre-3.6): один sparse массив (hash, key, value)
slot 0empty (24B)Каждый slot - 24 байта (3 указателя), независимо занят он или пуст. Memory waste при load factor 1/3 - 67%.
slot 1('apple', 1)Hash, key pointer, value pointer - 24 байта. Если hash('apple') & mask = 1, запись здесь. Inserts в hash-order, не insertion-order.
slot 2empty (24B)Sparse table - load factor 1/3 максимум. Нет separation между метаданными (lookup) и данными (storage).
slot 3('banana', 2)Если hash('banana') & mask = 3, здесь. Iteration пройдёт slots последовательно: вернёт apple первым, banana вторым - в hash order, не insertion order.
slot 4empty (24B)Каждый empty slot всё ещё 24 байта в массиве. Память не возвращается аллокатору при delete - помечается как dummy.

Compact layout (PEP 468) — dk_indices + dk_entries

PEP 468 разделяет dict на два массива разного размера и плотности:

  • dk_indicessparse массив (размер ~3·N для load factor 2/3), содержит индексы в dk_entries. Это где идёт hash lookup и probing. Значения — int1/int2/int4/int8 (1, 2, 4 или 8 байт) в зависимости от размера dict (для маленького dict — 1 байт на index).
  • dk_entriesdense массив (hash, key, value) triples (24 байт × N), без holes (insertion-ordered).

Lookup алгоритм:

1. i = hash & mask
2. entry_idx = dk_indices[i]
3. if entry_idx == DKIX_EMPTY: not found; if collision -> probe next slot in dk_indices
4. else: ep = dk_entries[entry_idx]; check ep.hash == hash && ep.key == key

Cite: Objects/dictobject.c, PyDictKeysObject struct, макросы DK_LOG_SIZE, DK_SIZE, DK_IXSIZE (выбор 1/2/4/8-byte index в зависимости от размера).

Compact dict (3.6+): индексы (sparse) + entries (dense)
dk_indices (sparse, 1 byte per slot)[2, -, 0, -, 1, -, -, -]Sparse массив индексов в dk_entries. Размер ~3*N для load factor 2/3. Каждый slot - 1 байт для маленького dict, 2/4/8 байт для большего. '-' = DKIX_EMPTY = 0xFF (для int1).
dk_indices[i] -> entry_idx
dk_entries (dense, insertion order)[(h_apple, 'apple', 1), (h_banana, 'banana', 2), (h_cherry, 'cherry', 3)]Dense массив записей: ровно N entries, без holes, в порядке insertion. Каждая запись - (hash, key*, value*) = 24 байта. Iteration просто проходит этот массив sequentially.

Memory savings — конкретный расчёт

Для dict из 10 записей:

Old layout (pre-3.6):

  • table size = 32 slots (load factor 1/3, округлено до 2^5)
  • 32 × 24 = 768 байт для самой таблицы
  • Эффективность: 240 / 768 = 31%

Compact layout (3.6+):

  • dk_indices: 16 slots × 1 byte = 16 байт (load factor 2/3, размер 16 — следующая 2^k)
  • dk_entries: 10 × 24 = 240 байт
  • Total: 256 байт
  • Эффективность: 240 / 256 = 94%

Savings: 768 - 256 = 512 байт = ~67% для small dict. Для больших dict savings становятся меньше (dk_indices использует 2/4-byte indices), но всегда заметные (~30-50%).

import sys
print(sys.getsizeof({}))                       # ~64 байта (header only)
print(sys.getsizeof({'a': 1}))                 # ~184 байта (после первого insert)
print(sys.getsizeof({i: i for i in range(10)})) # значительно меньше чем 32 * 24 + header

Cite: Objects/dictobject.c, функции _PyDict_NewKeysForClass, dictresize. Структуры PyDictKeysObject, PyDictKeyEntry.


Insertion order — free side effect

dk_entries — sequential dense array, заполняется в порядке insert’а. Iteration по dict проходит dk_entries linearly (for k in d: это просто for-loop по dk_entries[0..n_used]). Insertion order сохраняется бесплатно — никакой extra structure (ordered linked list, как в Java LinkedHashMap), просто follow-the-storage-order.

d = {}
d['banana'] = 2
d['apple']  = 1
d['cherry'] = 3

print(list(d.keys()))    # ['banana', 'apple', 'cherry'] - insertion order
print(list(d.values()))  # [2, 1, 3]
NOTE

Python 3.6: insertion order — implementation detail (CPython 3.6 + PyPy 3.6+ сохраняли, но это не было гарантировано спецификацией). Python 3.7+: language guarantee — все compliant Python implementation должны preservирать insertion order. С этого момента можно полагаться на порядок при сериализации, JSON-выводе, тестировании.

При delete запись из dk_entries[i] помечается как dummy (entry hash = -1), но сам slot не сдвигается (иначе сломались бы индексы из dk_indices). Если dict стал sparse (много dummies), CPython compact’ит при следующем resize. Поэтому order между insert’ами сохраняется, но после delete + insert новый key добавляется в конец (а не на освобождённое место).


PEP 509 — ma_version_tag

Каждый dict содержит private поле ma_version_tag (64-bit). При любой mutation (insert, delete, update) оно incrementируется. Используется CPython attribute caches (LOAD_GLOBAL, LOAD_ATTR opcodes) для invalidation: bytecode interpreter кеширует результат предыдущего lookup, проверяет ma_version_tag — если не изменился, lookup re-using cached entry. См. PEP 509, внедрено в Python 3.6 одновременно с compact dict.

Ваш Python-код напрямую с ma_version_tag не взаимодействует, но эта оптимизация даёт ~10% speedup на attribute-heavy workloads — что-то знать стоит, если профилируете performance-critical код.


Когда compact layout не оптимален

1. Очень маленькие dicts (1-2 записи). Header overhead доминирует — savings минимальны.

2. Sparse-after-many-deletes. Если большой dict постоянно теряет записи через del, dk_entries накапливает dummies. CPython compact’ит при resize, но между resize’ами memory может быть waste’д.

3. Shared keys (PEP 412). Для атрибутов instance’ов CPython использует split-keys dict: keys array shared между всеми instance’ами одного класса; каждый instance имеет только values array. Это отдельная оптимизация от compact layout. Cite: PEP 412.


Cross-course context

Строковые функции ClickHouse: кодировки и хранение Колончатый формат: как упакованные данные ускоряют запросы

Ключевые выводы

  1. PEP 468 разделил layout на dk_indices (sparse, 1/2/4/8-byte indices) + dk_entries (dense, insertion-ordered triples). Реализация: Naoki Inada + Raymond Hettinger, Python 3.6.
  2. Memory savings ~30-65% vs old naïve sparse table: для 10-entry dict 768 → 256 байт (savings 67%). Эффективность с 31% до 94%.
  3. Insertion order — free side effect: dk_entries заполняется sequentially, iteration просто follow storage order. Python 3.7+ — language guarantee.
  4. PEP 509 ma_version_tag — incrementируется при mutation, используется attribute caches для invalidation. Даёт ~10% speedup на attribute-heavy workloads.
  5. Cite: Objects/dictobject.c, структура PyDictKeysObject, макросы DK_LOG_SIZE/DK_SIZE/DK_IXSIZE. PEP 468, talk «Modern Dictionaries» (Hettinger 2017).

В уроке M02-05 разберём set — тот же hash table без values, plus операции union/intersection/difference и big-O для них.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое PEP 468?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7