Swiss Table Memory Layout
Rethink hash table memory layout for SIMD hardware by splitting metadata from payloads and extracting 7-bit hash signatures.
In the previous lesson, we built SimdFindAll() using xsimd. By loading a contiguous block of int8_t bytes into a wide vector register, we compared 16 or 32 elements in a single clock cycle, extracted a bitmask of matches, and hopped directly to the matching indices using std::countr_zero().
Naturally, we want to point this hardware capability directly at the hash tables we built in the previous chapter. Instead of walking a linear probe chain one slot at a time, we want the CPU to inspect 16 or 32 hash table buckets in parallel.
But before we can vectorize our probing loop, we should rethink how a hash table is laid out in RAM. In this lesson, we will tear apart our open-addressed container and rebuild it using the Swiss Table architecture, which is the design behind Google Abseil's absl::flat_hash_map and the modern industry standard for high-performance hash tables.
This is a relatively complex lesson that reuses techniques we introduced in our previous chapter on hash-based containers. Reviewing those lessons is recommended if the topics covered here do not make sense, particularly the and implementations as the core algorithms - Insert(), Contains(), and Grow() - are very similar to what we walked through in those lessons.
Leaving Robin Hood Behind
In the previous chapter, we upgraded our basic FlatHashSet into a
To prevent primary clustering from hurting our worst-case lookup times, we added a 1-byte DIB (Distance from Initial Bucket) counter to every slot.
This helped reduce the primary clustering problem - situations where a probe had to traverse 10 or more hash collisions before finding the requested key, or concluding the key wasn't in the container.
In scalar code, stepping through a probe chain of 10 slots meant executing 10 loop iterations, 10 key comparisons, 10 conditional checks with branch misprediction penalties.
However, as we've seen in previous lessons, the vector registers of modern hardware give us another option. We can load a bunch of slots into a vector register and check them all at once. Checking 16 or 32 values using SIMD isn't much slower than checking a single value using scalar code.
To build our Swiss Table, we will completely abandon the DIB counter, the Robin Hood swaps, and the backward-shift deletions. Instead, we'll lean into SIMD, creating algorithms and a memory layout optimized for these vector operations.
The Fat Slot Problem
If we try to apply xsimd to our FlatHashSet or RobinHoodSet, we immediately hit a physical wall. Our previous hash tables arranged their memory as an array of structures. We had a Slot struct that stored our key value alongside whatever additional metadata our container required for each value:
struct Slot {
// The values our hash set stores
int Key{0};
// Metadata
CellState State{CellState::Empty};
// We'd also need to store distance from initial
// bucket if we were using Robin Hood hashing
// uint8_t DIB{0};
};Our containers like FlatHashSet or RobinHoodSet were then managing an array of these structures:
class FlatHashSet {
// ....
std::vector<Slot> Buffer;
// ...
};Even with a tiny 4-byte int key and a 1-byte CellState enum, the compiler inserts 3 bytes of alignment padding so that every int starts on a clean 4-byte memory boundary. Each Slot consumes 8 bytes of RAM, meaning a 64-byte L1 cache line holds only 8 slots.
Now consider what happens in a production system where our container stores more than just a single 4-byte integer. Imagine a hash set storing 32-byte std::string keys, or a hash map associating an 8-byte key with a 56-byte EnemyData payload struct:
struct FatSlot {
uint64_t Key{0}; // 8 bytes
EnemyData Payload{}; // 56 bytes
CellState State{Empty}; // 1 byte (+ 7 bytes padding)
};Every FatSlot now consumes 72 bytes of memory. A single FatSlot no longer fits inside a 64-byte L1 cache line.
Think about what the CPU actually has to do when a collision occurs and our probing loop walks past 8 occupied slots. For the first 7 slots, the keys do not match our query. We only needed to inspect their metadata state and reject them.
However, because State, Key, and Payload are interleaved inside the same FatSlot struct, the memory controller is forced to haul all 72 bytes of every FatSlot from main RAM into the cache just to read a single metadata byte. Over 98% of our L1 cache capacity is polluted with EnemyData payloads we never even read.
Worse still, as we saw in our lesson on , SIMD registers prefer contiguous lanes of identical types. We cannot efficiently load 16 CellState bytes into an xsimd::batch<int8_t> register when those bytes are separated by 71 bytes of keys and payloads in between.
Splitting the Container
To pack our metadata tightly for both the cache and our SIMD registers, we turn once again to and the structure of arrays (SoA) layout.
Instead of a single std::vector<Slot>, we split our hash table into two separate, parallel arrays of the same Capacity.
The first array is Metadata, a tightly packed buffer of 1-byte control values where Metadata[i] tracks the state of slot i. The second array is Keys, a parallel buffer where Keys[i] stores the key values our container is storing. It's also where we'd store the payload, if we were building a hash map rather than a set.
Because every element in Metadata is a single 1-byte int8_t, an entire 64-slot neighborhood of our hash table fits inside a single 64-byte cache line. Furthermore, because every element in Metadata is a 1-byte integer, the compiler inserts zero alignment padding between elements.
Let's create a new header file, SwissSet.h, and set up this dual-array skeleton alongside the usual Mix64() hash function we'll need later:
dsa_core/include/dsa/SwissSet.h
#pragma once
#include <vector>
#include <cstdint>
#include <cstddef>
class SwissSet {
private:
size_t Capacity{16};
size_t Mask{15};
size_t Size{0};
size_t Tombstones{0};
// SoA Split: 1-byte metadata separated from keys
std::vector<int8_t> Metadata;
std::vector<int> Keys;
uint64_t Mix64(uint64_t x) const {
x ^= x >> 30;
x *= 0xbf58476d1ce4e5b9ULL;
x ^= x >> 27;
x *= 0x94d049bb133111ebULL;
x ^= x >> 31;
return x;
}
public:
SwissSet() : Metadata(Capacity), Keys(Capacity) {}
};The Control Byte
Separating Metadata from Keys gives us a dense 1-byte array, but there is a catch. If Metadata[i] only told us whether slot i was Empty, Occupied, or Deleted, what would happen during a lookup when we hit an Occupied slot?
We would still have to jump over to Keys[i] in the second array to check if Keys[i] == key. If we have to fetch Keys[i] for every occupied slot in a cluster, touching two separate memory arrays on every probe step.
To avoid reading Keys[i] unless we are almost certain our target key is sitting there, we combine the technique and the from our Cuckoo Filter directly inside the Metadata byte.
In Swiss Table terminology, each int8_t in the Metadata array is called a control byte.
Designing the Control Byte
An int8_t has 8 bits. We need a single byte to represent both the lifecycle state of the slot (Empty, Deleted, or Occupied) and a hash fingerprint at the same time.
We accomplish this by dividing the 8 bits using the leftmost bit. This is the most significant bit in standard binary representations and, for signed integers like int8_t, represents the sign of the integer. When that bit is 1, the int8_t value is negative (-128 to -1).
We assign two specific negative values to represent our empty and tombstone states:
kEmptyis-128(binary10000000), meaning the slot has never been used, and any probe chain that hits it can immediately terminatekDeletedis-2(binary11111110), representing a tombstone that probe chains must step over
When the leftmost bit is 0, the int8_t value is non-negative (0 to 127), which indicates that the slot is occupied.
In this state, the lower 7 bits (0xxxxxxx) store a 7-bit hash signature of the key sitting in Keys[i].
This bit-level layout gives the CPU three major mechanical advantages. First, there are zero wasted bits, because we pack both the state enum and a 7-bit hash fingerprint into the same byte.
Second, we get a single-instruction occupancy check. Because kEmpty (-128) and kDeleted (-2) are negative, and all valid 7-bit fingerprints (0 to 127) are non-negative; checking if any slot holds a live element is a simple Metadata[i] >= 0 comparison.
Third, we get direct signature matching. When searching for a key whose 7-bit signature is 42, we do not need to check if Metadata[i] is occupied first. We simply check if (Metadata[i] == 42).
Since 42 cannot equal -128 (kEmpty) or -2 (kDeleted), a single byte equality check tests both occupancy and the 7-bit hash fingerprint simultaneously.
H1/H2 Hash Splitting
To populate our control byte, we split our 64-bit hash from Mix64() into two independent values, typically called H1 and H2.
H1 uses the top 57 bits of the hash, which we can extract by shifting the 64-bit hash right by 7 bits: hash >> 7. The H1 value determines which bucket our probe should start when we're trying to add or read a value from our set.
Like with our previous containers, we can get this preferred bucket by applying our mask: H1(hash) & Mask.
H2 uses the bottom 7 bits of the hash, which we extract by performing hash & 0b01111111 (or hash & 0x7F). This isolates the lowest 7 bits and forces the 8th bit to 0, giving us our int8_t control byte signature in the range 0 to 127.
Because H2 is 7 bits wide, there are distinct signatures. When our probe loop walks past an occupied slot containing an unrelated key, there is a chance that its H2 byte will not match our target's H2 byte.
That means 99.22% of collisions are rejected directly inside the 1-byte Metadata array. The CPU rarely has to fetch from the Keys array until it lands on the exact slot containing our target key.
Let's add our Ctrl constants and the H1() and H2() helper methods to SwissSet, and initialize every slot in Metadata to Ctrl::kEmpty:
dsa_core/include/dsa/SwissSet.h
#pragma once
#include <vector>
#include <cstdint>
#include <cstddef>
class SwissSet {
// Special negative control byte states (Bit 7 is 1)
struct Ctrl {
static constexpr int8_t kEmpty = -128; // 0b10000000
static constexpr int8_t kDeleted = -2; // 0b11111110
};
private:
size_t Capacity{16};
size_t Mask{15};
size_t Size{0};
size_t Tombstones{0};
std::vector<int8_t> Metadata;
std::vector<int> Keys;
uint64_t Mix64(uint64_t x) const {/*...*/}
// Top 57 bits: determines the starting bucket index
static size_t H1(uint64_t hash) {
return static_cast<size_t>(hash >> 7);
}
// Bottom 7 bits: stored in Metadata (always 0..127)
static int8_t H2(uint64_t hash) {
return static_cast<int8_t>(hash & 0x7F);
}
public:
// Initialize all control bytes to kEmpty (-128)
SwissSet()
: Metadata(Capacity, Ctrl::kEmpty),
Keys(Capacity) {}
};Implementing Lookups and Insertions
Before we vectorize the probe loop in the next lesson, let's build the core lookup and insertion operations over our new SoA layout.
Implementing this scalar metadata probe first establishes the exact state transitions between Ctrl::kEmpty, Ctrl::kDeleted, and H2 signatures that our SIMD batch probing will operate on next.
It also allows us to benchmark and isolate how much performance comes purely from the SoA metadata split and 7-bit H2 filtering before SIMD instructions even enter the picture.
Implementing Contains()
When Contains(int key) is called, we hash the key once with Mix64(key), extract the starting bucket index using H1(hash) & Mask, and extract the 7-bit fingerprint using H2(hash).
We then walk the Metadata array starting at Index. At each slot, we load the 1-byte control value at Metadata[Index].
If ctrl == h2, the slot is occupied, and its 7-bit signature matches our query's H2. Only in this case do we reach into the Keys array to verify Keys[Index] == key. If the full keys match, we return true. If it was a 1-in-128 false positive, we continue probing.
If ctrl == kEmpty, we have reached an empty slot (-128). The probe chain is broken, so we immediately return false without ever touching the Keys array.
Otherwise, the slot is either a kDeleted tombstone or holds a different H2 signature, so we advance Index = (Index + 1) & Mask to inspect the next adjacent byte in Metadata:
dsa_core/include/dsa/SwissSet.h
// ...
class SwissSet {
// ...
public:
SwissSet()
: Metadata(Capacity, Ctrl::kEmpty),
Keys(Capacity) {}
bool Contains(int key) const {
uint64_t hash{Mix64(key)};
size_t Index{H1(hash) & Mask};
int8_t h2{H2(hash)};
while (true) {
int8_t ctrl{Metadata[Index]};
// Fast Path: 7-bit signature matches
// Only touch the Keys array on a signature hit
if (ctrl == h2) {
if (Keys[Index] == key) return true;
}
// Chain broken: key is not in the set
if (ctrl == Ctrl::kEmpty) {
return false;
}
Index = (Index + 1) & Mask;
}
}
};Implementing Insert() and Tombstone Recycling
Our Insert() logic follows the same tombstone-recycling strategy we built in the lesson, subtly adapted to operate on our split Metadata and Keys arrays.
As we walk the probe chain checking whether the key already exists in the set, we track the index of the first kDeleted tombstone we encounter using FirstDeleted. Just like in Contains(), we guard the duplicate key check behind if (ctrl == h2) so we never waste memory bandwidth reading Keys[Index] on unrelated slots.
Once the probe loop reaches a kEmpty slot, we know the key is not a duplicate. If FirstDeleted recorded a tombstone earlier in the chain, we recycle that slot and decrement Tombstones; otherwise, we claim the kEmpty slot at Index. We then write the 1-byte h2 signature into Metadata[Target] and the full key into Keys[Target]:
dsa_core/include/dsa/SwissSet.h
// ...
#include <limits>
class SwissSet {
// ...
private:
// ...
static constexpr size_t InvalidIndex{
std::numeric_limits<size_t>::max()
};
public:
// ...
void Insert(int key) {
if ((Size + Tombstones) >= (Capacity * 0.7f)) {
Grow(); // We will implement Grow() shortly
}
uint64_t hash{Mix64(key)};
size_t Index{H1(hash) & Mask};
int8_t h2{H2(hash)};
size_t FirstDeleted{InvalidIndex};
while (true) {
int8_t ctrl{Metadata[Index]};
// Check for duplicates only when H2 matches
if (ctrl == h2 && Keys[Index] == key) {
return;
}
// Record the first tombstone we walk over
if (ctrl == Ctrl::kDeleted) {
if (FirstDeleted == InvalidIndex) {
FirstDeleted = Index;
}
}
if (ctrl == Ctrl::kEmpty) {
size_t Target = (FirstDeleted != InvalidIndex)
? FirstDeleted
: Index;
Metadata[Target] = h2;
Keys[Target] = key;
++Size;
if (Target == FirstDeleted) --Tombstones;
return;
}
Index = (Index + 1) & Mask;
}
}
};Implementing Deletion
Because we dropped Robin Hood hashing's DIB tracking and backward-shift deletion, we cannot reset a deleted slot directly to Ctrl::kEmpty. Doing so would sever the probe chain for any elements that collided and shifted past that slot during earlier insertions.
Instead, Erase() uses our kDeleted (-2) tombstone state. We hash the target key, compute Index from H1(hash) and h2 from H2(hash), and walk the Metadata array.
If we encounter kEmpty, the key was never in the container, and we return immediately. If we find a slot where ctrl == h2 and Keys[Index] == key, we overwrite Metadata[Index] with kDeleted, decrement Size, and increment Tombstones.
Notice that for trivial key types like int, Erase() does not even need to modify Keys[Index] when deleting an element. Overwriting the 1-byte control value in Metadata[Index] with -2 immediately hides the stale key from future Contains() queries and marks the slot as available for reuse during future Insert() calls:
dsa_core/include/dsa/SwissSet.h
// ...
class SwissSet {
// ...
public:
// ...
void Erase(int key) {
uint64_t hash{Mix64(key)};
size_t Index{H1(hash) & Mask};
int8_t h2{H2(hash)};
while (true) {
int8_t ctrl{Metadata[Index]};
if (ctrl == Ctrl::kEmpty) return;
if (ctrl == h2 && Keys[Index] == key) {
Metadata[Index] = Ctrl::kDeleted;
--Size;
++Tombstones;
return;
}
Index = (Index + 1) & Mask;
}
}
};Implementing Reallocation and Growth
As elements are inserted and erased, Size + Tombstones gradually approaches our load factor threshold. Even though Insert() recycles tombstones whenever it walks past them, tombstones still lengthen probe chains for negative queries because Contains() cannot stop until it hits a true kEmpty byte.
When Size + Tombstones reaches 70% of Capacity, we call Grow() to double the capacity of both parallel arrays, wipe away all accumulated tombstones, and rehash the live elements into fresh buckets.
Here, our signed int8_t control byte encoding simplifies the reallocation pass. How does Grow() know whether slot i in OldMetadata holds a live element versus kEmpty or kDeleted?
Because both special states have Bit 7 set to 1 (making them negative) and every valid 7-bit H2 signature has Bit 7 set to 0 (making it 0 to 127), Grow() simply checks if (OldMetadata[i] >= 0). Every live key is re-inserted into the newly resized arrays, while empty slots and dead tombstones are ignored:
dsa_core/include/dsa/SwissSet.h
// ...
class SwissSet {
private:
// ...
void Grow() {
size_t OldCapacity{Capacity};
std::vector<int8_t> OldMetadata{std::move(Metadata)};
std::vector<int> OldKeys{std::move(Keys)};
Capacity *= 2;
Mask = Capacity - 1;
Metadata.assign(Capacity, Ctrl::kEmpty);
Keys.assign(Capacity, 0);
Size = 0;
Tombstones = 0;
for (size_t i{0}; i < OldCapacity; ++i) {
// Any non-negative control byte (0..127) is occupied
if (OldMetadata[i] >= 0) {
Insert(OldKeys[i]);
}
}
}
public:
// ...
};Let's test our completed SwissSet in dsa_app/main.cpp to verify that insertions, lookups, and tombstone deletions work together properly:
dsa_app/main.cpp
#include <iostream>
#include <dsa/SwissSet.h>
int main() {
SwissSet Set;
Set.Insert(42);
Set.Insert(99);
Set.Insert(150);
std::cout << "Contains 42? "
<< (Set.Contains(42) ? "Yes" : "No") << "\n";
Set.Erase(42);
std::cout << "Contains 42 after erase? "
<< (Set.Contains(42) ? "Yes" : "No") << "\n";
std::cout << "Contains 99 after erase? "
<< (Set.Contains(99) ? "Yes" : "No");
}Contains 42? Yes
Contains 42 after erase? No
Contains 99 after erase? YesBenchmarking the SoA Split
We have not written a single line of SIMD code inside SwissSet yet. Our while loops are still stepping through Metadata one byte at a time using scalar instructions.
Even so, let's see what happens when we benchmark our SoA SwissSet against std::unordered_set, FlatHashSet, and RobinHoodSet.
We'll use the same test suite from the previous chapter, inserting 3,000,000 random integers and running 1,000,000 queries across 1%, 50%, and 99% miss rates:
benchmarks/main.cpp
#include <benchmark/benchmark.h>
#include <unordered_set>
#include <vector>
#include <random>
#include <algorithm>
#include <dsa/FlatHashSet.h>
#include <dsa/RobinHoodSet.h>
#include <dsa/SwissSet.h>
const int kNumElements = 3'000'000;
const int kNumQueries = 1'000'000;
std::vector<int> GenerateData() {
std::vector<int> data(kNumElements);
std::mt19937 rng(42);
std::uniform_int_distribution<int> dist(1, 50'000'000);
for (int i = 0; i < kNumElements; ++i) data[i] = dist(rng);
return data;
}
const std::vector<int> Data = GenerateData();
std::vector<int> GenerateQueries(double miss_rate) {
std::vector<int> queries;
queries.reserve(kNumQueries);
int misses = kNumQueries * miss_rate;
int hits = kNumQueries - misses;
for (int i = 0; i < hits; ++i) {
queries.push_back(Data[i]);
}
for (int i = 0; i < misses; ++i) {
queries.push_back(i + 100'000'000);
}
std::mt19937 rng(42);
std::shuffle(queries.begin(), queries.end(), rng);
return queries;
}
static void BM_StdSet(benchmark::State& state) {
double miss_rate = state.range(0) / 100.0;
std::vector<int> queries = GenerateQueries(miss_rate);
std::unordered_set<int> set(Data.begin(), Data.end());
for (auto _ : state) {
for (int query : queries) {
benchmark::DoNotOptimize(set.contains(query));
}
}
}
static void BM_FlatHashSet(benchmark::State& state) {
double miss_rate = state.range(0) / 100.0;
std::vector<int> queries = GenerateQueries(miss_rate);
FlatHashSet set;
for (int val : Data) set.Insert(val);
for (auto _ : state) {
for (int query : queries) {
benchmark::DoNotOptimize(set.Contains(query));
}
}
}
static void BM_RobinHood(benchmark::State& state) {
double miss_rate = state.range(0) / 100.0;
std::vector<int> queries = GenerateQueries(miss_rate);
RobinHoodSet set;
for (int val : Data) set.Insert(val);
for (auto _ : state) {
for (int query : queries) {
benchmark::DoNotOptimize(set.Contains(query));
}
}
}
static void BM_SwissSet(benchmark::State& state) {
double miss_rate = state.range(0) / 100.0;
std::vector<int> queries = GenerateQueries(miss_rate);
SwissSet set;
for (int val : Data) set.Insert(val);
for (auto _ : state) {
for (int query : queries) {
benchmark::DoNotOptimize(set.Contains(query));
}
}
}
#define REGISTER_BENCHMARK(func) BENCHMARK(func) \
->Unit(benchmark::kMillisecond) \
->ArgName("MissRatePct") \
->Arg(1) \
->Arg(50) \
->Arg(99)
REGISTER_BENCHMARK(BM_StdSet);
REGISTER_BENCHMARK(BM_FlatHashSet);
REGISTER_BENCHMARK(BM_RobinHood);
REGISTER_BENCHMARK(BM_SwissSet);---------------------------------------
Benchmark CPU
---------------------------------------
BM_StdSet/MissRatePct:1 72.9 ms
BM_StdSet/MissRatePct:50 78.8 ms
BM_StdSet/MissRatePct:99 62.2 ms
BM_FlatHashSet/MissRatePct:1 26.5 ms
BM_FlatHashSet/MissRatePct:50 50.8 ms
BM_FlatHashSet/MissRatePct:99 78.0 ms
BM_RobinHood/MissRatePct:1 35.9 ms
BM_RobinHood/MissRatePct:50 50.0 ms
BM_RobinHood/MissRatePct:99 47.4 ms
BM_SwissSet/MissRatePct:1 26.2 ms
BM_SwissSet/MissRatePct:50 51.4 ms
BM_SwissSet/MissRatePct:99 26.3 msEven with a scalar probing loop, SwissSet outperforms both FlatHashSet and RobinHoodSet. This is particularly true in use cases involving high miss rates, where the SwissSet is cutting query time in half.
There are three reasons why SwissSet is more efficient, particularly as the miss rate increases.
Reason 1: Working Set Compression
To hold 3,000,000 elements under a 70% load factor, our Capacity scales to over 8 million slots using the power-of-two allocation strategy: .
In FlatHashSet and RobinHoodSet, the Slot array then occupies around 64MB of RAM, which exceeds the L3 cache capacity of many processors.
In SwissSet, the Metadata array occupies only 8 MB of RAM. Because the entire metadata table fits inside the L3 cache, probing Metadata avoids main memory stalls on a massive fraction of queries.
Reason 2: Overall Memory Efficiency
In FlatHashSet, bundling a 4-byte int key with a 1-byte CellState enum forces the compiler to add 3 bytes of alignment padding per slot, consuming 8 bytes per slot.
By separating Metadata (1 byte per slot) and Keys (4 bytes per slot) into homogeneous arrays, all alignment padding disappears. Our total memory footprint drops from 8 bytes per slot down to 5 bytes per slot, representing a 37.5% reduction in cache pressure and total RAM usage.
Reason 3: Filtering Collisions Before Touching Keys
When a query misses, SwissSet walks a few 1-byte entries in Metadata until it encounters kEmpty. Because 99.22% of colliding occupied slots have a different H2 byte, the CPU rarely fetches from the 16 MB Keys array during a missed query.
By storing H2 inside the control byte, we have effectively embedded a directly into the front door of our hash table.
Preparing for SIMD Group Probing
Let's look one more time at the inner while loop of SwissSet::Contains():
while (true) {
int8_t ctrl{Metadata[Index]};
if (ctrl == h2) {
if (Keys[Index] == key) return true;
}
if (ctrl == Ctrl::kEmpty) {
return false;
}
Index = (Index + 1) & Mask;
}Our Metadata buffer is a contiguous array of int8_t bytes, and our loop is simply scanning that int8_t array for two target values: h2 and kEmpty.
That is the exact problem we solved with xsimd::batch, mask(), and std::countr_zero() in .
In the next lesson, we will replace this byte-by-byte walk with vectorized group probing. Instead of inspecting Metadata[Index] one slot at a time, we will load a 16-byte or 32-byte xsimd::batch directly from Metadata and compare the entire group against h2 and kEmpty in parallel.
Complete Code
Here is the complete code for the initial SwissSet we constructed in this lesson:
Files
Summary
In this lesson, we redesigned our open-addressed hash table from the ground up to prepare it for hardware vectorization:
- Ditching Robin Hood: When probing metadata is cheap, tracking
DIBvalues and swapping elements duringInsert()costs more CPU cycles and cache write traffic than it saves. - Structure of Arrays (SoA) Split: Interleaving
State,Key, andPayloadinside an array of structures wastes alignment padding, pollutes cache lines with uninspected payloads, and prevents contiguous SIMD loads. Separating 1-byteMetadatafromKeyspacks 64 slot states into a single 64-byte cache line. - Control Bytes: We split our 64-bit hash into a 57-bit
H1()bucket index and a 7-bitH2()signature. Storing negative states (kEmpty = -128,kDeleted = -2) alongside non-negativeH2signatures inside each control byte filters out 99.22% of hash collisions without touching theKeysarray.
With our Metadata packed into a contiguous std::vector<int8_t>, our container is now primed for xsimd.
In the next lesson, we will vectorize SwissSet to probe 16, 32, or 64 buckets per clock cycle, handle group wrap-around at array boundaries, and push our maximum load factor all the way to 87.5%.
Swiss Table SIMD Probing
Combine structure of arrays metadata with xsimd batch operations to search 16, 32, or 64 hash buckets in a single clock cycle.