ЗАМЕР
bench/gomap/groupindex.go
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/go/data-structures/maps
- Прогон
- go1.24.7 linux/amd64, Intel Xeon 2.10GHz
- Как запустить
go run bench/gomap/layout.go # работает и из корня go run bench/gomap/nankey.go go run bench/gomap/insert.go go run bench/gomap/groupindex.go go run bench/gomap/controlword.go cd bench/gomap go test -run '^$' -bench . -benchmem . ./ab.sh
Запись прогона
Замеры для статьи «Карта в Go: Swiss Table, порядок обхода и цена промаха»
| Файл | Что делает |
|---|---|
layout.go |
восемь наблюдений без единого замера времени: карта как указатель, сколько бывает порядков обхода, nil-карта, почему нельзя взять адрес элемента, что делает delete с памятью, что даёт подсказка размера, порог 128 байт у ключа, какие типы годятся в ключи |
cost_test.go |
цена: поиск по типу ключа, промах против попадания, построение с подсказкой и без, обход, плотные ключи против среза |
nankey.go |
NaN как ключ: сколько записей после N вставок, что возвращает поиск, что делает delete и что — clear. Ни одного замера времени |
ab.sh |
старая реализация карты против новой на одной машине: GOEXPERIMENT=noswissmap |
contract.go, contract.sh |
что карта обещает: ноль вместо ошибки, форма с двумя результатами, nil-карта, и отдельно — что отвергает компилятор на несравнимом ключе |
controlword.go |
управляющее слово: четыре шага, которыми восемь сравнений становятся одним, на настоящих байтах и со сверкой с честным перебором |
insert.go |
путь вставки: как хеш делится на H1 и H2, где применяется каждая половина, пять сценариев вставки со сверкой с настоящей картой |
groupindex.go |
откуда берётся номер группы: таблица как массив групп, h1 & (N − 1) и какие именно биты хеша становятся индексом; что делает с номером рост таблицы; цена пересечения бит метки и номера |
growth.go |
расширение карты наблюдением за памятью, а не по описанию |
concurrent.go, race.sh |
что происходит при одновременном доступе без синхронизации |
practice.go |
ответы к задачам урока — прогоном, а не рассуждением |
Каталог — отдельный модуль Go, поэтому замеры запускаются из него:
go run bench/gomap/layout.go # работает и из корня
go run bench/gomap/nankey.go
go run bench/gomap/insert.go
go run bench/gomap/groupindex.go
go run bench/gomap/controlword.go
cd bench/gomap
go test -run '^$' -bench . -benchmem .
./ab.sh
layout.go помечен //go:build ignore — он package main, а рядом лежит
тест пакета gomap, и без метки go test ./... спотыкался бы о два пакета в
одном каталоге.
Что здесь важно прочитать правильно
Главное — не наносекунды. Оно в layout.go: порядок обхода не случаен, а
провёрнут; delete не возвращает память; подсказка размера уменьшает не
карту, а мусор. Это утверждения о поведении, и проверяются они сравнением, а
не секундомером.
По времени сопоставляются только строки внутри одного блока cost_test.go.
В каждом блоке все строки дают одинаковый результат, отличается лишь способ.
Блоки друг с другом не сопоставляются.
ab.sh — единственное место, где сравниваются две сборки, и это законно
ровно потому, что отличается один флаг. Компилятор, машина и сам замер те
же. Строка cpu: печатается для каждого прогона намеренно: на виртуальной
машине частота плавает, и если она в раундах разная, раунд надо выбросить, а
не считать разницу.
Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.10GHz)
Устройство
Из internal/abi и internal/runtime/maps:
| константа | значение | что это |
|---|---|---|
SwissMapGroupSlots |
8 | слотов в группе; на группу один 8-байтовый управляющий блок |
maxAvgGroupLoad |
7 | предел заполнения перед ростом — 7 из 8, то есть 87,5 % |
maxTableCapacity |
1024 | больше этого таблица не растёт целиком, а делится надвое |
SwissMapMaxKeyBytes |
128 | ключ крупнее хранится не в слоте, а по указателю |
SwissMapMaxElemBytes |
128 | то же для значения |
unsafe.Sizeof(map[int]int{}) = 8 байт: в переменной лежит один указатель.
Для сравнения: срез — 24 байта, строка — 16.
Вставка: где применяется H1, а где H2 (insert.go)
Хеш режется по седьмому биту, и половины не пересекаются:
| биты | где применяется | |
|---|---|---|
h1 = h >> 7 |
старшие 57 | номер стартовой группы (h1 & mask) и семя треугольного пути |
h2 = h & 0x7f |
младшие 7 | ложится в управляющий байт слота как метка |
Ни один бит не участвует в обеих ролях: совпадение метки ничего не говорит о группе, а номер группы ничего не говорит о метке.
Комментарий в group.go здесь расходится с кодом. Строка про занятый слот
full: 0 h h h h h h h // h represents the H1 hash bits
называет H1, а код рядом пишет в управляющий байт H2: PutSlot делает
g.ctrls().set(i, ctrl(h2(hash))), поиск зовёт matchH2(h2(hash)), и map.go
над самой функцией h2 поясняет — «These are used as an occupied control byte»
(пер.: «Они используются как управляющий байт занятого слота»). В байте лежит
H2; проверено на go1.24.7.
Пять сценариев вставки, все на настоящих хешах FNV-1a и с полной трассой:
| сценарий | что показывает |
|---|---|
| в стартовой группе есть место | один шаг: свободный слот — конец пути |
| метка совпала, ключ чужой | ложное совпадение стоит одного полного сравнения, поиск продолжается в той же группе |
| ключ уже есть | значение переписывается на месте, нового слота не появляется, len не растёт |
| стартовая группа занята целиком | путь идёт дальше по треугольной последовательности |
| по дороге удалённый слот | путь не останавливается на нём: место запоминается, поиск идёт до свободного слота, и только тогда ключ кладётся в запомненный удалённый |
Последняя строка — то, ради чего написана программа. Вставка не может занять первое подвернувшееся место: пока не встретился свободный слот, неизвестно, нет ли этого ключа дальше по пути. Поэтому вставка нового ключа стоит полного поиска промаха, а не «нашли дырку и положили».
Перенос алгоритма проверяется в самой программе: битовые формулы сверяются с
честным перебором на 200 000 случайных групп (расхождений 0), треугольный путь
— с утверждением исходника, что он обходит каждую группу ровно один раз
(проверено для 4, 8, 16, 64 и 1024 групп), а наблюдаемая часть — с настоящей
map[string]int по len на семи операциях (расхождений 0).
Хеш-функция взята FNV-1a, а не картина: внутренний хеш карты сеется случайно при её создании и снаружи не воспроизводится. Таблица сокращена до 4 групп по 8 слотов — сокращена только ширина, не правила.
Порядок обхода
Одна и та же карта, 2000 обходов, считаем число РАЗЛИЧНЫХ порядков:
| n | различных порядков | обходов |
|---|---|---|
| 3 | 3 | 2 000 |
| 6 | 6 | 2 000 |
| 8 | 8 | 2 000 |
| 9 | 9 | 2 000 |
| 16 | 16 | 2 000 |
| 896 | 896 | 20 000 |
| 897 | 1 174 | 20 000 |
До 896 записей — ровно n, а не n!. Все порядки при этом сдвиги одной и той
же последовательности: [0 1 2], [1 2 0], [2 0 1].
Граница считается из констант: maxTableCapacity = 1024, коэффициент
заполнения 7/8, то есть 896 записей — последняя длина, при которой карта
помещается в ОДНУ таблицу. На 897 у итератора появляется второе независимое
смещение, по директории таблиц (it.dirOffset), и правило кончается.
Замеренная граница совпала с расчётной точно.
Сама последовательность у каждого запуска программы своя: карта засевает хеш при старте. Постоянно другое — что внутри одного запуска порядки отличаются только сдвигом.
Память
Миллион записей map[int]int, живая куча:
| момент | куча | len |
|---|---|---|
| до начала | 0,1 МБ | — |
| миллион записей | 36,0 МБ | 1 000 000 |
после delete всех ключей |
36,0 МБ | 0 |
после clear(m) |
36,0 МБ | 0 |
после m = make(map[int]int) |
0,1 МБ | 0 |
Подсказка размера, миллион записей:
| выделено всего | выделений | осталось жить | |
|---|---|---|---|
| без подсказки | 75 407 864 б | 8 188 | 37 776 744 б |
make(map, n) |
37 832 960 б | 4 101 | 37 832 752 б |
| отношение | ×1,99 | ×2,00 | ×1,00 |
Готовая карта одинакова. Экономятся не байты в ней, а промежуточные таблицы.
Порог размера ключа, 10 000 записей:
| ключ | выделено | выделений | на запись |
|---|---|---|---|
[120]byte |
2 228 912 б | 34 | 222,9 б |
[128]byte |
2 359 984 б | 34 | 236,0 б |
[136]byte |
1 735 600 б | 10 034 | 173,6 б |
За порогом выделений становится по одному на каждый ключ, а байт — меньше: слоты в группе резервируются все восемь сразу, а вынесенный ключ выделяется ровно по размеру.
Цена, N = 100 000
Блок 1 — найти ключ, который есть:
| тип ключа | ns/op |
|---|---|
int64 |
18,06 |
[2]int64 |
24,95 |
string |
28,19 |
Блок 2 — промах против попадания и форма запроса. Все четыре строки в одном блоке намеренно: брать «попадание» из блока 1 и «попадание с запятой» отсюда было бы сравнением через границу блока.
| что | ns/op |
|---|---|
попадание, v := m[k] |
17,83 |
попадание, v, ok := m[k] |
18,43 |
промах, v := m[k] |
28,62 |
промах, v, ok := m[k] |
28,44 |
Форма с запятой не стоит ничего — на промахе разница даже в другую сторону. А промах дороже попадания в 1,6 раза, и это следствие устройства: поиск останавливается, только найдя группу со свободным слотом.
Блок 3 — построить карту из 100 000 записей:
| ns/op | B/op | allocs/op | |
|---|---|---|---|
make(map[int64]int) |
5 423 978 | 4 729 886 | 532 |
make(map[int64]int, N) |
2 760 050 | 2 364 769 | 258 |
Блок 4 — обойти 100 000 значений и просуммировать:
| ns/op | |
|---|---|
| карта | 855 702 |
| срез (та же задача, плотные ключи) | 37 434 |
Блок 5 — прочитать одно значение по плотному целому ключу:
| ns/op | |
|---|---|
| карта | 15,55 |
| срез | 1,12 |
От прогона к прогону (три прогона по 2 с): блок 1 — 17,81–18,37 / 24,79–24,99 /
27,46–28,25; блок 2 — 17,75–18,11 и 18,40–18,84 у попаданий, 28,57–28,76 и
28,28–28,89 у промахов; блок 3 — 5 420 612–5 506 708 и 2 740 518–2 816 191; блок 4 —
855 535–856 652 и 36 697–37 482. Столбцы B/op и allocs/op не менялись.
Старая реализация против новой
ab.sh, четыре чередующихся раунда, одна машина, строка cpu: во всех
раундах совпала:
| операция | Swiss Table (по умолчанию) | старая (noswissmap) |
|
|---|---|---|---|
попадание, int64, форма v, ok := |
17,95–18,88 | 35,22–36,58 | новая быстрее вдвое |
попадание, string |
25,59–28,14 | 51,89–54,70 | новая быстрее вдвое |
промах, int64 |
28,96–29,66 | 20,57–21,71 | новая медленнее в 1,4 раза |
| обход 100 000 записей | 847 706–893 851 | 1 046 614–1 079 682 | новая быстрее на ~25 % |
Замер один и тот же, компилятор один и тот же, отличается флаг сборки.
Наблюдения layout.go
- Карта — указатель, поэтому вставка внутри функции видна снаружи; у среза добавление в той же ситуации не видно.
- Порядок обхода — сдвиг, а не перестановка: различных порядков ровно
n— до 896 записей, то есть пока карта помещается в одну таблицу. - nil-карта: чтение,
len,rangeиdeleteработают; паникует только запись. - Адрес элемента взять нельзя — при росте таблица перестраивается целиком и записи переезжают.
deleteиclearне возвращают память — только замена карты.- Подсказка размера вдвое уменьшает выделенное по дороге и не меняет размер готовой карты.
- Порог 128 байт переносит ключ из слота в отдельное выделение.
- Ключом может быть любой сравнимый тип; у
map[any]Tсравнимость проверяется в рантайме, и несравнимое значение даёт паникуhash of unhashable type.
Что не подтвердилось
Расхожее «карта в Go — это бакеты по 8 элементов с цепочкой переполнения»
описывает реализацию до Go 1.24. Она никуда не делась, но включается флагом:
GOEXPERIMENT=noswissmap. По умолчанию с 1.24 работает Swiss Table.
И вторая половина расхожего — «новая реализация быстрее». Быстрее не всё:
на промахе она медленнее старой в 1,4 раза, и это воспроизводится в каждом
раунде. Механизм разный: в старой карте промах смотрит один бакет и кончается
там же, если цепочки переполнения нет, а в Swiss Table он обязан идти до
группы со свободным слотом. То, что делает попадание быстрым, делает промах
длинным. Оговорка в блоге команды Go стоит: «some edge cases do regress
compared to Go 1.23». Здесь этот случай назван поимённо и измерен. Оговорка к
самому измерению тоже нужна: это одна форма карты (map[int64]int, 100 000
записей, ключи подряд), а цена промаха зависит от заполнения таблицы и от
того, как разложены ключи.
NaN как ключ (nankey.go)
Запись, которую нельзя ни найти, ни перезаписать, ни удалить, и которая накапливается при каждой вставке.
Первоисточник №1 — спецификация Go, раздел Comparison operators:
Floating-point types are comparable and ordered. Two floating-point values are compared as defined by the IEEE 754 standard.
(пер.: «Типы с плавающей точкой сравнимы и упорядочены. Два значения с плавающей точкой сравниваются так, как определено стандартом IEEE 754».)
IEEE 754 определяет NaN как значение, не равное ничему, включая само себя. Отсюда всё остальное: карта ищет ключ по равенству, а ключ не равен сам себе.
Первоисточник №2 — спецификация Go, раздел Map types:
The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice.
(пер.: «Операторы сравнения == и != должны быть полностью определены для операндов типа ключа; поэтому тип ключа не должен быть функцией, картой или срезом».)
float64 этому требованию удовлетворяет — операторы определены. Определены, но
не рефлексивны, и на это карта не рассчитана. Требование спецификации
запрещает срезы и карты, но NaN не запрещает.
Первоисточник №3 — комментарий в рантайме,
$(go env GOROOT)/src/runtime/alg.go:93:
// NOTE: Because NaN != NaN, a map can contain any // number of (mostly useless) entries keyed with NaNs. // To avoid long hash chains, we assign a random number // as the hash value for a NaN.
(пер.: «ЗАМЕЧАНИЕ: поскольку NaN != NaN, карта может содержать любое число (по большей части бесполезных) записей с ключами-NaN. Чтобы избежать длинных цепочек хеша, мы назначаем NaN случайное число в качестве значения хеша».)
Это ключевая цитата: накопление дубликатов — не побочный эффект и не дефект, а
известное и задокументированное поведение. Рантайм лишь смягчает последствия,
раздавая NaN случайный хеш, чтобы они не собирались в одну цепочку. Видно это
прямо в f64hash там же: case f != f: return c1 * (c0 ^ h ^ uintptr(rand())).
Первоисточник №4 — $(go env GOROOT)/src/internal/runtime/maps/table.go:634,
про то, что с такой записью можно сделать:
// One exception is keys that don't // compare equal to themselves (e.g., // NaN). These keys cannot be looked // up, so getWithKey will fail even if // the key exists. // // However, we are in luck because such // keys cannot be updated and they // cannot be deleted except with clear.
(пер.: «Исключение — ключи, которые не сравниваются равными сами себе (например, NaN). Такие ключи невозможно найти, поэтому getWithKey потерпит неудачу, даже если ключ существует. Однако нам везёт, потому что такие ключи нельзя обновить и нельзя удалить иначе как через clear».)
Здесь названы сразу три следствия и один выход: не найти, не обновить, не
удалить — кроме как clear. Всё четыре проверены прогоном.
Прогон целиком:
=== 0. Исходное свойство ===
math.NaN() == math.NaN() : false
nan == nan (одна переменная): false
math.IsNaN(nan): true
=== 1. N вставок одного и того же NaN ===
после m[nan] = 1 → len(m) = 1
после m[nan] = 2 → len(m) = 2
после m[nan] = 3 → len(m) = 3
после m[nan] = 4 → len(m) = 4
после m[nan] = 5 → len(m) = 5
итог: 5 вставок дали 5 записей
обычный ключ на его месте дал бы одну: присваивание по
существующему ключу перезаписывает, но «существующий» —
это найденный по ==, а найти NaN нельзя.
=== 2. Что возвращает поиск ===
m[nan] → значение 0, найдено false
m[math.NaN()] → значение 0, найдено false
не помогает даже та же самая переменная: сравнение всё равно
идёт по ==, а оно ложно.
=== 3. Что делает delete ===
len до delete: 5
len после двух delete: 5
delete не паникует и не сообщает об ошибке — он просто не
находит, что удалять.
=== 4. Записи на месте, и их видно перебором ===
запись 1: ключ IsNaN=true, k==k → false, значение 1
запись 2: ключ IsNaN=true, k==k → false, значение 2
запись 3: ключ IsNaN=true, k==k → false, значение 3
запись 4: ключ IsNaN=true, k==k → false, значение 4
запись 5: ключ IsNaN=true, k==k → false, значение 5
перебор нашёл 5 записей
то есть память они занимают, значения хранят, а достучаться
до них по ключу нельзя. Под нагрузкой, где NaN приходит из
данных (0.0/0.0, math.Sqrt(-1), разбор "NaN"), карта растёт
без предела, и ни одна запись из неё не переиспользуется.
=== 5. Единственный штатный способ убрать: clear ===
len до clear: 5
len после clear(m): 0
ровно то, что написано в рантайме: "cannot be deleted
except with clear".
=== 6. Контроль: карта следует == буквально, и там, где == ===
ведёт себя нормально, странностей нет ===
0.0 == -0.0: true
после m[+0]=1 и m[-0]=2 → len = 1, m[0] = 2
+0 и -0 равны по IEEE 754, поэтому это ОДНА запись, и вторая
вставка перезаписала первую. Карта не «сломана» на float —
она честно следует ==. Ломается ровно то место, где ==
перестаёт быть рефлексивным, и это только NaN.
=== 7. Как с этим жить ===
отсекать NaN на входе:
NaN отвергнут проверкой math.IsNaN
NaN отвергнут проверкой math.IsNaN
len(safe) = 2 — ровно столько, сколько годных ключей
Что показал прогон:
| действие | результат |
|---|---|
5 вставок одного и того же nan |
len(m) = 5 |
m[nan] |
0, false |
m[math.NaN()] |
0, false |
delete(m, nan) дважды |
len не изменился: 5 → 5 |
перебор range |
находит все 5 записей, значения 1…5 целы |
clear(m) |
len = 0 |
Четыре вещи, которые стоит различать:
- Не помогает та же самая переменная.
m[nan]с тем жеnan, которым клали, всё равно даётfalse: сравнение идёт по==, а оно ложно независимо от того, откуда взялось значение. deleteмолчит. Он не паникует и ничего не возвращает — просто не находит, что удалять. Ошибка не проявляется никак.- Записи живые. Перебором они видны, значения целы, память занята.
То есть это утечка с полностью рабочим на вид кодом: под нагрузкой, где
NaN приходит из данных (
0.0/0.0,math.Sqrt(-1), разбор строки"NaN"), карта растёт без предела и ни одна запись не переиспользуется. clearработает — ровно как обещает комментарий рантайма.
Контроль, который стоит прочитать. Блок 6 прогона показывает +0 и -0:
по IEEE 754 они равны, и карта делает из них ОДНУ запись, вторая вставка
перезаписывает первую (len = 1, m[0] = 2). То есть карта не «сломана» на
float — она честно следует ==. Ломается ровно то место, где == перестаёт
быть рефлексивным, и это только NaN.
Лечение — отсекать NaN на входе через math.IsNaN, блок 7.
Источники
- Go 1.24 Release Notes, раздел Runtime — https://go.dev/doc/go1.24
- Faster Go maps with Swiss Tables (блог команды Go) — https://go.dev/blog/swisstable
internal/runtime/maps/map.go, вводный комментарий пакета — https://go.dev/src/internal/runtime/maps/map.gointernal/runtime/maps/group.goиtable.go— константыmaxAvgGroupLoadиmaxTableCapacityinternal/abi/map_swiss.go—SwissMapGroupSlots,SwissMapMaxKeyBytes- Спецификация Go, разделы Map types и Comparison operators — https://go.dev/ref/spec
runtime/alg.go— комментарий про ключи-NaN иf64hash— https://go.dev/src/runtime/alg.gointernal/runtime/maps/table.go— «cannot be deleted except with clear» — https://go.dev/src/internal/runtime/maps/table.go- Abseil, Swiss Tables design notes — https://abseil.io/about/design/swisstables
Скрипт
363 строк//go:build ignore
// Номер группы: как из хеша получается место в таблице.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Читатель статьи сообщил, что фраза «H1 назвал группу,
// с которой начинается путь» ничего ему не объяснила: непонятно, что такое
// группа, что значит «назвал» и как из старших бит хеша получается номер.
// Замечание точное. Про группу обычно пишут «восемь слотов плюс управляющее
// слово» — это описание ОДНОЙ группы, а не таблицы, и из него не следует, что
// таблица есть МАССИВ таких групп, а номер группы — обычный индекс в массиве.
// Здесь весь путь от 64-битного хеша до индекса печатается по шагам.
//
// ЧТО ЗДЕСЬ НАСТОЯЩЕЕ. Разбор хеша (`h1(h) = h >> 7`, `h2(h) = h & 0x7f`,
// go1.24.7, `src/internal/runtime/maps/map.go`) и вычисление стартовой группы
// (`makeProbeSeq(h1(hash), mask)` → `offset = hash & mask`,
// `src/internal/runtime/maps/table.go`). Число групп в рантайме — степень
// двойки, поэтому `& (N-1)` там же играет роль `% N`.
//
// ЧТО ЗДЕСЬ УСЛОВНО. Хеш-функция — FNV-1a, как и в insert.go, и по той же
// причине: внутренний хеш карты сеется случайно при её создании и снаружи не
// воспроизводится, а числа в статье должны получаться у читателя такими же.
// На разбор хеша выбор функции не влияет: 64-битное число делится одинаково.
//
// ЧЕМ ПОДПЁРТО. Блок 5 проверяет три утверждения, на которых держится текст:
// что маска равна остатку РОВНО для степеней двойки (и показывает, где это
// ломается), что стартовая группа здесь та же, что даёт probeSeq из insert.go,
// и что старшие биты в номере — не украшение: если взять под номер младшие,
// метка H2 потеряет большую часть различающей силы, и это считается.
//
// ЗАПУСК:
//
// go run bench/gomap/groupindex.go
package main
import (
"fmt"
"hash/fnv"
"math/rand"
"sort"
"strings"
)
// ---------------------------------------------------------------- разбор хеша
//
// Дословно из go1.24.7, src/internal/runtime/maps/map.go.
// h1 — старшие 57 бит. Из них берётся номер стартовой группы.
func h1(h uint64) uint64 { return h >> 7 }
// h2 — младшие 7 бит. Они ложатся в управляющий байт как метка слота.
func h2(h uint64) uint8 { return uint8(h & 0x7f) }
// startGroup — то, что делает makeProbeSeq(h1(hash), mask) в table.go:
// offset = hash & mask, где mask = число групп - 1.
func startGroup(h uint64, groups uint64) uint64 { return h1(h) & (groups - 1) }
func hashKey(k string) uint64 {
s := fnv.New64a()
_, _ = s.Write([]byte(k))
return s.Sum64()
}
func bin(v uint64, n int) string {
s := fmt.Sprintf("%0*b", n, v&((1<<n)-1))
return s
}
func rule(title string) {
fmt.Printf("\n=== %s ===\n\n", title)
}
// ---------------------------------------------------------------- блок 1
// blockTable показывает, ЧТО такое таблица. Это единственное место, где видно,
// что группа — не абстракция, а элемент массива, и что «номер группы» — индекс
// в нём, а не что-то, требующее отдельного понимания.
func blockTable() {
rule("БЛОК 1. ТАБЛИЦА — ЭТО МАССИВ ГРУПП")
fmt.Println(" Группа — восемь слотов и управляющее слово при них. Таблица — массив")
fmt.Println(" таких групп, лежащих подряд. Больше в ней ничего нет:")
fmt.Println()
const n = 8
for g := 0; g < n; g++ {
fmt.Printf(" группа %d [упр. слово: 8 байт][слот 0][слот 1]…[слот 7]\n", g)
}
fmt.Println()
fmt.Printf(" Групп здесь %d, значит номера идут 0..%d. «H1 назвал группу» означает\n", n, n-1)
fmt.Println(" ровно одно: H1 дал число из этого диапазона — индекс в массиве выше.")
fmt.Println(" Никакого поиска на этом шаге ещё не было: поиск начнётся ВНУТРИ")
fmt.Println(" названной группы, по её управляющему слову.")
fmt.Println()
fmt.Println(" Число групп в рантайме — всегда степень двойки. Из этого одного факта")
fmt.Println(" следуют и способ взять номер (блок 2), и обход пути при промахе.")
}
// ---------------------------------------------------------------- блок 2
// demoKeys — та же серия, что заполняет таблицу в insert.go, чтобы две картинки
// статьи говорили об одних и тех же ключах.
//
// ПОЧЕМУ НЕ alpha/bravo/charlie, как в блоке 1 insert.go. Первый черновик брал
// их — и блок 3 получился бессмысленным: у FNV-1a от этих строк биты, попадающие
// в номер, оказались нулями, все три ключа сидели в группе 0 при любом числе
// групп, а текст рядом утверждал, что от числа групп номер зависит. Утверждение
// верное, пример — опровергающий его. Серия k0..k7 никак не подбиралась под
// результат: это первые восемь ключей той же последовательности.
var demoKeys = []string{"k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7"}
// blockIndex — главный блок. Показывает, что вычисление номера группы это одна
// операция «И», и что биты, попавшие в номер, — это конкретные биты исходного
// хеша, которые можно ткнуть пальцем.
func blockIndex() {
rule("БЛОК 2. КАК ИЗ ХЕША ПОЛУЧАЕТСЯ НОМЕР ГРУППЫ")
const groups = 8
const mask = groups - 1
shift := 0
for m := uint64(mask); m > 0; m >>= 1 {
shift++
}
fmt.Printf(" Групп %d, значит mask = %d = 0b%s, и номер занимает %d бита.\n",
groups, mask, bin(mask, shift), shift)
fmt.Println()
fmt.Println(" h1 = h >> 7 отбросили 7 бит метки")
fmt.Println(" номер = h1 & mask взяли младшие биты того, что осталось")
fmt.Println()
fmt.Println(" Вторая строка — это остаток от деления на число групп, записанный")
fmt.Println(" одной операцией. Так можно только потому, что групп степень двойки;")
fmt.Println(" в блоке 5 показано, где этот приём перестаёт работать.")
fmt.Println()
fmt.Println(" ключ хеш младшие 16 бит хеша h1&mask h2")
for _, k := range demoKeys {
h := hashKey(k)
low := bin(h, 16)
// Разрез: 7 младших бит — метка; следующие shift бит — номер группы.
fp := low[16-7:]
idx := low[16-7-shift : 16-7]
rest := low[:16-7-shift]
fmt.Printf(" %-9s 0x%016x %s|%s|%s %d 0x%02x\n",
k, h, rest, idx, fp, startGroup(h, groups), h2(h))
}
fmt.Println()
fmt.Println(" Столбец с чертами — одно и то же число, разрезанное дважды. Справа")
fmt.Println(" от правой черты семь бит метки, они уходят в управляющий байт. Между")
fmt.Println(" чертами — те самые биты, которые И ЕСТЬ номер группы: их не надо")
fmt.Println(" вычислять, их надо просто взять. Слева остальные 54 бита H1: они не")
fmt.Println(" участвуют в номере, но участвуют в пути, если группа окажется занята.")
fmt.Println()
fmt.Println(" Отсюда же видно, почему номер берут у H1, а не у всего хеша: биты")
fmt.Println(" метки и биты номера не должны пересекаться. Цена пересечения")
fmt.Println(" посчитана в блоке 5.")
}
// ---------------------------------------------------------------- блок 3
// blockGrowth. Тот же ключ при разном числе групп попадает в разные группы —
// это и есть «записи переезжают при росте», но увиденное на арифметике, а не
// принятое на веру.
func blockGrowth() {
rule("БЛОК 3. ЧТО ДЕЛАЕТ С НОМЕРОМ РОСТ ТАБЛИЦЫ")
sizes := []uint64{4, 8, 16, 32, 64}
fmt.Printf(" ключ h1 ")
for _, n := range sizes {
fmt.Printf("N=%-4d", n)
}
fmt.Println()
for _, k := range demoKeys {
h := hashKey(k)
fmt.Printf(" %-9s 0x%014x ", k, h1(h))
for _, n := range sizes {
fmt.Printf("%-6d", startGroup(h, n))
}
fmt.Println()
}
fmt.Println()
fmt.Println(" Хеш ключа не менялся ни разу. Менялась маска — и с ней номер группы.")
fmt.Println(" Удвоение таблицы добавляет к маске один бит, и этот бит либо оставляет")
fmt.Println(" ключ на месте, либо переносит его ровно на N групп вперёд.")
fmt.Println()
// Считаем долю переехавших при удвоении — величина, которую можно проверить.
const sample = 100000
for i := 0; i+1 < len(sizes); i++ {
from, to := sizes[i], sizes[i+1]
moved := 0
for j := 0; j < sample; j++ {
h := hashKey(fmt.Sprintf("k%d", j))
if startGroup(h, from) != startGroup(h, to) {
moved++
}
}
fmt.Printf(" %d → %-3d групп: сменили номер %5.2f%% ключей из %d\n",
from, to, 100*float64(moved)/float64(sample), sample)
}
fmt.Println()
fmt.Println(" Около половины — и это не совпадение: добавленный бит маски у половины")
fmt.Println(" ключей единица. Вот почему рост перестраивает таблицу целиком и почему")
fmt.Println(" адрес элемента карты взять нельзя.")
}
// ---------------------------------------------------------------- блок 4
// blockPath. Номер группы — только НАЧАЛО. Без этого блока читатель уносит
// мысль «хеш указывает место», которую статья опровергает.
func blockPath() {
rule("БЛОК 4. НОМЕР — ЭТО НАЧАЛО ПУТИ, А НЕ МЕСТО")
const groups = 8
const mask = groups - 1
fmt.Println(" Если названная группа занята целиком, поиск идёт дальше по")
fmt.Println(" треугольному пути p(i) = (i²+i)/2 + start. Вот полные пути:")
fmt.Println()
for _, k := range demoKeys[:4] {
h := hashKey(k)
start := startGroup(h, groups)
var path []string
offset, index := start, uint64(0)
for i := 0; i < groups; i++ {
path = append(path, fmt.Sprint(offset))
index++
offset = (offset + index) & mask
}
fmt.Printf(" %-9s старт %d: %s\n", k, start, strings.Join(path, " → "))
}
fmt.Println()
fmt.Println(" Каждый путь обходит все восемь групп и ни одну дважды. Стартовая")
fmt.Println(" группа у ключей разная, поэтому и порядок обхода разный — два ключа,")
fmt.Println(" столкнувшиеся в одной группе, дальше расходятся.")
}
// ---------------------------------------------------------------- блок 5
// blockChecks. Всё, на что опирается текст, проверяется здесь.
func blockChecks() {
rule("БЛОК 5. ПРОВЕРКИ")
// 1. Маска против остатка.
fmt.Println(" 1. Маска против остатка от деления")
fmt.Println()
r := rand.New(rand.NewSource(20260902))
const trials = 1000000
for _, n := range []uint64{2, 4, 8, 16, 64, 1024, 65536} {
bad := 0
for i := 0; i < trials; i++ {
v := r.Uint64()
if v&(n-1) != v%n {
bad++
}
}
fmt.Printf(" N = %-6d степень двойки: да расхождений на %d значениях: %d\n",
n, trials, bad)
}
fmt.Println()
fmt.Println(" А теперь то же самое там, где приём неприменим:")
for _, n := range []uint64{6, 10, 100} {
bad := 0
first := ""
for i := 0; i < trials; i++ {
v := r.Uint64()
if v&(n-1) != v%n {
bad++
if first == "" {
first = fmt.Sprintf("v=%d: v&%d=%d, но v%%%d=%d", v, n-1, v&(n-1), n, v%n)
}
}
}
fmt.Printf(" N = %-6d степень двойки: нет расхождений на %d значениях: %d\n",
n, trials, bad)
fmt.Printf(" пример: %s\n", first)
}
fmt.Println()
fmt.Println(" Вот зачем число групп держат степенью двойки: не «для красоты»,")
fmt.Println(" а чтобы взятие номера осталось одной операцией И вместо деления.")
fmt.Println()
// 2. Совпадение с probeSeq из insert.go.
fmt.Println(" 2. Тот же номер, что даёт probeSeq из insert.go")
fmt.Println()
mismatch := 0
for i := 0; i < 200000; i++ {
h := hashKey(fmt.Sprintf("key-%d", i))
for _, n := range []uint64{4, 8, 16, 64, 1024} {
// makeProbeSeq(h1(hash), mask): offset = hash & mask.
viaProbeSeq := h1(h) & (n - 1)
if viaProbeSeq != startGroup(h, n) {
mismatch++
}
}
}
fmt.Printf(" расхождений на 200 000 ключах × 5 размеров: %d\n", mismatch)
fmt.Println()
// 3. Цена пересечения битов: что было бы, возьми номер у младших бит.
fmt.Println(" 3. Почему номер берут у H1, а не у всего хеша")
fmt.Println()
fmt.Println(" Возьмём номер группы у МЛАДШИХ бит хеша — тех самых, что уже")
fmt.Println(" заняты меткой, — и посмотрим, сколько разных меток может")
fmt.Println(" оказаться внутри одной группы.")
fmt.Println()
const keys = 400000
const groups = 8
rightSet := make([]map[uint8]bool, groups)
wrongSet := make([]map[uint8]bool, groups)
for i := range rightSet {
rightSet[i] = map[uint8]bool{}
wrongSet[i] = map[uint8]bool{}
}
for i := 0; i < keys; i++ {
h := hashKey(fmt.Sprintf("k%d", i))
rightSet[startGroup(h, groups)][h2(h)] = true
wrongSet[h&(groups-1)][h2(h)] = true
}
countOf := func(sets []map[uint8]bool) []int {
out := make([]int, len(sets))
for i, s := range sets {
out[i] = len(s)
}
sort.Ints(out)
return out
}
rc, wc := countOf(rightSet), countOf(wrongSet)
fmt.Printf(" номер у H1 (как в Go): разных меток в группе %d..%d из 128\n", rc[0], rc[len(rc)-1])
fmt.Printf(" номер у младших бит: разных меток в группе %d..%d из 128\n", wc[0], wc[len(wc)-1])
fmt.Println()
// Кратность и потерянные биты СЧИТАЮТСЯ по измеренному, а не вписаны
// константами: иначе при смене числа групп текст разойдётся с таблицей
// над ним и никто этого не заметит.
worst := wc[len(wc)-1]
factor := 128 / worst
lost := 0
for v := factor; v > 1; v >>= 1 {
lost++
}
fmt.Printf(" Во втором случае метка теряет %d бита: номер группы и метка\n", lost)
fmt.Println(" сделаны из одних и тех же бит, поэтому внутри группы метка")
fmt.Println(" меняться уже почти не может. Ложные совпадения при поиске стали бы")
fmt.Printf(" примерно в %d раз чаще, и за каждое платили бы полным сравнением\n", factor)
fmt.Println(" ключа. Разрез хеша на непересекающиеся части — не аккуратность,")
fmt.Println(" а условие, при котором метка вообще что-то отсеивает.")
}
func main() {
fmt.Println("НОМЕР ГРУППЫ: КАК ИЗ ХЕША ПОЛУЧАЕТСЯ МЕСТО В ТАБЛИЦЕ")
fmt.Println()
fmt.Println("Правила — из go1.24.7: internal/runtime/maps/{map.go,table.go}.")
fmt.Println("Хеш-функция взята детерминированная (FNV-1a): внутренний хеш карты")
fmt.Println("сеется случайно при её создании и снаружи не воспроизводится.")
blockTable()
blockIndex()
blockGrowth()
blockPath()
blockChecks()
fmt.Println()
fmt.Println("Все строки выше напечатаны этой программой.")
}