В Go 1.24 встроенная реализация map была полностью переработана и теперь основана на Swiss Table. В этой статье мы рассмотрим, какие преимущества даёт Swiss Table по сравнению с традиционными хеш‑таблицами.
В приведённом выше графике мы видим заметно различающиеся модели потребления памяти между SwissMap и встроенной картой (map) в Go. Для сравнения также включено потребление памяти массивом, хранящим тот же набор данных. Потребление памяти стандартной реализации структуры данных map — выглядит как ступенчатая функция, поскольку она всегда создаётся с числом бакетов, равным степени двойки. Это связано с классической оптимизацией, основанной на побитовых операциях.
Любой поиск в хеш‑таблице (как с открытым, так и с закрытым хешированием) должен выбрать начальную позицию для своей последовательности пробирования на основе хеша запрашиваемого ключа. Преобразование хеш‑значения в номер бакета или слота обычно выполняется с помощью операции нахождения остатка от деления. Однако операция остатка от деления (%) довольно затратна по числу тактов процессора. Но если делитель — это степень двойки, то операцию% можно заменить на сверхбыструю битовую маску младших n битов. По этой причине многие, если не большинство, хеш‑таблиц ограничены размерами, кратными степеням двойки.
Часто это создаёт незначительный избыточный расход памяти, но при выделении хеш‑таблиц с миллионами элементов влияние становится ощутимым! Как показано на графике выше, стандартная реализация map в Golang в среднем потребляет на 63% больше памяти, чем SwissTable!
Немного истории
Концепция хеш‑таблицы впервые была описана Хансом Петером Луном в 1953 году во внутренней записке IBM. В ней предлагалось ускорить поиск, распределяя элементы по «бакетам» и используя связанный список для разрешения коллизий, когда бакет уже содержит элемент. Сегодня такой метод известен как хеш‑таблица с цепочечным хешированием (chaining).
В 1954 году Джин А. Амдал, Элейн М. Макгроу и Артур Л. Самуэль впервые применили метод «открытой адресации» при программировании компьютера IBM 701. При этом, если бакет уже занят, новый элемент помещается в следующий свободный бакет. Этот метод был формализован и опубликован в 1957 году У. Уэсли Петерсоном в статье «Addressing for Random‑Access Storage». Сегодня это называется открытой адресацией с линейным пробированием (linear probing).
В 2017 году Сэм Бензакен, Алкис Евлогименос, Мэтт Кулукундис и Роман Перепелица из Google представили новый дизайн хеш‑таблиц на C++, названный «Swiss Tables». В 2018 году их реализация была открыта в библиотеке Abseil C++.
Основное отличие в дизайне
Ключевое различие между классической реализацией map в Golang и SwissMap заключается в методе хеширования.
Классическая реализация использует «открытое хеширование» (open hashing), где пары ключ‑значение с одинаковыми хешами группируются в один «бакет». Чтобы найти значение по ключу, сначала выбирается бакет на основе хеша ключа, а затем происходит итерация по всем парам ключ‑значение в этом бакете, пока не будет найдено совпадение.
Одной из ключевых оптимизаций встроенной map в Go является использование «дополнительных битов хеша» (extra hash bits), которые позволяют ускорить проверку равенства при итерации по слотам в бакете.
Перед тем как напрямую сравнивать запрашиваемый ключ с кандидатом, алгоритм поиска сначала сравнивает 8-битный хеш каждого ключа (он независим от хеша бакета), чтобы определить, возможен ли положительный результат. Этот быстрый предварительный тест имеет ложноположительный результат в 1 из 256 случаев, но значительно ускоряет поиск внутри бакета хеш‑таблицы.
Swiss Tables относятся к классу хеш‑таблиц с открытой адресацией (open‑addressed hash table).
В хеш‑таблицах с открытой адресацией все элементы хранятся в едином массиве. Каждое место в этом массиве называется слотом.
Слот, в который должен попасть ключ, определяется хеш‑функцией hash(key).
-
Хеш‑функция отображает каждый ключ в целое число,
-
Один и тот же ключ всегда получает одно и то же число,
-
Разные ключи в идеале должны равномерно распределяться по диапазону чисел.
Ключевая особенность хеш‑таблиц с открытой адресацией — способ разрешения коллизий.
-
Если слот уже занят (коллизия), ключ помещается в другое место в массиве.
-
Для этого используется последовательность пробирования (probe sequence) — поиск ближайшего свободного слота.
-
Поиск продолжается, пока не найдётся пустой слот.
Вот как это работает:
Предположим, у нас есть хеш‑таблица с 16 слотами. В таблице уже хранятся несколько ключей:

Теперь попробуем вставить новый ключ 98.
-
Вычисляем hash(98)% 16 = 7.
-
Слот 7 пуст, поэтому просто вставляем 98.
Теперь попробуем вставить ключ 25.
-
hash(25)% 16 = 3.
-
Слот 3 уже занят (ключ 56) → произошла коллизия.
Линейное пробирование (Linear probing)
Для поиска свободного слота мы используем линейное пробирование — оно просто проверяет следующие слоты по порядку.
-
Слот 3 занят (ключ 56).
-
Слот 4 занят (ключ 32).
-
Слот 5 занят (ключ 21).
-
Слот 6 свободен → вставляем ключ 25.
При поиске ключа 25 алгоритм действует так же:
-
Начинаем с слота 3 → там ключ 56.
-
Идём дальше по слоту 4 → 32.
-
Дальше по слоту 5 → 21.
-
Наконец, в слоте 6 находим ключ 25.
Увеличение размера хеш-таблицы
В примере выше у нас было 16 слотов. А что, если мы вставим больше 16 элементов?
Когда хеш‑таблица заканчивает свободные слоты, она увеличивает размер, обычно удваивая массив. Все существующие элементы перехешируются и заново вставляются в новый массив. Однако таблицы с открытой адресацией не ждут полного заполнения массива, прежде чем расширяться.
Почему?
-
Чем больше заполнен массив, тем длиннее средняя последовательность пробирования.
-
В примере с ключом 25 нам пришлось пройти 4 слота, чтобы найти пустой.
-
Если останется только 1 пустой слот, в худшем случае придётся сканировать весь массив O(n).
Чтобы этого избежать, хеш‑таблицы вводят порог заполненности (load factor).
-
Обычно он составляет 70–90%.
-
Когда количество занятых слотов превышает этот порог, таблица расширяется, чтобы избежать слишком длинных последовательностей пробирования.
Swiss Table
Swiss Table — это усовершенствованная версия хеш‑таблицы с открытой адресацией. Давайте разберёмся, чем она лучше классической реализации.
Как и прежде, данные хранятся в едином массиве. Однако теперь массив разбивается на логические группы по 8 слотов (возможны и более крупные размеры групп).
Кроме того, каждая группа сопровождается 64-битным управляющим словом (control word), которое содержит метаданные.
-
Каждый из восьми байт управляющего слова соответствует одному из 8 слотов в группе.
-
Значение каждого байта указывает, является ли слот пустым, удалённым или занятым.
-
Если он используется, байт содержит 7 младших бит хеша для ключа этого слота (так называемого h2).

Вставка работает следующим образом
-
Вычисляем хеш (hash(key)) и разбиваем его на две части: верхние 57 бит (называемые h1) и нижние 7 бит (называемые h2).
-
Верхние биты (h1) используются для выбора первой группы для рассмотрения: h1% 2 в данном случае, так как есть только 2 группы.
-
Внутри группы все слоты равноправны для хранения ключа. Сначала мы проверяем, содержит ли какой‑либо слот уже этот ключ — если да, то это обновление, а не новая вставка.
-
Если ни один слот не содержит ключ, то ищем пустой слот для его размещения.
-
Если свободных слотов нет, продолжаем последовательность пробирования, переходя к следующей группе.
Шаг 3 — это момент, когда происходит «магия» Swiss Table. Нам нужно проверить, содержит ли какой‑либо слот в группе искомый ключ. Наивный подход — выполнить линейный поиск и сравнить все 8 ключей. Однако управляющее слово (control word) позволяет сделать это более эффективно.
Каждый байт в управляющем слове содержит нижние 7 бит хеша (h2) для соответствующего слота. Если мы определим, какие байты в управляющем слове содержат искомый h2, у нас появится набор кандидатов на совпадение.
Другими словами, мы хотим выполнить побайтовое сравнение на равенство внутри управляющего слова. Например, если мы ищем ключ 32, у которого h2 = 89, то операция, которую мы хотим выполнить, будет выглядеть следующим образом.

Только во втором слоте (значение 89) возможно совпадение.
Эта операция выполняется аппаратно с помощью SIMD (Single Instruction, Multiple Data).
-
Одна операция проверяет все 8 слотов параллельно.
-
Если SIMD недоступен, алгоритм использует комбинацию обычных арифметических и битовых операций.
В результате мы получаем набор кандидатных слотов.
-
Слоты, где h2 не совпадает, не могут содержать нужный ключ, поэтому их можно пропустить.
-
Слоты, где h2 совпадает, являются потенциальными совпадениями, но нам всё равно нужно сравнить весь ключ, так как возможны коллизии (вероятность коллизии для 7-битного хеша — 1/128, что довольно мало).
Эта операция очень мощная, так как мы фактически выполняем 8 шагов последовательного поиска одновременно, в параллельном режиме. Это ускоряет поиск и вставку, уменьшая среднее число необходимых сравнений.
Итоги
Такое улучшение процесса пробирования позволило увеличить максимальный коэффициент загрузки для хеш‑таблиц Swiss Table по сравнению с предыдущими реализациями, что снижает средний объем потребляемой памяти.
-
Операции чтения и записи в map с более чем 1024 элементами стали быстрее примерно на 30%.
-
Запись в заранее выделенные map стала быстрее примерно на 35%.
-
Итерация ускорилась в среднем на 10%, а для map с небольшим числом записей относительно размера — до 60%.
Вы можете отключить новую реализацию, установив GOEXPERIMENT=noswissmap во время сборки. Это актуально для старых процессоров в которых не реализован SIMD
источники:
https://www.dolthub.com/blog/2023–03–28-swiss‑map/
https://medium.com/@lordmoma/go-1–24s‑swiss‑tables‑the‑silver‑bullet‑or‑just‑a-shiny‑new‑gadget-8e5f7f37c2a8
https://go.dev/blog/swisstable
ссылка на оригинал статьи https://habr.com/ru/articles/890570/
Добавить комментарий