Swiss Tables в Go 1.24
В Go 1.24 мапы получили новую реализацию на основе Swiss Tables.
Как было раньше: bucket-based map
Вплоть до Go 1.23 стандартная map в Go реализовывалась как хеш-таблица с цепочками коллизий на основе связанных списков.
Структура данных
- Хеш-таблица разделена на бакеты (buckets), каждый из которых вмещает до 8 пар ключ–значение.
- Бакеты организованы в два массива: один - для хеш-префиксов (
tophash), второй - для самих данных (keys/values), хранящихся интерливом (interleaved layout). Это улучшает кэш-локальность при последовательном сканировании.
Почему именно 8? Это компромисс между локальностью данных и издержками на рост таблицы. При среднем заполнении >6.5 элементов на бакет запускается инкрементальная эвакуация (incremental grow), чтобы сохранить амортизированную сложность O(1).
Алгоритм вставки
- Ключ проходит через хеш-функцию (
memhash,strhashи т.д. - в зависимости от типа). - Остаток от деления хеша на число бакетов (
hash % nbuckets) даёт индекс целевого бакета. - Внутри бакета:
- Если ячейка свободна - записываем туда хеш-префикс (
tophash = hash >> (64-8)) и пару ключ–значение. - Если ячейка занята, но с тем же
tophash- проверяем полное равенство ключей. - При коллизии и переполнении бакета (>8 элементов) создаётся оверфлоу-бакет, связанный через указатель - получается linked list оверфлоу-бакетов.
- Если ячейка свободна - записываем туда хеш-префикс (
Проблемы старой реализации
Плохая локальность при поиске
В худшем случае приходится «прыгать» по памяти через оверфлоу-бакеты. Это разрушает spatial locality и плохо ложится на CPU prefetcher.Неэффективное использование кэш-линий
При поиске в бакете нужно последовательно проверять до 8 ключей. Каждое сравнение загружает ключ целиком, хотя часто достаточно лишь нескольких байт для отсечения.Фрагментация памяти
Оверфлоу-бакеты аллоцируются отдельно, что увеличивает давление на аллокатор и снижает плотность данных.
Как стало теперь: Swiss Tables
Начиная с Go 1.24, стандартная map перешла на реализацию на основе Swiss Tables - структуры, придуманной в Google и впервые применённой в Abseil (C++), а позже - в Rust (hashbrown), Java (fastutil) и Python (начиная с 3.6 для dict, в модифицированном виде).
Дизайн-обсуждение: https://github.com/golang/go/issues/54766
Почему не раньше? Swiss Tables заметно сложнее в реализации, особенно в контексте Go: нужно аккуратно управлять памятью, не хранить указатели в метаданных (совместимость с GC), и сохранить корректное поведение при конкурентном доступе. Только к 2025 году инфраструктура рантайма Go стала достаточно зрелой для безопасного внедрения.
Основные идеи Swiss Tables
1. Группы вместо бакетов
Память делится на группы по 8 слотов, выровненные по границе кэш-линии (64 байта). Каждая группа - это единый плотный блок, содержащий:
- 8 × 1-байтных control bytes
- 8 × пар ключ–значение (интерливом)
2. Control byte - ключ к скорости
Для каждого слота хранится 1 байт:
0x80- слот пуст (empty)0x00- слот удалён (tombstone)0x01..0x7F- значениеh2(младшие 7 бит хеша)
Это позволяет загружать control-байты группы одним чтением и выполнять SIMD-сканирование:
- x86-64:
PSHUFB - ARM64:
tbl
За одну инструкцию можно проверить сразу 8 слотов.
3. Поиск без ветвлений
При поиске ключа:
- Вычисляем хеш ключа.
- Делим его на:
h1 = hash >> 7h2 = hash & 0x7F
- Начальная группа определяется как
h1 % num_groups. - Далее выполняется линейное пробирование (linear probing):
- Внутри группы SIMD-ом ищутся слоты с совпадающим
h2. - При совпадении проверяется полное равенство ключа.
- Если встречается
empty-слот - поиск завершается: ключа в таблице нет.
- Внутри группы SIMD-ом ищутся слоты с совпадающим
Никаких связанных списков. Никаких оверфлоу-бакетов. Только плотные массивы и последовательный доступ к памяти.
Алгоритм вставки
- Хешируем ключ → получаем 64-битный
hash. - Вычисляем
h1иh2. - Определяем стартовую группу.
- В цикле:
- Загружаем control-байты группы.
- SIMD-поиском находим:
- либо слот с совпадающим
h2 - либо первый
emptyслот
- либо слот с совпадающим
- При совпадении
h2проверяем ключ и обновляем значение. - При
empty- вставляем новый элемент и записываемh2в control byte. - Если группа полностью занята - переходим к следующей.
Цена такого подхода - чуть более сложная логика вставки и роста таблицы, но она с лихвой окупается кэш-локальностью.
Преимущества
| Критерий | Старая реализация | Swiss Tables |
|---|---|---|
| Поиск (hit) | 10–30 ns | 4–8 ns |
| Вставка (new key) | 25–60 ns | 10–20 ns |
| Локальность | Средняя | Высокая |
| Использование памяти | Оверфлоу-бакеты | Минимум фрагментации |
| SIMD | Нет | Да (x86 / ARM64) |
По данным релиз-ноута Go 1.24:
map[int]intget: ~+210% throughputmap[string]intset: ~+170% throughput- Пиковое потребление памяти: ~-12% на больших мапах
Что не изменилось?
- Интерфейс:
make(map[K]V),m[k] = v,delete(m, k)- без изменений. - Порядок итерации по-прежнему не определён и может отличаться между запусками.
mapостаётся не потокобезопасной: гонки приводят к панике или неопределённому поведению.- Внутреннее представление
mapпо-прежнему не является стабильным ABI дляunsafe-кода.