Содержание
1. Эксперимент с производительностью
-
2. Хэши и равенство (equality)
2.1 Коллизии хэша
-
3. Хэш-таблицы в
set3.1. Алгоритм хэш-таблицы
3.2. Практические выводы по тому как работает
set
-
4. Хэш-таблицы в
dict
4. Хэш-таблицы в dict
Пусть ваши хэши будут уникальными,
у ваших ключей редко возникают коллизии,
а ваши словари всегда будут упорядочены(6).— Brandon Rhodes
The Dictionary Even Mightier
С 2012 года в реализации типа dict было проведено две крупные оптимизации для сокращения потребления памяти. Первая была предложена в PEP 412 - Key-Sharing Dictionary и реализована в Python 3.3(7). Вторая называется компактный dict и появилась в Python 3.6. Побочным эффектом оптимизации памяти компактного dict стало сохранение порядка вставки ключей. В следующих разделах мы обсудим компактный dict и новую схему совместного использования ключей, именно в таком порядке, для удобства изложения.
4.1. Как компактный dict экономит место и сохраняет порядок
? Это высокоуровневое объяснение реализации
dictв Python. Одно из отличий заключается в том, что фактическая полезная доля хэш-таблицыdictсоставляет ⅓, а не ⅔ как вset. Фактическая доля ⅓ потребовала бы 16bucketsдля хранения 4 элементов в моем примереdict, и диаграммы в этом разделе стали бы слишком большими, поэтому я делаю вид, что полезная доля равна ⅔ в этих объяснениях. Один из комментариев в Objects/dictobject.c объясняет, что любая дробь между ⅓ и ⅔ «кажется, хорошо работает на практике».
Рассмотрим dict, содержащий сокращенные названия дней недели с 'Mon' по 'Thu' и количество студентов, записанных на занятия по плаванию в каждый день:
>>> swimmers = {'Mon': 14, 'Tue': 12, 'Wed': 14, 'Thu': 11}
До оптимизации компактного dict хэш-таблица, лежащая в основе словаря swimmers, выглядела бы как на рис. 9. Как видите, в 64-битном Python каждый bucket содержит три 64-битных поля: хэш-код ключа, указатель на объект ключа и указатель на объект значения. Это 24 байта на bucket.

dict с 4 парами ключ-значение. Каждый bucket представляет собой структуру с хэш-кодом ключа, указателем на ключ и указателем на значениеПервые два поля играют ту же роль, что и в реализации set. Чтобы найти ключ, Python вычисляет хэш-код ключа, извлекает индекс из ключа, а затем просматривает хэш-таблицу, чтобы найти bucket с подходящим хэш-кодом и подходящим ключевым объектом. Третье поле обеспечивает основную функцию dict: сопоставление ключа с произвольным значением. Ключ должен быть хэшируемым объектом и алгоритм хэш-таблицы гарантирует, что он будет уникальным в dict. Но значение может быть любым объектом — оно не обязательно должно быть хэшируемым или уникальным.
Raymond Hettinger заметил, что можно добиться значительной экономии, если хэш-код, указатель на ключ и значение хранить в массиве entries, в котором нет пустых строк, а сама хэш-таблица будет представлять собой разреженный массив с гораздо меньшими buckets, содержащими индексы в массиве entries(8). В своем оригинальном сообщении на python-dev Hettinger назвал хэш-таблицу indices (индексы). Ширина buckets в indices меняется по мере роста dict, начиная с 8 бит на bucket (достаточно для индексации до 128 записей), при этом для специальных целей оставляются отрицательные значения, такие как -1 для пустых и -2 для удаленных.
В качестве примера, dict swimmers будет храниться в таком виде, как показано на рис. 10.

dict с 4 парами ключ-значение. Хэш-коды, указатели на ключи и значения хранятся в порядке вставки в массиве entries, а смещения записей, полученные из хэш-кодов, хранятся в разреженном массиве indices, где значение индекса -1 означает пустой bucketПредполагая 64-битную сборку CPython, наш 4-элементный dict swimmers занял бы 192 байта памяти в старой схеме: 24 байта на bucket, умноженные на 8 строк. Эквивалентный компактный dict использует 104 байта: 96 байт в записях (24 * 4), плюс 8 байт для buckets в индексах, сконфигурированных как массив из 8 байт.
В следующем разделе описывается, как используются эти два массива.
4.2 Алгоритм добавления элементов в компактный dict
Шаг 0: установите индексы
Таблица индексов изначально задается в виде массива байтов со знаком с 8 buckets, каждый из которых инициализируется значением -1, что означает "пустой bucket". До 5 из этих buckets в конечном итоге будут содержать индексы строк в массиве entries, оставляя ⅓ из них с -1. Другой массив, entries, будет содержать данные ключ/значение с теми же тремя полями, что и в старой схеме, но в порядке вставки.
Шаг 1: вычисление хэш-кода для ключа
Чтобы добавить пару ключ-значение ('Mon', 14) в dict swimmers, Python сначала вызывает hash('Mon'), чтобы вычислить хэш-код этого ключа.
Шаг 2: поиск записей по индексам
Python вычисляет hash('Mon') % len(indices). В нашем примере это 3. Смещение 3 в indices содержит -1, это пустой bucket.
Шаг 3: помещаем ключ-значение в записи, обновляя индексы
Массив entries пуст, поэтому следующее доступное смещение в нем — 0. Python помещает 0 по смещению 3 в indices и сохраняет хэш-код ключа, указатель на объект ключа 'Mon' и указатель на значение int 14 по смещению 0 в entries. На рис. 11 показано состояние массивов, когда значение swimmers равно {'Mon': 14}.
![Рис. 11. Компактное хранилище для {'Mon': 14}: indices[3] хранит смещение первой записи: entries[0] Рис. 11. Компактное хранилище для {'Mon': 14}: indices[3] хранит смещение первой записи: entries[0]](https://habrastorage.org/getpro/habr/upload_files/097/8c9/b03/0978c9b03da7eb1d6d2c0252194d1381.png)
{'Mon': 14}: indices[3] хранит смещение первой записи: entries[0]Шаги для следующего элемента
Добавить ('Tue', 12) к swimmers:
Вычислить хэш-код ключа
'Tue'.Вычислите смещение в индексах, как
hash('Tue') % len(indices). Это2. Вindices[2]есть-1. Пока что коллизий нет.Поместим следующее доступное смещение записи,
1, вindices[2], затем сохраним запись вentries[1].
Теперь состояние выглядит так, как показано на рис. 12. Обратите внимание, что entries хранит пары ключ-значение в порядке вставки.

{'Mon': 14, 'Tue': 12}.Шаги при коллизии
Вычислить хэш-код ключа
'Wed'.Теперь
hash('Wed') % len(indices)равно3. Вindices[3]есть0, указывающий на существующую запись. Посмотрите на хэш-код вentries[0]. Это хэш-код для'Mon', который, как оказалось, отличается от хэш-кода для'Wed'. Это несовпадение сигнализирует о коллизии. Проверить следующий индекс:indices[4]. Это-1, поэтому его можно использовать.Сделать
indices[4] = 2, потому что2— это следующее доступное смещение вentries. Затем заполнитеentries[2], как обычно.
После добавления ('Wed', 14) у нас есть рис. 13

{'Mon': 14, 'Tue': 12, 'Wed': 14}Как растет компактный dict
Напомню, что изначально buckets в массиве indices имеют размер 8 байтов со знаком, что достаточно для хранения смещений до 5 записей, при этом ⅓ buckets остаются пустыми. Когда в dict добавляется 6-й элемент, indices релоцируют 16 buckets — достаточно для 10 смещений. Размер indices удваивается по мере необходимости, сохраняя при этом байты со знаком, пока не придет время добавить 129-й элемент в dict. В этот момент массив indices содержит 256 8-битных байтов. Однако, одного байта со знаком недостаточно для хранения смещений после 128 записей, поэтому массив indices перестраивается на 256 16-битных buckets для хранения целых чисел со знаком, достаточно широких для представления смещений 32768 строк в таблице entries. Следующее изменение размера происходит при 171-м добавлении, когда indices становятся более чем на ⅔ заполненными. Тогда количество buckets в indices удваивается до 512, но каждый bucket по прежнему имеет ширину 16 бит. В общем, массив indices растет за счет удвоения числа buckets, а также — реже — за счет удвоения ширины каждого bucket, чтобы вместить растущее число строк в entries.
На этом мы закончим обзор реализации компактного dict. Я опустил много деталей, но теперь давайте рассмотрим другую оптимизацию для экономии места в dict — совместное использование ключей.
4.3. dict с общим доступом к ключам
Экземпляры пользовательских классов хранят свои атрибуты в атрибуте — __dict__(9), который представляет собой обычный dict. Экземпляр __dict__ сопоставляет имена атрибутов со значениями атрибутов. Чаще всего все экземпляры имеют одинаковые атрибуты с разными значениями. Когда это происходит, два из трех полей в таблице записей для каждого экземпляра имеют одинаковое содержимое: хэш-код имени атрибута и указатель на имя атрибута. Только указатель на значение атрибута отличается.
В PEP 412 - Key-Sharing Dictionary Mark Shannon предложил разделить хранение dict-ов, используемых в качестве экземпляров __dict__, таким образом, чтобы каждый хэш-код атрибута и указатель хранились только один раз, связанный с классом, а значения атрибутов хранились в параллельных массивах указателей, прикрепленных к каждому экземпляру.
Возьмем класс Movie, в котором все экземпляры имеют одинаковые атрибуты 'title', 'release', 'directors' и 'actors', на рис. 14 показана организация совместного использования ключей в разделенном dict — также реализованном с помощью новой компактной схемы.

__dict__ класса и трех экземпляровВ PEP 412 были введены термины combined-table для обсуждения старой схемы и split-table для предлагаемой оптимизации.
combined-table по-прежнему используется по умолчанию, когда вы создаете dict с помощью литерального синтаксиса или вызываете dict(). split-table dict создается для заполнения специального атрибута __dict__ экземпляра, когда он является первым экземпляром класса. Таблица ключей (рис. 14) затем кэшируется в объекте класса. Это позволяет использовать тот факт, по большей части в объектно-ориентированном коде Python все атрибуты экземпляра назначаются в методе __init__. Первый экземпляр (и все последующие) будет хранить только свой собственный массив значений. Если экземпляр получает новый атрибут, не найденный в таблице общих ключей, то __dict__ этого экземпляра преобразуется в форму combined-table. Однако если этот экземпляр является единственным в своем классе, то __dict__ преобразуется обратно в split-table, поскольку предполагается, что последующие экземпляры будут иметь тот же набор атрибутов и совместное использование ключей будет полезным.
Структура PyDictObject, представляющая dict в исходном коде CPython, одинакова как для dict с combined-table, так и для dict со split-table . Когда dict преобразуется из одной схемы в другую, изменения происходят в полях PyDictObject, с помощью других внутренних структур данных.
4.4. Практические последствия работы dict
Ключи должны быть хэшируемыми объектами. Они должны реализовывать соответствующие методы
__hash__и__eq__(«Python. К вершинам мастерства» 2-е изд. — Словари и множества — Что значит "хэшируемый"?, стр. 105).Поиск по ключу выполняется почти так же быстро, как поиск элементов в
set.Упорядочивание элементов сохраняется в таблице записей — это было реализовано в CPython 3.6, а в 3.7 стало официальной функцией языка.
Чтобы сэкономить память, не создавайте атрибуты экземпляра вне метода
__init__. Если все атрибуты экземпляра будут созданы в__init__, то в__dict__ваших экземпляров будет использоваться схемаsplit-table, разделяя те же индексы и массив ключевых записей, которые хранятся вместе с классом.
Примечания
Доклад на PyCon 2017: https://youtu.be/66P5FMkWoVU?t=56
Это было до того, как я начал писать 1-е издание Fluent Python, но я это пропустил.
Иронично, что
bucketsв хэш-таблице здесь не содержат хэш-кодов, а только индексы к массиву записей, где находятся хэш-коды. Но концептуально массив индексов действительно является хэш-таблицей в этой реализации, даже если в егоbucketsнет хэшей.
Если у класса нет атрибута
__slots__.
Комментарии (2)

Verstov
20.07.2024 15:51Хм, выходит можно и в set порядок вставки сохранить если чуть поменять реализацию
Andrey_Solomatin
Компактные словари портировали с pypy.