From 813405aa881fc77e7b37000b8ee1b32b19d1211a Mon Sep 17 00:00:00 2001 From: cloudnative0x0 <> Date: Tue, 11 Aug 2026 14:28:39 +0300 Subject: [PATCH] swiss table added. --- swiss_table/README.md | 117 ++++++++ swiss_table/swiss_table.go | 464 +++++++++++++++++++++++++++++++ swiss_table/swiss_table_test.go | 475 ++++++++++++++++++++++++++++++++ 3 files changed, 1056 insertions(+) create mode 100644 swiss_table/README.md create mode 100644 swiss_table/swiss_table.go create mode 100644 swiss_table/swiss_table_test.go diff --git a/swiss_table/README.md b/swiss_table/README.md new file mode 100644 index 0000000..8af07d4 --- /dev/null +++ b/swiss_table/README.md @@ -0,0 +1,117 @@ +# FastSwissMap + +

+ РусскийEnglish +

+ +--- + +## Русский + +FastSwissMap — хеш-таблица общего назначения, построенная на двух идеях: swiss table (батчевый поиск по control-байтам внутри группы из 8 слотов) и extendible hashing (директория указателей на bucket'ы, растущая делением, а не полным рехэшем). + +Обычная swiss table при переполнении рехэширует всю таблицу целиком. Здесь вместо этого каждый bucket — отдельная небольшая swiss table. Пока bucket не достиг `maxBucketCapacity` (64 слота), он просто удваивается на месте. Когда предел достигнут, bucket расщепляется на два по одному биту хэша, а рехэшу подвергаются только записи этого одного bucket'а, а не вся карта. + +### Внутреннее устройство + +**Control-байты и группы.** Каждая группа занимает один `uint64` (`groupSize = 8`, по байту на слот). Пустой слот кодируется как `0b10000000`, удалённый (tombstone) — как `0b11111110`. Поиск совпадающего байта в группе делает `matchByte` — трюк SWAR (широкое XOR + вычитание + маска знаковых битов), который находит все позиции сразу, без цикла по восьми байтам. У этого трюка есть цена: вычитание всего 64-битного слова разом может дать ложное совпадение из-за переноса между байтами (borrow chain), поэтому кандидаты из маски перепроверяются побайтово в `firstVerified`. + +**Разбиение хэша.** `splitHash` делит 64-битный хэш на `h1` (верхние 57 бит — индекс в директории плюс затравка пробинга внутри bucket'а) и `h2` (младшие 7 бит — то, что кладётся в control-байт). Индекс в директории берётся как верхние `globalDepth` бит хэша (`dirIndex`), то есть один и тот же топ-срез бит определяет и позицию в директории, и по какую сторону разреза окажется запись при расщеплении bucket'а. + +**Пробинг.** Внутри bucket'а группы перебираются квадратичным пробингом — смещения растут по треугольным числам (0, 1, 3, 6, 10, ...), а не на единицу за шаг, что снижает первичную кластеризацию. Последовательность биективна по модулю числа групп, поэтому цикл `probe < b.groups` гарантированно обходит каждую группу ровно один раз и завершается. + +**growthLeft.** Счётчик реально свободных (`ctrlEmpty`) слотов. Удаление не восстанавливает его — только помечает слот как tombstone или, если группа никогда не была заполнена целиком, сразу как `ctrlEmpty`. Если бы tombstone восполнял growthLeft, bucket мог бы физически забиться удалёнными байтами при малом числе живых записей и никогда не вырасти: `Get`/`Put` крутились бы в поиске пустого слота, которого физически не осталось. + +**Удаление.** Если в группе целевого слота уже есть хотя бы один `ctrlEmpty`, значит группа никогда не заполнялась полностью и не участвует в обрыве цепочки пробинга — слот сразу понижается до `ctrlEmpty`, а `growthLeft` растёт. Иначе ставится tombstone: без него цепочка пробинга для других ключей, проходящая через этот слот, оборвалась бы преждевременно. + +**Рост bucket'а.** Пока `capacity() < maxBucketCapacity`, `Put` при нехватке места вызывает `rehashInPlace` — bucket того же `localDepth`, вдвое больше, без tombstone'ов. Когда предел достигнут, вызывается `splitBucket`: если `localDepth` bucket'а уже равен `globalDepth` директории, директория сперва удваивается (`growDirectory`), затем bucket делится на `lo`/`hi` по значению бита хэша на позиции `64 - newDepth` — первого бита, ещё не использованного директорией. Оба новых bucket'а получают `localDepth + 1`, а во все ячейки директории, ссылавшиеся на старый bucket, записываются `lo` или `hi` в зависимости от значения этого бита. + +### Использование + +```go +m := NewFastSwissMap[string, int](hashString) +m.Put("a", 1) +m.Put("b", 2) +val, ok := m.Get("a") // val = 1, ok = true +m.Delete("b") +n := m.Len() // n = 1 +``` + +Функция хэширования передаётся снаружи (`hash func(K) uint64`) — таблица не привязана к конкретной хэш-функции. + +### Операции + +| Операция | Сложность | Описание | +|---|---|---| +| `Get(key)` | O(1) амортизированно | вернуть значение по ключу | +| `Put(key, val)` | O(1) амортизированно | вставить или обновить значение | +| `Delete(key)` | O(1) амортизированно | удалить запись по ключу | +| `Len()` | O(1) | текущее число элементов | + +Максимальная средняя загрузка группы — `maxAvgGroupLoad = 7` из 8 слотов (~87.5%), после чего `growthLeft` обнуляется и bucket растёт. + +### Сборка и тестирование + +```bash +go test -v ./... +``` + +--- + +## English + +FastSwissMap is a general-purpose hash map combining two ideas: swiss table (batched lookup over control bytes within groups of 8 slots) and extendible hashing (a directory of bucket pointers that grows by splitting, not by a full rehash). + +A plain swiss table rehashes everything once it overflows. Here, each bucket is its own small swiss table instead. As long as a bucket is below `maxBucketCapacity` (64 slots), it just doubles in place. Once the limit is hit, the bucket splits into two along a single hash bit, and only that one bucket's entries get rehashed — not the whole map. + +### Internal layout + +**Control bytes and groups.** Each group occupies one `uint64` (`groupSize = 8`, one byte per slot). An empty slot is encoded as `0b10000000`, a deleted one (tombstone) as `0b11111110`. Matching a byte within a group is done by `matchByte` — a SWAR trick (broadcast XOR, subtract, mask the sign bits) that finds all matching positions at once instead of looping over eight bytes. The trick has a catch: subtracting the whole 64-bit word at once can produce a false positive from a borrow chain crossing byte boundaries, so candidates from the mask get re-verified byte by byte in `firstVerified`. + +**Hash split.** `splitHash` divides the 64-bit hash into `h1` (upper 57 bits — directory index plus the probe seed inside the bucket) and `h2` (lower 7 bits — what gets stored in the control byte). The directory index is the top `globalDepth` bits of the hash (`dirIndex`), so the same top-bit slice both selects the directory slot and decides which side of the split an entry lands on when a bucket is divided. + +**Probing.** Groups within a bucket are visited with quadratic probing — offsets grow by triangular numbers (0, 1, 3, 6, 10, ...) instead of incrementing by one each step, which avoids primary clustering. The sequence is a bijection modulo the group count, so the `probe < b.groups` loop is guaranteed to visit every group exactly once and terminate. + +**growthLeft.** A counter of genuinely empty (`ctrlEmpty`) slots. Deletion never refills it — a slot is marked either as a tombstone or, if its group was never fully occupied, directly as `ctrlEmpty`. If tombstones did refill growthLeft, a bucket could fill up with deleted bytes while the live entry count stayed low and never trigger growth: `Get`/`Put` would spin looking for an empty slot that no longer physically exists. + +**Deletion.** If the target slot's group already contains an empty byte, the group was never fully occupied and doesn't gate probe termination — the slot is downgraded straight to `ctrlEmpty` and `growthLeft` increases. Otherwise it becomes a tombstone: without it, probing for other keys whose chain passes through this slot would terminate too early. + +**Bucket growth.** While `capacity() < maxBucketCapacity`, a `Put` that runs out of room triggers `rehashInPlace` — a bucket of the same `localDepth`, twice the size, with tombstones cleared. Once the limit is reached, `splitBucket` runs: if the bucket's `localDepth` already equals the directory's `globalDepth`, the directory doubles first (`growDirectory`), then the bucket splits into `lo`/`hi` based on the hash bit at position `64 - newDepth` — the first bit not yet consumed by the directory. Both new buckets get `localDepth + 1`, and every directory slot that pointed at the old bucket is rewritten to `lo` or `hi` depending on that bit. + +### Usage + +```go +m := NewFastSwissMap[string, int](hashString) +m.Put("a", 1) +m.Put("b", 2) +val, ok := m.Get("a") // val = 1, ok = true +m.Delete("b") +n := m.Len() // n = 1 +``` + +The hash function is supplied by the caller (`hash func(K) uint64`) — the table isn't tied to any specific hashing scheme. + +### Operations + +| Operation | Complexity | Description | +|---|---|---| +| `Get(key)` | O(1) amortized | return the value for a key | +| `Put(key, val)` | O(1) amortized | insert or update a value | +| `Delete(key)` | O(1) amortized | remove an entry by key | +| `Len()` | O(1) | current number of elements | + +Maximum average group load is `maxAvgGroupLoad = 7` out of 8 slots (~87.5%), past which `growthLeft` hits zero and the bucket grows. + +### Build and test + +```bash +go test -v ./... +``` + +--- + +
+ +> Директория не хранит записи — она хранит право спросить нужный bucket. Когда bucket переполняется, делится не карта, а один-единственный узел ответственности. +> +> *The directory holds no entries of its own — only the right to ask the correct bucket. When a bucket overflows, what splits is not the map, but one single unit of responsibility.* \ No newline at end of file diff --git a/swiss_table/swiss_table.go b/swiss_table/swiss_table.go new file mode 100644 index 0000000..1909398 --- /dev/null +++ b/swiss_table/swiss_table.go @@ -0,0 +1,464 @@ +package swiss_table + +import "math/bits" + +const ( + groupSize = 8 + ctrlEmpty = 0b10000000 + ctrlDeleted = 0b11111110 + maxAvgGroupLoad = 7 + maxBucketCapacity = 64 +) + +type slot[K comparable, V any] struct { + key K + val V +} + +func broadcast(b byte) uint64 { return uint64(b) * 0x0101010101010101 } + +func matchByte(word uint64, b byte) uint64 { + x := word ^ broadcast(b) + return (x - 0x0101010101010101) &^ x & 0x8080808080808080 +} + +func setByte(word uint64, pos int, b byte) uint64 { + shift := uint(pos) * 8 + return (word &^ (0xFF << shift)) | (uint64(b) << shift) +} + +func byteAt(word uint64, pos int) byte { return byte(word >> (uint(pos) * 8)) } + +// firstVerified: matchByte даёт ложные срабатывания (borrow-цепочка при вычитании +// всего 64-битного слова разом), поэтому позиции из mask перепроверяются побайтово. +// firstVerified: matchByte can false-positive (borrow chain from subtracting the whole +// 64-bit word at once), so positions from mask are re-checked byte by byte. +func firstVerified(word uint64, mask uint64, want byte) (pos int, ok bool) { + for mask != 0 { + p := bits.TrailingZeros64(mask) / 8 + if byteAt(word, p) == want { + return p, true + } + mask &= mask - 1 + } + return 0, false +} + +// splitHash: h1 — верхние 57 бит (индекс в директории + затравка пробинга), +// h2 — младшие 7 бит (кладутся в control-байт). +// splitHash: h1 — upper 57 bits (directory index + probe seed), +// h2 — lower 7 bits (stored as the control byte). +func splitHash(hash uint64) (h1 uint64, h2 byte) { + return hash >> 7, byte(hash & 0x7F) +} + +// dirIndex = hash >> (64-globalDepth): те же top globalDepth бит, что и в cockroachdb/swiss. +// dirIndex = hash >> (64-globalDepth): same top-globalDepth-bits rule as cockroachdb/swiss. +func dirIndex(hash uint64, globalDepth uint) uint64 { + if globalDepth == 0 { + return 0 + } + return hash >> (64 - globalDepth) +} + +// probeSeq — квадратичный пробинг между группами: смещения — треугольные числа +// (0,1,3,6,10,...) вместо "+1", убирает первичную кластеризацию. +// probeSeq — quadratic probing across groups: triangular-number offsets +// (0,1,3,6,10,...) instead of "+1", removes primary clustering. +type probeSeq struct { + mask uint64 + offset uint64 + index uint64 +} + +func makeProbeSeq(h1 uint64, mask uint64) probeSeq { + return probeSeq{mask: mask, offset: h1 & mask} +} + +func (s probeSeq) next() probeSeq { + s.index++ + s.offset = (s.offset + s.index) & s.mask + return s +} + +// bucket — отдельная swiss-таблица, адресуется через extendible hashing. +// Растёт удвоением до maxBucketCapacity, дальше — split на два bucket'а +// с localDepth+1 вместо рехэша всей карты. +// bucket — a standalone swiss table, addressed via extendible hashing. +// Doubles in place up to maxBucketCapacity, then splits into two buckets +// with localDepth+1 instead of rehashing the whole map. +type bucket[K comparable, V any] struct { + ctrl []uint64 + slots []slot[K, V] + groups int + used int + + // growthLeft — число ещё доступных НАСТОЯЩИХ empty-слотов. Tombstone его + // не восполняет: иначе bucket может физически забиться deleted-байтами + // при малом used и никогда не вырасти → Get/Put зациклятся, не найдя ни + // одного ctrlEmpty. + // growthLeft — count of remaining REAL empty slots. Tombstones don't refill it: + // otherwise a bucket can fill up with deleted bytes while used stays low and + // never grow → Get/Put would loop forever finding no ctrlEmpty. + growthLeft int + localDepth uint +} + +func newBucket[K comparable, V any](groups int, localDepth uint) *bucket[K, V] { + b := &bucket[K, V]{ + ctrl: make([]uint64, groups), + slots: make([]slot[K, V], groups*groupSize), + groups: groups, + localDepth: localDepth, + } + b.growthLeft = (b.capacity() * maxAvgGroupLoad) / groupSize + empty := broadcast(ctrlEmpty) + for i := range b.ctrl { + b.ctrl[i] = empty + } + return b +} + +func (b *bucket[K, V]) capacity() int { return b.groups * groupSize } + +func (b *bucket[K, V]) get(h1 uint64, h2 byte, key K) (val V, ok bool) { + seq := makeProbeSeq(h1, uint64(b.groups-1)) + // probe < b.groups — явная граница: квадратичная последовательность по модулю + // степени двойки обходит каждую группу ровно раз за b.groups шагов, поэтому + // цикл гарантированно конечен. Страховка на случай нарушения инварианта growthLeft. + + // probe < b.groups — explicit bound: quadratic probing modulo a power of two + // visits every group exactly once in b.groups steps, so the loop always + // terminates. Safety net in case the growthLeft invariant is ever violated. + for probe := 0; probe < b.groups; probe++ { + word := b.ctrl[seq.offset] + + for matches := matchByte(word, h2); matches != 0; matches &= matches - 1 { + pos := bits.TrailingZeros64(matches) / 8 + idx := int(seq.offset)*groupSize + pos + if b.slots[idx].key == key { + return b.slots[idx].val, true + } + } + if empty := matchByte(word, ctrlEmpty); empty != 0 { + if _, ok := firstVerified(word, empty, ctrlEmpty); ok { + var zero V + return zero, false + } + } + seq = seq.next() + } + var zero V + return zero, false +} + +// putIfPresent обновляет значение существующего ключа; false, если дошли до +// пустого слота (ключа в bucket'е нет). +// putIfPresent updates the value of an existing key; false once an empty slot +// is reached (key not present). +func (b *bucket[K, V]) putIfPresent(h1 uint64, h2 byte, key K, val V) bool { + seq := makeProbeSeq(h1, uint64(b.groups-1)) + for probe := 0; probe < b.groups; probe++ { + word := b.ctrl[seq.offset] + + for matches := matchByte(word, h2); matches != 0; matches &= matches - 1 { + pos := bits.TrailingZeros64(matches) / 8 + idx := int(seq.offset)*groupSize + pos + if b.slots[idx].key == key { + b.slots[idx].val = val + return true + } + } + if empty := matchByte(word, ctrlEmpty); empty != 0 { + if _, ok := firstVerified(word, empty, ctrlEmpty); ok { + return false + } + } + seq = seq.next() + } + return false +} + +// insert вставляет новую пару, предполагая, что ключа ещё нет (проверка — на +// вызывающей стороне). false = свободного слота нет, bucket нужно расщепить/растить. +// insert adds a new pair assuming the key is absent (caller's responsibility to check). +// false = no free slot, bucket must be grown or split. +func (b *bucket[K, V]) insert(h1 uint64, h2 byte, key K, val V) bool { + seq := makeProbeSeq(h1, uint64(b.groups-1)) + delGroup, delPos := -1, -1 + + for probe := 0; probe < b.groups; probe++ { + g := int(seq.offset) + word := b.ctrl[g] + + if del := matchByte(word, ctrlDeleted); del != 0 && delGroup == -1 { + if p, ok := firstVerified(word, del, ctrlDeleted); ok { + delGroup, delPos = g, p + } + } + + if empty := matchByte(word, ctrlEmpty); empty != 0 { + if p, ok := firstVerified(word, empty, ctrlEmpty); ok { + tg, tp := g, p + if delGroup != -1 { + tg, tp = delGroup, delPos + } + idx := tg*groupSize + tp + if delGroup == -1 { + b.growthLeft-- + } + b.ctrl[tg] = setByte(b.ctrl[tg], tp, h2) + b.slots[idx] = slot[K, V]{key: key, val: val} + b.used++ + return true + } + } + seq = seq.next() + } + return false +} + +// delete: если в группе уже есть empty-байт, группа никогда не была полностью +// заполненной и не участвует в обрыве цепочки пробинга — слот сразу помечается +// empty (downgrade), иначе ставится tombstone, чтобы не сломать пробинг для +// ключей, чья цепочка проходит через этот слот. +// delete: if the group already has an empty byte, it was never fully occupied +// and doesn't gate probe termination — the slot is downgraded straight to empty; +// otherwise it becomes a tombstone so probing for other keys through this slot +// still works. +func (b *bucket[K, V]) delete(h1 uint64, h2 byte, key K) bool { + seq := makeProbeSeq(h1, uint64(b.groups-1)) + for probe := 0; probe < b.groups; probe++ { + g := int(seq.offset) + word := b.ctrl[g] + + for matches := matchByte(word, h2); matches != 0; matches &= matches - 1 { + pos := bits.TrailingZeros64(matches) / 8 + idx := g*groupSize + pos + if b.slots[idx].key == key { + if empty := matchByte(word, ctrlEmpty); empty != 0 { + if _, ok := firstVerified(word, empty, ctrlEmpty); ok { + b.ctrl[g] = setByte(word, pos, ctrlEmpty) + b.growthLeft++ + } else { + b.ctrl[g] = setByte(word, pos, ctrlDeleted) + } + } else { + b.ctrl[g] = setByte(word, pos, ctrlDeleted) + } + var zero slot[K, V] + b.slots[idx] = zero + b.used-- + return true + } + } + if empty := matchByte(word, ctrlEmpty); empty != 0 { + if _, ok := firstVerified(word, empty, ctrlEmpty); ok { + return false + } + } + seq = seq.next() + } + return false +} + +// rehashInPlace переносит живые записи в новый bucket той же localDepth, вдвое +// больше — убирает tombstone'ы без расщепления по биту хэша. +// rehashInPlace copies live entries into a new bucket, same localDepth, double +// size — clears tombstones without splitting on a hash bit. +func (b *bucket[K, V]) rehashInPlace(hash func(K) uint64) *bucket[K, V] { + grown := newBucket[K, V](b.groups*2, b.localDepth) + for g := 0; g < b.groups; g++ { + word := b.ctrl[g] + for pos := 0; pos < groupSize; pos++ { + c := byteAt(word, pos) + if c != ctrlEmpty && c != ctrlDeleted { + idx := g*groupSize + pos + h1, h2 := splitHash(hash(b.slots[idx].key)) + grown.insert(h1, h2, b.slots[idx].key, b.slots[idx].val) + } + } + } + return grown +} + +// split делит bucket на два с localDepth+1 по значению бита hash на позиции +// bitPos = 64-newDepth (первый ещё не использованный директорией бит). +// split partitions a bucket into two with localDepth+1, keyed on the hash bit +// at bitPos = 64-newDepth (the first bit not yet consumed by the directory). +func (b *bucket[K, V]) split(hash func(K) uint64) (lo, hi *bucket[K, V]) { + newDepth := b.localDepth + 1 + groups := b.groups + if groups < 1 { + groups = 1 + } + lo = newBucket[K, V](groups, newDepth) + hi = newBucket[K, V](groups, newDepth) + + bitPos := 64 - newDepth + for g := 0; g < b.groups; g++ { + word := b.ctrl[g] + for pos := 0; pos < groupSize; pos++ { + c := byteAt(word, pos) + if c != ctrlEmpty && c != ctrlDeleted { + idx := g*groupSize + pos + key, val := b.slots[idx].key, b.slots[idx].val + full := hash(key) + h1, h2 := splitHash(full) + dst := lo + if (full>>bitPos)&1 == 1 { + dst = hi + } + if !dst.insert(h1, h2, key, val) { + // dst сам переполнился при перераспределении — растим его на месте. + // dst overflowed during redistribution — grow it in place. + dst = dst.rehashInPlace(hash) + dst.insert(h1, h2, key, val) + } + if (full>>bitPos)&1 == 1 { + hi = dst + } else { + lo = dst + } + } + } + } + return lo, hi +} + +// FastSwissMap — swiss table с квадратичным пробингом и extendible hashing: +// рост = split одного переполненного bucket'а, а не рехэш всей карты. +// FastSwissMap — swiss table with quadratic probing and extendible hashing: +// growth means splitting one overflowing bucket, not rehashing the whole map. +type FastSwissMap[K comparable, V any] struct { + dir []*bucket[K, V] + globalDepth uint + count int + hash func(K) uint64 +} + +func NewFastSwissMap[K comparable, V any](hash func(K) uint64) *FastSwissMap[K, V] { + b := newBucket[K, V](1, 0) + return &FastSwissMap[K, V]{ + dir: []*bucket[K, V]{b}, + hash: hash, + } +} + +func (m *FastSwissMap[K, V]) bucketFor(hash uint64) (idx uint64, b *bucket[K, V]) { + idx = dirIndex(hash, m.globalDepth) + return idx, m.dir[idx] +} + +func (m *FastSwissMap[K, V]) Get(key K) (V, bool) { + full := m.hash(key) + h1, h2 := splitHash(full) + _, b := m.bucketFor(full) + return b.get(h1, h2, key) +} + +func (m *FastSwissMap[K, V]) Put(key K, val V) { + full := m.hash(key) + h1, h2 := splitHash(full) + + idx, b := m.bucketFor(full) + if b.putIfPresent(h1, h2, key, val) { + return + } + + // growthLeft>0 гарантирует хотя бы один настоящий empty где-то в bucket'е — + // insert() пройдёт по всем группам (probeSeq — биекция по modulo) и найдёт его. + + // growthLeft>0 guarantees at least one real empty somewhere in the bucket — + // insert() sweeps all groups (probeSeq is a bijection mod groups) and finds it. + if b.growthLeft > 0 { + if b.insert(h1, h2, key, val) { + m.count++ + return + } + } + + if b.capacity() < maxBucketCapacity { + // Не достигли предела bucket'а — растим удвоением на месте (заодно чистим tombstone'ы). + // Below the bucket size limit — grow in place by doubling (also clears tombstones). + grown := b.rehashInPlace(m.hash) + m.installBucket(idx, grown) + grown.insert(h1, h2, key, val) + m.count++ + return + } + + // Bucket достиг maxBucketCapacity — расщепляем вместо рехэша всей карты. + // Bucket hit maxBucketCapacity — split instead of rehashing the whole map. + m.splitBucket(idx, b) + m.Put(key, val) // директория и bucket изменились — вставляем заново / directory and bucket changed — retry insertion +} + +func (m *FastSwissMap[K, V]) Delete(key K) bool { + full := m.hash(key) + h1, h2 := splitHash(full) + _, b := m.bucketFor(full) + if b.delete(h1, h2, key) { + m.count-- + return true + } + return false +} + +func (m *FastSwissMap[K, V]) Len() int { return m.count } + +// installBucket пишет bucket во все 2^(globalDepth-localDepth) ячеек директории, +// которые на него указывают. +// installBucket writes the bucket into all 2^(globalDepth-localDepth) directory +// slots that reference it. +func (m *FastSwissMap[K, V]) installBucket(anyIdx uint64, b *bucket[K, V]) { + step := uint64(1) << (m.globalDepth - b.localDepth) + start := (anyIdx / step) * step + for i := uint64(0); i < step; i++ { + m.dir[start+i] = b + } +} + +// splitBucket расщепляет переполненный bucket на два. Если его localDepth уже +// равна globalDepth, директория сначала удваивается. +// splitBucket splits an overflowing bucket into two. If its localDepth already +// equals globalDepth, the directory is doubled first. +func (m *FastSwissMap[K, V]) splitBucket(idx uint64, b *bucket[K, V]) { + // origLocalDepth — глубина ДО расщепления: задаёт суммарное число ячеек + // директории для lo+hi. Глубина уже созданных lo/hi (localDepth+1) для этого + // не годится — с ней часть ячеек останется не переписана и укажет на + // выброшенный bucket (был баг именно на этом месте). + + // origLocalDepth — depth BEFORE the split: defines the total directory slot + // count for lo+hi combined. Using the already-incremented lo/hi depth here is + // wrong — some slots would stay unwritten and point at the discarded bucket + // (this exact spot was the bug). + origLocalDepth := b.localDepth + if origLocalDepth == m.globalDepth { + m.growDirectory() + // Директория удвоилась: старый индекс i -> пара новых {2i, 2i+1}. + // Directory doubled: old index i -> new pair {2i, 2i+1}. + idx *= 2 + } + lo, hi := b.split(m.hash) + + step := uint64(1) << (m.globalDepth - origLocalDepth) + start := (idx / step) * step + half := step / 2 + for i := uint64(0); i < half; i++ { + m.dir[start+i] = lo + } + for i := half; i < step; i++ { + m.dir[start+i] = hi + } +} + +func (m *FastSwissMap[K, V]) growDirectory() { + newDir := make([]*bucket[K, V], len(m.dir)*2) + for i, b := range m.dir { + newDir[2*i] = b + newDir[2*i+1] = b + } + m.dir = newDir + m.globalDepth++ +} diff --git a/swiss_table/swiss_table_test.go b/swiss_table/swiss_table_test.go new file mode 100644 index 0000000..295fbe9 --- /dev/null +++ b/swiss_table/swiss_table_test.go @@ -0,0 +1,475 @@ +package swiss_table + +import ( + "fmt" + "math/bits" + "math/rand" + "testing" + "time" +) + +func hashInt(k int) uint64 { + h := uint64(k) + h ^= h >> 33 + h *= 0xff51afd7ed558ccd + h ^= h >> 33 + h *= 0xc4ceb9fe1a85ec53 + h ^= h >> 33 + return h +} + +func TestEmptyMap(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + if m.Len() != 0 { + t.Fatalf("Len() = %d, want 0", m.Len()) + } + if _, ok := m.Get(42); ok { + t.Fatalf("Get on empty map returned ok=true") + } + if m.Delete(42) { + t.Fatalf("Delete on empty map returned true") + } +} + +func TestPutGetSingle(t *testing.T) { + m := NewFastSwissMap[int, string](hashInt) + m.Put(1, "one") + v, ok := m.Get(1) + if !ok || v != "one" { + t.Fatalf("Get(1) = (%q,%v), want (\"one\",true)", v, ok) + } + if m.Len() != 1 { + t.Fatalf("Len() = %d, want 1", m.Len()) + } +} + +func TestPutGetMany(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + const n = 1000 + for i := 0; i < n; i++ { + m.Put(i, i*i) + } + if m.Len() != n { + t.Fatalf("Len() = %d, want %d", m.Len(), n) + } + for i := 0; i < n; i++ { + v, ok := m.Get(i) + if !ok || v != i*i { + t.Fatalf("Get(%d) = (%d,%v), want (%d,true)", i, v, ok, i*i) + } + } +} + +func TestOverwriteDoesNotGrowLen(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + m.Put(42, 1) + m.Put(42, 2) + m.Put(42, 3) + if v, ok := m.Get(42); !ok || v != 3 { + t.Fatalf("Get(42) = (%d,%v), want (3,true)", v, ok) + } + if m.Len() != 1 { + t.Fatalf("Len() = %d, want 1 (overwrite must not add a new entry)", m.Len()) + } +} + +func TestGetMissingKey(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + for i := 0; i < 100; i++ { + m.Put(i, i) + } + for _, k := range []int{-1, 100, 100000, -100000} { + if _, ok := m.Get(k); ok { + t.Fatalf("Get(%d) = ok=true, want false (key was never inserted)", k) + } + } +} + +func TestDeleteReducesLen(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + for i := 0; i < 10; i++ { + m.Put(i, i) + } + if !m.Delete(5) { + t.Fatalf("Delete(5) = false, want true") + } + if m.Len() != 9 { + t.Fatalf("Len() = %d, want 9", m.Len()) + } + if _, ok := m.Get(5); ok { + t.Fatalf("Get(5) after Delete returned ok=true") + } + + for _, k := range []int{4, 6} { + if v, ok := m.Get(k); !ok || v != k { + t.Fatalf("Get(%d) = (%d,%v), want (%d,true)", k, v, ok, k) + } + } +} + +func TestDeleteMissingKeyIsNoop(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + m.Put(1, 1) + if m.Delete(999) { + t.Fatalf("Delete(999) = true, want false (key was never present)") + } + if m.Len() != 1 { + t.Fatalf("Len() = %d, want 1 (unaffected by no-op delete)", m.Len()) + } +} + +func TestDeleteThenReinsert(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + const n = 500 + for i := 0; i < n; i++ { + m.Put(i, i) + } + for i := 0; i < n; i += 2 { + if !m.Delete(i) { + t.Fatalf("Delete(%d) = false, want true", i) + } + } + if m.Len() != n/2 { + t.Fatalf("Len() = %d, want %d", m.Len(), n/2) + } + + for i := 0; i < n; i += 2 { + m.Put(i, i*10) + } + if m.Len() != n { + t.Fatalf("Len() = %d, want %d", m.Len(), n) + } + for i := 0; i < n; i++ { + want := i + if i%2 == 0 { + want = i * 10 + } + v, ok := m.Get(i) + if !ok || v != want { + t.Fatalf("Get(%d) = (%d,%v), want (%d,true)", i, v, ok, want) + } + } +} + +func TestZeroValueKeysAndValues(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + m.Put(0, 0) + v, ok := m.Get(0) + if !ok || v != 0 { + t.Fatalf("Get(0) = (%d,%v), want (0,true) — zero key/value must be distinguishable from absence", v, ok) + } + if !m.Delete(0) { + t.Fatalf("Delete(0) = false, want true") + } + if _, ok := m.Get(0); ok { + t.Fatalf("Get(0) after delete = ok=true, want false") + } +} + +func TestStringKeys(t *testing.T) { + hashStr := func(s string) uint64 { + var h uint64 = 14695981039346656037 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return h + } + m := NewFastSwissMap[string, int](hashStr) + words := []string{"", "a", "ab", "abc", "swiss", "table", "quadratic", "probing"} + for i, w := range words { + m.Put(w, i) + } + for i, w := range words { + v, ok := m.Get(w) + if !ok || v != i { + t.Fatalf("Get(%q) = (%d,%v), want (%d,true)", w, v, ok, i) + } + } + if m.Len() != len(words) { + t.Fatalf("Len() = %d, want %d", m.Len(), len(words)) + } +} + +func TestRehashInPlaceBelowCapacityLimit(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + + const n = 40 + for i := 0; i < n; i++ { + m.Put(i, i) + } + if m.globalDepth != 0 { + t.Fatalf("globalDepth = %d, want 0 (must not have split yet)", m.globalDepth) + } + if len(m.dir) != 1 { + t.Fatalf("len(dir) = %d, want 1", len(m.dir)) + } + for i := 0; i < n; i++ { + if v, ok := m.Get(i); !ok || v != i { + t.Fatalf("Get(%d) = (%d,%v), want (%d,true)", i, v, ok, i) + } + } +} + +func TestBucketSplitTriggers(t *testing.T) { + m := NewFastSwissMap[int, int](hashInt) + const n = 50_000 + for i := 0; i < n; i++ { + m.Put(i, i) + } + if m.globalDepth == 0 { + t.Fatalf("globalDepth = 0, want > 0 after %d inserts (split should have triggered)", n) + } + if len(m.dir) != 1<