ЗАМЕР
bench/gomap/insert.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
Скрипт
796 строк//go:build ignore
// Вставка в карту Go: что делает H1, а что H2.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Про Swiss Table обычно пишут, что хеш делится на H1 и
// H2, и на этом останавливаются. Из такой фразы не выводится ничего: непонятно,
// где H1 применяется, почему H2 попадает в управляющий байт, почему вставка
// обязана сначала ИСКАТЬ и что происходит, когда по дороге встретился удалённый
// слот. Здесь путь вставки выполняется по шагам и печатается целиком.
//
// ЧТО ЗДЕСЬ НАСТОЯЩЕЕ. Алгоритм — дословный перенос `table.PutSlot` из
// go1.24.7, `src/internal/runtime/maps/table.go`, вместе с разбором хеша
// (`h1(h) = h >> 7`, `h2(h) = h & 0x7f`, `map.go`), треугольным путём поиска
// (`makeProbeSeq`, `table.go`) и битовыми формулами группы (`group.go`).
// Порядок решений сохранён вплоть до правила «первый удалённый слот
// запоминается, но путь продолжается до свободного».
//
// ЧТО ЗДЕСЬ УСЛОВНО, И ПОЧЕМУ ИНАЧЕ НЕЛЬЗЯ. Хеш-функция взята другая: FNV-1a из
// `hash/fnv`. Карта хеширует своей внутренней функцией со случайным seed,
// который выбирается при создании карты, — снаружи её ни вызвать, ни
// воспроизвести. Взять детерминированную функцию — единственный способ
// напечатать числа, которые у читателя получатся такими же. На разбор хеша это
// не влияет: делится любое 64-битное число одинаково.
//
// Таблица тоже маленькая: 4 группы по 8 слотов вместо настоящей, которая для
// таких размеров ещё и не разбита на несколько таблиц. Сокращена ТОЛЬКО
// ширина, не правила.
//
// ЧЕМ ПОДПЁРТО. Блок 4 проверяет перенос: битовые формулы сверяются с честным
// перебором на всех 128 метках и на случайных группах, а треугольный путь — с
// утверждением исходника, что он обходит каждую группу ровно один раз. Блок 5
// сверяет наблюдаемую часть с настоящей `map[string]int`: то, что видно снаружи
// (len до и после повторной вставки), обязано совпасть.
//
// ЗАПУСК:
//
// go run bench/gomap/insert.go
package main
import (
"fmt"
"hash/fnv"
"math/bits"
"math/rand"
"strings"
)
// ------------------------------------------------------------ из group.go
//
// Дословно из go1.24.7, src/internal/runtime/maps/group.go.
//
// empty: 1 0 0 0 0 0 0 0
// deleted: 1 1 1 1 1 1 1 0
// full: 0 h h h h h h h
//
// Про последнюю строку в group.go написано «h represents the H1 hash bits», и
// это единственное место, где сказано «H1». Код рядом говорит обратное:
// `PutSlot` пишет в управляющий байт `ctrl(h2(hash))`, поиск ищет `matchH2`, а
// `map.go` над самой функцией `h2` поясняет: «These are used as an occupied
// control byte». В управляющем байте лежит H2; комментарий в group.go
// разошёлся с кодом.
const (
ctrlEmpty = 0b10000000
ctrlDeleted = 0b11111110
bitsetLSB = 0x0101010101010101
bitsetMSB = 0x8080808080808080
slotsPerGroup = 8 // abi.SwissMapGroupSlots
maxAvgLoad = 7 // maxAvgGroupLoad из group.go: 7 занятых слотов на 8
)
func matchH2(g uint64, h uint8) uint64 {
v := g ^ (bitsetLSB * uint64(h))
return ((v - bitsetLSB) &^ v) & bitsetMSB
}
func matchEmpty(g uint64) uint64 { return (g &^ (g << 6)) & bitsetMSB }
func matchEmptyOrDeleted(g uint64) uint64 { return g & bitsetMSB }
// first — номер слота по младшему выставленному биту (bitsetFirst из group.go).
func first(mask uint64) int { return bits.TrailingZeros64(mask) >> 3 }
// removeFirst гасит младший выставленный бит (bitset.removeFirst).
func removeFirst(mask uint64) uint64 { return mask & (mask - 1) }
// ------------------------------------------------------------ из map.go
// h1 — старшие 57 бит хеша. Ими выбирается группа и задаётся путь поиска.
func h1(h uint64) uint64 { return h >> 7 }
// h2 — младшие 7 бит хеша. Они ложатся в управляющий байт как метка слота.
func h2(h uint64) uint8 { return uint8(h & 0x7f) }
// ------------------------------------------------------------ из table.go
// probeSeq — треугольный путь по группам, p(i) = (i²+i)/2 + hash (mod mask+1).
type probeSeq struct {
mask uint64
offset uint64
index uint64
}
func makeProbeSeq(hash, mask uint64) probeSeq {
return probeSeq{mask: mask, offset: hash & mask}
}
func (s probeSeq) next() probeSeq {
s.index++
s.offset = (s.offset + s.index) & s.mask
return s
}
// ------------------------------------------------------------ таблица
type slot struct {
key string
val int
}
type table struct {
mask uint64 // число групп минус один
ctrl []uint64
slots [][slotsPerGroup]slot
growthLeft int
used int
}
func newTable(groups int) *table {
t := &table{
mask: uint64(groups - 1),
ctrl: make([]uint64, groups),
slots: make([][slotsPerGroup]slot, groups),
}
for i := range t.ctrl {
t.ctrl[i] = bitsetLSB * uint64(ctrlEmpty) // все слоты свободны
}
t.growthLeft = groups * slotsPerGroup * maxAvgLoad / slotsPerGroup
return t
}
// clone — честная копия. Нужна, чтобы каждый сценарий начинался с одного и
// того же состояния. Первая версия делала `snap := *t`, и это было ошибкой:
// в table лежат срезы, у копии структуры массив под ними тот же самый, так что
// запись одного сценария оказывалась видна следующему. В напечатанной трассе
// это выглядело так, будто вставка нового ключа обернулась обновлением
// существующего, — программа сама себе противоречила, и потому ошибку было
// видно.
func (t *table) clone() *table {
c := &table{
mask: t.mask,
ctrl: append([]uint64(nil), t.ctrl...),
slots: append([][slotsPerGroup]slot(nil), t.slots...),
growthLeft: t.growthLeft,
used: t.used,
}
return c
}
func (t *table) ctrlByte(g, i int) uint8 { return uint8(t.ctrl[g] >> (8 * i)) }
func (t *table) setCtrl(g, i int, c uint8) {
t.ctrl[g] &^= 0xff << (8 * i)
t.ctrl[g] |= uint64(c) << (8 * i)
}
func hash(key string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(key))
return h.Sum64()
}
// step — одна запись трассы. Печатается и она же уезжает в картинку.
type step struct {
group int
kind string // probe-match, probe-full, probe-deleted, probe-empty, write, update, grow
slotIdx int
comment string
candidat []int // слоты, у которых совпала метка
// Где путь ДОШЁЛ до свободного слота. Может отличаться от group: если по
// дороге попался удалённый слот, ключ ложится в него, а не сюда. Без этих
// двух полей трасса умалчивала самое интересное — что путь побывал в одной
// группе, а запись случилась в другой.
endGroup int
endSlot int
}
// put повторяет table.PutSlot: сначала ищет ключ, потом место.
func (t *table) put(key string, val int) (trace []step) {
h := hash(key)
mark := h2(h)
seq := makeProbeSeq(h1(h), t.mask)
firstDeletedGroup, firstDeletedSlot := -1, -1
for {
g := int(seq.offset)
word := t.ctrl[g]
// 1. Есть ли уже такой ключ? Сначала метка, потом полное сравнение.
match := matchH2(word, mark)
var cands []int
for m := match; m != 0; m = removeFirst(m) {
cands = append(cands, first(m))
}
for m := match; m != 0; m = removeFirst(m) {
i := first(m)
if t.slots[g][i].key == key {
t.slots[g][i].val = val
trace = append(trace, step{group: g, kind: "update", slotIdx: i, candidat: cands,
comment: "метка совпала и ключ совпал целиком — это тот же ключ. Значение переписывается на месте: нового слота не появляется, len не растёт."})
return
}
}
if len(cands) > 0 {
trace = append(trace, step{group: g, kind: "probe-match", slotIdx: -1, candidat: cands,
comment: "метка совпала, но ключ при полном сравнении оказался чужим. Семь бит — не ответ, а сужение круга: это ложное совпадение, поиск продолжается в этой же группе."})
}
// 2. Ключа здесь нет. Конец ли это пути?
free := matchEmptyOrDeleted(word)
if free == 0 {
trace = append(trace, step{group: g, kind: "probe-full", slotIdx: -1, candidat: cands,
comment: "в группе нет ни одного свободного или удалённого слота — она занята целиком. Остановиться нельзя: ключ мог осесть дальше по пути."})
seq = seq.next()
continue
}
i := first(free)
if t.ctrlByte(g, i) == ctrlDeleted {
if firstDeletedGroup < 0 {
firstDeletedGroup, firstDeletedSlot = g, i
}
trace = append(trace, step{group: g, kind: "probe-deleted", slotIdx: i, candidat: cands,
comment: "первый незанятый слот здесь — удалённый. Место запоминается, но путь продолжается: пока не встретился свободный слот, нельзя утверждать, что такого ключа в таблице нет."})
seq = seq.next()
continue
}
// 3. Свободный слот: путь окончен, ключа в таблице нет.
endG, endI := g, i
useG, useI := g, i
reused := false
if firstDeletedGroup >= 0 {
useG, useI = firstDeletedGroup, firstDeletedSlot
reused = true
t.growthLeft++ // ниже вычтется обратно: удалённый слот уже был учтён
}
if t.growthLeft <= 0 {
trace = append(trace, step{group: g, kind: "grow", slotIdx: i, candidat: cands,
comment: "место есть, но запаса роста не осталось: занято 7/8. Таблица пересобирается, и только потом ключ ложится на место."})
return
}
t.slots[useG][useI] = slot{key: key, val: val}
t.setCtrl(useG, useI, mark)
t.growthLeft--
t.used++
why := "свободный слот — конец пути: будь такой ключ в таблице, он лежал бы не дальше этого места. Ключ и значение ложатся в слот, а в управляющий байт пишется метка — младшие 7 бит хеша."
if reused {
why = fmt.Sprintf("свободный слот нашёлся в группе %d — значит, такого ключа в таблице точно нет. Только теперь можно вернуться к запомненному удалённому слоту в группе %d и занять его: он ближе к началу пути, и запас роста на него не тратится.", endG, useG)
}
trace = append(trace, step{group: useG, kind: "write", slotIdx: useI, candidat: cands,
endGroup: endG, endSlot: endI, comment: why})
return
}
}
func (t *table) del(key string) bool {
h := hash(key)
mark := h2(h)
seq := makeProbeSeq(h1(h), t.mask)
for {
g := int(seq.offset)
word := t.ctrl[g]
for m := matchH2(word, mark); m != 0; m = removeFirst(m) {
i := first(m)
if t.slots[g][i].key == key {
t.slots[g][i] = slot{}
// Правило из map.go: если в группе есть свободный слот, удалённый
// можно пометить свободным; иначе — только «удалён».
if matchEmpty(word) != 0 {
t.setCtrl(g, i, ctrlEmpty)
t.growthLeft++
} else {
t.setCtrl(g, i, ctrlDeleted)
}
t.used--
return true
}
}
if matchEmpty(word) != 0 {
return false
}
seq = seq.next()
}
}
// ------------------------------------------------------------ печать
func stateRune(c uint8) string {
switch {
case c == ctrlEmpty:
return " ····"
case c == ctrlDeleted:
return " удал"
default:
return fmt.Sprintf(" 0x%02x", c)
}
}
func (t *table) dump(indent string) {
for g := range t.ctrl {
var parts []string
for i := 0; i < slotsPerGroup; i++ {
parts = append(parts, stateRune(t.ctrlByte(g, i)))
}
fmt.Printf("%sгруппа %d %s\n", indent, g, strings.Join(parts, ""))
}
}
func header(n int, title string) {
fmt.Printf("\n=== БЛОК %d. %s ===\n\n", n, title)
}
// ------------------------------------------------------------ блок 1
func blockSplit(keys []string) {
header(1, "КАК ДЕЛИТСЯ ХЕШ")
fmt.Println(" h1 = h >> 7 (старшие 57 бит) — выбирает группу и задаёт путь")
fmt.Println(" h2 = h & 0x7f (младшие 7 бит) — ложится в управляющий байт")
fmt.Println()
fmt.Printf(" %-8s %-20s %-8s %-6s %s\n", "ключ", "хеш", "h2", "группа", "младшие 16 бит хеша")
for _, k := range keys {
h := hash(k)
fmt.Printf(" %-8s 0x%016x 0x%02x %-6d %s|%s\n",
k, h, h2(h), h1(h)&3,
fmt.Sprintf("%09b", (h>>7)&0x1ff), fmt.Sprintf("%07b", h&0x7f))
}
fmt.Println()
fmt.Println(" Столбец справа — это одно и то же число, разрезанное по седьмому биту:")
fmt.Println(" слева от черты уходит в h1, справа — в h2. Ни один бит не участвует")
fmt.Println(" в обеих ролях, поэтому совпадение метки ничего не говорит о группе,")
fmt.Println(" а номер группы ничего не говорит о метке.")
}
// ------------------------------------------------------------ блок 2
func printTrace(title, key string, t *table, trace []step) {
h := hash(key)
fmt.Printf(" %s\n", title)
fmt.Printf(" m[%q] = …\n", key)
fmt.Printf(" хеш 0x%016x h1 = 0x%014x h2 = 0x%02x старт: группа %d\n",
h, h1(h), h2(h), h1(h)&t.mask)
for i, s := range trace {
fmt.Printf(" шаг %d · группа %d · %s\n", i+1, s.group, s.kind)
if len(s.candidat) > 0 {
fmt.Printf(" метка совпала в слотах: %v\n", s.candidat)
}
if s.kind == "write" || s.kind == "update" || s.kind == "probe-deleted" {
fmt.Printf(" слот %d\n", s.slotIdx)
}
if s.kind == "write" && s.endGroup != s.group {
fmt.Printf(" путь дошёл до свободного слота в группе %d, слот %d,\n", s.endGroup, s.endSlot)
fmt.Printf(" но запись — в группу %d: там ждал удалённый слот\n", s.group)
}
fmt.Printf(" %s\n", s.comment)
}
fmt.Printf(" len после операции: %d\n\n", t.used)
}
// ------------------------------------------------------------ блок 3
//
// Данные для картинки печатаются здесь же, а не переносятся в неё руками.
// Правило простое: если число видно читателю, оно должно быть в записи
// прогона. Копировать глазами таблицу из восьми слотов на четыре группы —
// верный способ ошибиться в одном байте и не заметить.
func jsonSlots(t *table) string {
var groups []string
for g := range t.ctrl {
var cells []string
for i := 0; i < slotsPerGroup; i++ {
c := t.ctrlByte(g, i)
switch {
case c == ctrlEmpty:
cells = append(cells, `{"s":"empty"}`)
case c == ctrlDeleted:
cells = append(cells, `{"s":"deleted"}`)
default:
cells = append(cells, fmt.Sprintf(`{"s":"used","h2":%d,"k":%q}`, c, t.slots[g][i].key))
}
}
groups = append(groups, " ["+strings.Join(cells, ",")+"]")
}
return "[\n" + strings.Join(groups, ",\n") + "\n ]"
}
func jsonTrace(key string, t *table, trace []step) string {
h := hash(key)
var steps []string
for _, s := range trace {
steps = append(steps, fmt.Sprintf(
` {"group":%d,"kind":%q,"slot":%d,"endGroup":%d,"endSlot":%d,"cands":%s}`,
s.group, s.kind, s.slotIdx, s.endGroup, s.endSlot, jsonInts(s.candidat)))
}
return fmt.Sprintf("{\n \"key\":%q,\n \"hash\":\"0x%016x\",\n \"h1\":\"0x%x\",\n \"h2\":%d,\n \"start\":%d,\n \"lenBefore\":%d,\n \"steps\":[\n%s\n ]\n}",
key, h, h1(h), h2(h), h1(h)&t.mask, t.used, strings.Join(steps, ",\n"))
}
func jsonInts(xs []int) string {
if len(xs) == 0 {
return "[]"
}
parts := make([]string, len(xs))
for i, x := range xs {
parts[i] = fmt.Sprint(x)
}
return "[" + strings.Join(parts, ",") + "]"
}
// ------------------------------------------------------------ блок 4
func blockSelfCheck() {
header(4, "ПРОВЕРКА ПЕРЕНОСА")
// 4.1. Битовые формулы против честного перебора.
rng := rand.New(rand.NewSource(20260902))
bad := 0
for n := 0; n < 200000; n++ {
var b [slotsPerGroup]uint8
var word uint64
for i := range b {
switch rng.Intn(4) {
case 0:
b[i] = ctrlEmpty
case 1:
b[i] = ctrlDeleted
default:
b[i] = uint8(rng.Intn(128))
}
word |= uint64(b[i]) << (8 * i)
}
h := uint8(rng.Intn(128))
var wantH2, wantEmpty, wantEOD []int
for i, c := range b {
if c == h {
wantH2 = append(wantH2, i)
}
if c == ctrlEmpty {
wantEmpty = append(wantEmpty, i)
}
if c == ctrlEmpty || c == ctrlDeleted {
wantEOD = append(wantEOD, i)
}
}
gotH2 := setBits(matchH2(word, h))
if !covers(gotH2, wantH2) || !equal(setBits(matchEmpty(word)), wantEmpty) ||
!equal(setBits(matchEmptyOrDeleted(word)), wantEOD) {
bad++
}
}
fmt.Printf(" формулы группы против перебора, 200 000 случайных групп: расхождений %d\n", bad)
fmt.Println(" (matchH2 сверяется на «не пропустил ни одного настоящего совпадения»:")
fmt.Println(" лишние кандидаты формуле разрешены и стоят одного сравнения ключа)")
// 4.2. Треугольный путь обходит каждую группу ровно один раз.
fmt.Println()
for _, groups := range []int{4, 8, 16, 64, 1024} {
mask := uint64(groups - 1)
ok := true
for start := uint64(0); start < uint64(groups) && ok; start++ {
seen := make(map[uint64]bool, groups)
s := makeProbeSeq(start, mask)
for i := 0; i < groups; i++ {
if seen[s.offset] {
ok = false
break
}
seen[s.offset] = true
s = s.next()
}
if len(seen) != groups {
ok = false
}
}
fmt.Printf(" путь по %4d группам обходит каждую ровно один раз: %v\n", groups, ok)
}
}
func setBits(mask uint64) []int {
var out []int
for i := 0; i < slotsPerGroup; i++ {
if mask&(1<<(8*i+7)) != 0 {
out = append(out, i)
}
}
return out
}
func equal(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
// covers — все ожидаемые слоты названы (лишние формуле разрешены).
func covers(got, want []int) bool {
set := map[int]bool{}
for _, g := range got {
set[g] = true
}
for _, w := range want {
if !set[w] {
return false
}
}
return true
}
// ------------------------------------------------------------ блок 5
func blockAgainstRealMap() {
header(5, "СВЕРКА С НАСТОЯЩЕЙ КАРТОЙ")
fmt.Println(" Внутренности воспроизведены, поэтому наблюдаемая часть обязана совпасть")
fmt.Println(" с тем, что делает настоящая map[string]int. Проверяется то, что видно")
fmt.Println(" снаружи: как len отвечает на вставку нового ключа, на повторную вставку")
fmt.Println(" того же ключа и на вставку после удаления.")
fmt.Println()
real := map[string]int{}
model := newTable(4)
type op struct {
kind string
key string
}
ops := []op{
{"put", "alpha"}, {"put", "bravo"}, {"put", "delta"},
{"put", "alpha"}, // повторная вставка того же ключа
{"del", "bravo"},
{"put", "echo"},
{"put", "bravo"}, // вставка после удаления
}
mismatch := 0
for _, o := range ops {
switch o.kind {
case "put":
real[o.key] = 1
model.put(o.key, 1)
case "del":
delete(real, o.key)
model.del(o.key)
}
mark := "="
if len(real) != model.used {
mark = "РАСХОЖДЕНИЕ"
mismatch++
}
fmt.Printf(" %-4s %-7s len настоящей: %d len модели: %d %s\n",
o.kind, o.key, len(real), model.used, mark)
}
fmt.Printf("\n расхождений: %d\n", mismatch)
}
// ------------------------------------------------------------ подбор ключей
// findKeys подбирает ключи под нужные сценарии на настоящих хешах, а не
// подгоняет числа: перебираются короткие имена вида k0, k1, …, и берутся
// первые, у которых складывается нужная ситуация.
func candidates(n int) []string {
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, fmt.Sprintf("k%d", i))
}
return out
}
func main() {
fmt.Println("ВСТАВКА В КАРТУ GO: ЧТО ДЕЛАЕТ H1, А ЧТО H2")
fmt.Println()
fmt.Println("Алгоритм — перенос table.PutSlot из go1.24.7. Хеш-функция взята")
fmt.Println("детерминированная (FNV-1a), потому что внутренний хеш карты сеется")
fmt.Println("случайно при её создании и снаружи не воспроизводится. Таблица")
fmt.Println("сокращена до 4 групп по 8 слотов; сокращена только ширина, не правила.")
blockSplit([]string{"alpha", "bravo", "delta", "echo", "foxtrot"})
// --- строим таблицу настоящими вставками
header(2, "ТРАССА ВСТАВКИ")
t := newTable(4)
var base []string
for _, k := range candidates(400) {
if len(base) == 22 {
break
}
base = append(base, k)
t.put(k, 1)
}
fmt.Printf(" Исходное состояние: вставлено %d ключей (%s …), занято %d из 32 слотов,\n",
len(base), strings.Join(base[:3], ", "), t.used)
fmt.Printf(" запас роста %d. В каждом занятом слоте показана его метка — h2.\n\n", t.growthLeft)
t.dump(" ")
fmt.Println()
// Собранные сценарии уезжают в блок 3 — данные для картинки.
type shot struct {
id string
title string
base *table
key string
trace []step
}
var shots []shot
// СЦЕНАРИЙ А: обычная вставка нового ключа.
fresh := ""
for _, k := range candidates(4000) {
h := hash(k)
g := int(h1(h) & t.mask)
if matchEmpty(t.ctrl[g]) != 0 && matchH2(t.ctrl[g], h2(h)) == 0 && !contains(base, k) {
fresh = k
break
}
}
w := t.clone()
tr := w.put(fresh, 1)
printTrace("СЦЕНАРИЙ А · новый ключ, в стартовой группе есть место", fresh, w, tr)
shots = append(shots, shot{"plain", "новый ключ, в стартовой группе есть место", t, fresh, tr})
// СЦЕНАРИЙ Б: ложное совпадение метки.
falseHit := ""
for _, k := range candidates(20000) {
h := hash(k)
g := int(h1(h) & t.mask)
if matchH2(t.ctrl[g], h2(h)) != 0 && matchEmptyOrDeleted(t.ctrl[g]) != 0 && !contains(base, k) {
falseHit = k
break
}
}
if falseHit != "" {
w = t.clone()
tr = w.put(falseHit, 1)
printTrace("СЦЕНАРИЙ Б · метка совпала, а ключ чужой", falseHit, w, tr)
shots = append(shots, shot{"falsehit", "метка совпала, а ключ чужой", t, falseHit, tr})
}
// СЦЕНАРИЙ В: повторная вставка существующего ключа.
w = t.clone()
tr = w.put(base[0], 42)
printTrace("СЦЕНАРИЙ В · ключ уже есть: значение переписывается на месте", base[0], w, tr)
shots = append(shots, shot{"update", "ключ уже есть: значение переписывается на месте", t, base[0], tr})
// СЦЕНАРИЙ Г: стартовая группа занята целиком — путь идёт дальше.
//
// Первый заход брал первый попавшийся ключ, стартующий в полной группе, и
// не проверял, нет ли такого ключа уже в таблице. Попался ключ, который в
// ней лежал, — и вместо пути по группам напечаталось обновление на месте.
// Отсюда условие `!inTable`: сценарий про путь, а не про совпадение.
full := ""
for _, k := range candidates(20000) {
if contains(base, k) {
continue
}
g := int(h1(hash(k)) & t.mask)
if matchEmptyOrDeleted(t.ctrl[g]) == 0 {
full = k
break
}
}
if full != "" {
w = t.clone()
tr = w.put(full, 1)
printTrace("СЦЕНАРИЙ Г · стартовая группа занята целиком", full, w, tr)
shots = append(shots, shot{"probe", "стартовая группа занята целиком", t, full, tr})
}
// СЦЕНАРИЙ Д: по дороге встретился удалённый слот.
dt, dkey, dtrace := blockDeleted()
shots = append(shots, shot{"deleted", "по дороге лежит удалённый слот", dt, dkey, dtrace})
header(3, "ДАННЫЕ ДЛЯ КАРТИНКИ")
fmt.Println(" Ровно то, что показывает MapInsert. Переносится в компонент как есть.")
for _, sh := range shots {
fmt.Printf("\n--- %s · %s\n", sh.id, sh.title)
fmt.Println(" таблица до вставки:")
fmt.Println(jsonSlots(sh.base))
fmt.Println(" трасса:")
fmt.Println(jsonTrace(sh.key, sh.base, sh.trace))
}
blockSelfCheck()
blockAgainstRealMap()
fmt.Println()
fmt.Println("Все строки выше напечатаны этой программой. Правила — из go1.24.7:")
fmt.Println("internal/runtime/maps/{map.go,table.go,group.go}.")
}
// blockDeleted строит расклад, в котором путь вставки ПРОХОДИТ через удалённый
// слот раньше, чем доходит до свободного. Только в таком раскладе видно правило
// из PutSlot: первый удалённый слот запоминается, но путь на нём не кончается.
//
// Расклад не рисуется байтами, а достигается настоящими вставками и удалением.
// Подобрать его пришлось по треугольному пути: из группы 1 он идёт
// 1 → 2 → 0 → 3. Значит, группы 1 и 0 должны быть полными, удалённый слот —
// в группе 2, а свободный — только в группе 3.
//
// Первый заход этого не учёл: ключ стартовал в группе 0, путь пошёл 0 → 1 → 3,
// и группу 2 с удалённым слотом просто миновал. Трасса получалась верной, но
// показывала не то правило, ради которого написана.
func blockDeleted() (*table, string, []step) {
t := newTable(4)
byGroup := map[int][]string{}
for _, k := range candidates(6000) {
g := int(h1(hash(k)) & t.mask)
byGroup[g] = append(byGroup[g], k)
}
// Группы 1 и 0 — под завязку, группа 2 — под завязку и потом одно удаление,
// в группе 3 остаётся свободное место.
var placed []string
fill := func(g, n int) []string {
var got []string
for _, k := range byGroup[g] {
if len(got) == n {
break
}
if int(h1(hash(k))&t.mask) != g {
continue
}
t.put(k, 1)
got = append(got, k)
placed = append(placed, k)
}
return got
}
fill(0, 8)
fill(1, 8)
inTwo := fill(2, 8)
fill(3, 2)
fmt.Println(" СЦЕНАРИЙ Д · по дороге лежит удалённый слот")
fmt.Println()
fmt.Println(" Состояние построено вставками и удалением, а не расстановкой байтов.")
fmt.Printf(" Группы 0, 1 и 2 заполнены целиком; в группе 2 удаляется ключ %q.\n", inTwo[3])
t.del(inTwo[3])
fmt.Println()
t.dump(" ")
fmt.Println()
fmt.Println(" Удалённый слот помечен «удал», а не «····»: группа была полной, и")
fmt.Println(" пометить слот свободным нельзя — это оборвало бы чужие пути поиска.")
fmt.Println(" Свободные слоты остались только в группе 3.")
fmt.Println()
// Ключ, стартующий в группе 1: треугольный путь ведёт 1 → 2 → 0 → 3, то есть
// удалённый слот встретится раньше свободного.
target := ""
for _, k := range byGroup[1] {
if !contains(placed, k) && int(h1(hash(k))&t.mask) == 1 {
target = k
break
}
}
before := t.clone()
tr := t.put(target, 1)
printTrace("вставка ключа, стартующего в полной группе 1", target, t, tr)
fmt.Println(" Освободившееся место переиспользовано, и запас роста на него не потрачен.")
fmt.Println()
t.dump(" ")
fmt.Println()
return before, target, tr
}