Cuckoo Hashing and Filters
Implement strict guarantees by bounding a lookup to exactly two memory locations, and build a fast rejection path for massive databases.
In our previous lessons, our hash containers searched for objects using a loop. The loop continued to probe the container's memory until it either found the key or it found a reason to conclude the key doesn't exist. If we maintain a low load factor, the average length of these probing chains is quite short.
However, some systems care more about the worst case performance than the average case. In systems with strict real-time deadlines, such as network routers or high-frequency trading engines, an unbounded while loop can be unacceptable. An algorithm that may need to check dozens of memory locations to find a specific key can be problematic.
We saw how to reduce the variance of these lookup times using , but low variance is not a strict guarantee.
When we need to guarantee an upper bound to our lookup times, Cuckoo Hashing is often the tool of choice. It trades off some insertion speed and memory efficiency for a guarantee that every key in the container can be found within a strictly limited number of memory locations - usually two.
Cuckoo Collisions
Cuckoo hashing gets its name from the cuckoo bird, which lays its eggs in the nests of other birds. When the cuckoo egg hatches, the chick pushes the original egg out of the nest to secure the slot for itself. A Cuckoo Hash Table uses this eviction mechanism to resolve collisions.
Instead of hashing a key once and walking sequentially down an array (linear probing), a Cuckoo Hash Table hashes a key multiple times. Most implementations use two hashes per key, which generates exactly two distinct index locations where the key is allowed to live.
Then, when we want to check if a key exists, we no longer need a while loop. We simply check Index1, and we check Index2. If the key is in neither of those two buckets, it is not in the table. We have bounded our lookup to a maximum of exactly two memory fetches.
The Cuckoo Kick
Because a key can only live in one of two specific locations, insertions become much more aggressive.
When we try to insert a new key, we check its Index1 and Index2. If one is empty, we drop the key in. But if both buckets are currently occupied by other keys, we do not start walking down the array looking for a gap.
Instead, we become the cuckoo bird. We forcibly evict one of the sitting keys, stealing its bucket for our new insertion.
The evicted key is now homeless. But remember, every key has two possible locations. The evicted key looks at its alternate bucket. If that bucket is empty, it settles in. If that bucket is also full, it evicts that sitting key, and the cycle continues.
This cascade of evictions keeps the table balanced without ever degrading the bounded lookup guarantee. However, the trade-off is that insertions are now much slower as they resolve a long chain of evictions.
It also introduces a new danger: infinite loops. If the table gets too full, or if we get unlucky with our hash functions, the evictions can form a closed cycle, bouncing keys back and forth endlessly.
Managing these cycles makes full Cuckoo-based containers relatively complex to implement. They're also not particularly common - most real world use cases prefer the more balanced performance profile of from earlier in the chapter, or Swiss Tables which we cover in the next chapter.
Cuckoo hashing is more commonly used in a slightly different context. Rather than using it to construct containers, it is regularly used to create filters.
The Fast Rejection Path
Optimizing our algorithms for efficient CPU and memory cache only goes so far. In the real world, we often need to interact with a database or similar storage sitting on a slow disk drive, or even worse, a network location.
Let's imagine we're creating a system that caches the contents of internet resources like web pages, images, and videos, and stores them in a database. When a user requests a URL, before making an expensive trip to the internet, the server first asks if our system already has a recent copy of that resource which it can use instead.
Our system will almost always respond with a "no", because there are countless internet resources and we can only possibly cache a tiny fraction of them at any given time.
This means we need to optimize for the rejection path. We need an efficient data structure that sits in front of the database and can confidently tell us: "I do not recognize this URL - do not spend the million cycles it would take to query the database - it's not there."
We briefly introduced as a way to handle scenarios like this. Today, the industry standard is the cuckoo filter, which we'll build in this lesson.
Fingerprints
To make our filter small, we cannot store full strings like "https://example.com/api/v1/users". Even if we interned them, storing a 4-byte handle per element across a billion resources would span gigabytes of RAM, reducing our cache efficiency.
Instead, we store a fingerprint. A fingerprint is just a tiny slice of a hash. When a URL comes in, we hash it to a massive 64-bit integer, but we only keep a tiny piece of it - usually just 1 byte:
uint64_t fullHash = StringHasher("https://example.com/api");
// Extract just the bottom 8 bits to create a tiny fingerprint
uint8_t fingerprint = fullHash & 0xFF;By storing only an 8-bit fingerprint, our memory usage plummets. A single 64-byte cache line can hold 64 fingerprints.
Because a fingerprint is so small, there is a chance that two completely different URLs will generate the same 8-bit fingerprint. This creates a false positive. The filter might tell us it has seen a URL before, even though it hasn't.
For a fast rejection path, false positives are perfectly acceptable. If the filter is wrong, we simply fall through to the slow database, at which point we'll discover the data doesn't exist. A filter's job is not to be perfect; its job is to accurately reject invalid requests instantly.
This is why filters are most commonly used in high-miss-rate contexts - that is, where the overwhelming majority of Contains() requests will return false. A cuckoo filter should never return a false negative, so the higher the miss rate, the more reliable and useful the filter is.
Constructing the Skeleton
Let's begin building the skeleton of our CuckooFilter. We'll start with the basic class definition, our constructor, and our trusty Mix64 algorithm which we will use for hashing integers later on.
Notice that just like our previous hash tables, we force our capacity to be a power of two so we can use a bitwise Mask for rapid modulo arithmetic.
dsa_core/include/dsa/CuckooFilter.h
#pragma once
#include <vector>
#include <string>
#include <string_view>
#include <cstdint>
#include <functional>
class CuckooFilter {
private:
size_t Capacity;
size_t Mask;
std::hash<std::string_view> StringHasher;
public:
CuckooFilter(size_t numBuckets = 1024) {
Capacity = 1;
// Requires capacity to be a power of 2
while (Capacity < numBuckets) Capacity *= 2;
Mask = Capacity - 1;
}
private:
uint64_t Mix64(uint64_t x) const {/*...*/}
};Next, we need a helper method to extract the fingerprint and the primary index (Index1) from an incoming key.
We grab the bottom 8 bits of the string's hash to form the fingerprint. We then shift the hash over to use the next chunk of bits for our Index1. We reserve 0 to mean "Empty Slot", so if our fingerprint happens to calculate to 0, we just bump it to 1.
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
// ...
private:
uint64_t Mix64(uint64_t x) const { /*...*/ }
// Helper to extract the 8-bit fingerprint and Index1
void GetHashes(
std::string_view key,
uint8_t& outFingerprint,
size_t& outIndex1
) const {
uint64_t hash = StringHasher(key);
outFingerprint = hash & 0xFF;
// We reserve 0 to mean "Empty Slot"
if (outFingerprint == 0) outFingerprint = 1;
// Shift by 8 to use different bits for the index
outIndex1 = (hash >> 8) & Mask;
}
};Partial-Key Cuckoo Hashing (The XOR Hack)
There is a challenge in combining fingerprints with cuckoo hashing. When a key is inserted, it might eventually get evicted by another incoming key. When it gets evicted, it needs to fly to its alternate bucket.
In a standard container, we store the full string key, so rehashing it isn't a problem. But in a filter, we threw the original value away. We only have the 1-byte fingerprint sitting in the bucket. How can we possibly calculate a key's alternate bucket if we don't know what the original key was?
We solve this using Partial-Key Cuckoo Hashing, affectionately known as the XOR Hack.
To calculate Index2, we take Index1 and run a bitwise XOR (^) against a mix of the fingerprint. Because the bitwise XOR operator is perfectly symmetrical and reversible, we can perform this same math in reverse. If we are sitting in Index2 and we get evicted, we can find our way back to Index1 using the same formula:
size_t Index1 = (Index2 ^ Mix64(Fingerprint)) & Mask;This mathematical sleight-of-hand allows a fingerprint to endlessly bounce back and forth between its two designated buckets without us ever needing to know what the original value was.
Let's add a helper function for this XOR hack to our class:
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
// ...
private:
// ...
void GetHashes( /*...*/ ) const { /*...*/ }
// The XOR Hack
size_t GetAlternateIndex(
size_t index,
uint8_t fingerprint
) const {
return (index ^ Mix64(fingerprint)) & Mask;
}
};Upgrading the Buckets
If each bucket in our array could only hold a single fingerprint, our filter would constantly experience cycles. Two different strings might map to the same two buckets, instantly locking the table into an infinite eviction loop.
To drastically increase the resilience of the filter, we can upgrade our buckets to hold multiple slots.
Because our fingerprints are only 1 byte, we can pack 4 fingerprints into a single 4-byte bucket. This means each Index actually points to a small neighborhood of 4 slots. If a string maps to Index1 and Index2, it actually has 8 possible slots where its fingerprint can reside.
Let's add this Bucket struct and our underlying storage Table to the filter, and initialize it in the constructor.
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
private:
// A 4-byte bucket containing 4 slots
struct Bucket {
uint8_t Slots[4]{0, 0, 0, 0};
};
size_t Capacity;
size_t Mask;
std::vector<Bucket> Table;
std::hash<std::string_view> StringHasher;
public:
CuckooFilter(size_t numBuckets = 1024) {
Capacity = 1;
while (Capacity < numBuckets) Capacity *= 2;
Mask = Capacity - 1;
Table.resize(Capacity);
}
// ...
};Implementing Lookups
With our buckets in place, we can implement our Contains() query.
When a query arrives, we extract the fingerprint and calculate Index1. We then check the 4 slots inside Index1. If we find the fingerprint, we return true. If it's not there, we use the XOR hack to calculate Index2, and check the 4 slots there.
There are no while loops, no linear probing, and no pointer chasing. It is a strictly bounded operation:
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
// ...
public:
// ...
bool Contains(std::string_view key) const {
uint8_t fingerprint;
size_t index1;
GetHashes(key, fingerprint, index1);
// Check the 4 slots in Bucket 1
const Bucket& b1 = Table[index1];
for (int i = 0; i < 4; ++i) {
if (b1.Slots[i] == fingerprint) return true;
}
// Use the XOR hack to find the alternate bucket
size_t index2 = GetAlternateIndex(index1, fingerprint);
// Check the 4 slots in Bucket 2
const Bucket& b2 = Table[index2];
for (int i = 0; i < 4; ++i) {
if (b2.Slots[i] == fingerprint) return true;
}
// Maximum 2 cache misses. We are done.
return false;
}
};Implementing Insertion
Insertion is where the cuckoo evictions happen, and it requires a bit more logic. We will build this in three pieces.
First, let's write a small private helper method. This method takes a bucket index, checks its 4 slots, and if it finds an empty slot (a 0), it drops the fingerprint in and returns true.
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
private:
// ...
// Helper to attempt dropping a fingerprint into a bucket
bool TryInsertInBucket(size_t index, uint8_t fingerprint) {
Bucket& b = Table[index];
for (int i = 0; i < 4; ++i) {
if (b.Slots[i] == 0) { // 0 means empty
b.Slots[i] = fingerprint;
return true;
}
}
return false;
}
public:
// ...
};Next, we can begin our Insert() method. We start by calculating our hashes and attempting the peaceful path. We simply ask our new helper method to try placing the fingerprint in Index1, and if that fails, we try Index2.
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
// ...
public:
// ...
bool Insert(std::string_view key) {
uint8_t fingerprint;
size_t index1;
GetHashes(key, fingerprint, index1);
// Attempt the fast, peaceful path
if (TryInsertInBucket(index1, fingerprint)) return true;
size_t index2 = GetAlternateIndex(index1, fingerprint);
if (TryInsertInBucket(index2, fingerprint)) return true;
// TODO: Both buckets are full. Prepare for eviction.
return false;
}
};The Cuckoo Eviction Loop
If both buckets are full (all 8 slots are occupied), we must initiate a cuckoo kick.
We randomly select either Index1 or Index2. We randomly select one of the 4 slots inside that bucket. We overwrite that slot with our incoming fingerprint, and we take the evicted fingerprint into our temporary registers.
We then calculate the alternate bucket for the evicted fingerprint using the XOR hack, and repeat the process.
To prevent an infinite loop where fingerprints endlessly bounce back and forth, we institute a hard limit: MaxKicks. If we kick fingerprints 500 times in a row, the table is either too full or caught in a cycle, and the insertion fails.
Let's add the MaxKicks limit and replace our TODO with the eviction logic:
dsa_core/include/dsa/CuckooFilter.h
// ...
#include <random>
class CuckooFilter {
private:
// ...
const int MaxKicks{500};
public:
// ...
bool Insert(std::string_view key) {
uint8_t fingerprint;
size_t index1;
GetHashes(key, fingerprint, index1);
if (TryInsertInBucket(index1, fingerprint)) return true;
size_t index2 = GetAlternateIndex(index1, fingerprint);
if (TryInsertInBucket(index2, fingerprint)) return true;
// Both buckets are full.
// Randomly select one of the two buckets to attack
size_t activeIndex = (rand() % 2 == 0) ? index1 : index2;
for (int kick = 0; kick < MaxKicks; ++kick) {
Bucket& b = Table[activeIndex];
// Randomly select a victim slot (0 to 3)
int victimSlot = rand() % 4;
// Swap our fingerprint with the victim
uint8_t evicted = b.Slots[victimSlot];
b.Slots[victimSlot] = fingerprint;
// We are now holding the homeless victim.
// Calculate its alternate destination via XOR
fingerprint = evicted;
activeIndex = GetAlternateIndex(activeIndex, fingerprint);
// Try to peacefully settle the victim in its new home
if (TryInsertInBucket(activeIndex, fingerprint)) {
return true;
}
}
// We hit the kick limit. The filter is too full
return false;
}
};Why not Grow the Filter?
In a conventional container, a failure to insert due to the container being almost full would prompt us to grow it. We'd allocate a larger array, and rehash everything to that new bucket count. However, this isn't an option for filters. We can't rehash the values, because we don't remember them - they were discarded after we generated their fingerprint.
More complex architectures are possible where we orchestrate multiple filters, and we can "grow" by adding additional filters to that cluster. But, in most scenarios, we generally know how big our filter needs to be, and can set its capacity accordingly.
Fast Randomness
In our insertion method, we used the C-style rand() function to select our victims. In a true production environment, rand() is often too slow and relies on hidden global state.
High-performance systems typically use a lightweight, bitwise pseudo-random number generator like Xorshift embedded directly inside the class to maintain cache locality and avoid locking overhead.
Safe Deletions
One of the flaws of a traditional Bloom filter is that we cannot delete data from it. Because multiple keys might flip the same bit, clearing a bit might accidentally erase a completely unrelated key.
Our cuckoo filter fixes this. Because our buckets hold discrete 8-bit fingerprints, we can safely delete an item by simply finding its fingerprint and wiping it back to 0.
However, we must be careful. If two different strings hash to the same 8-bit fingerprint, and they both map to the same bucket, removing one string should only delete one instance of the fingerprint. So, once we find and erase a single matching fingerprint, we immediately return true.
dsa_core/include/dsa/CuckooFilter.h
// ...
class CuckooFilter {
// ...
public:
// ...
bool Erase(std::string_view key) {
uint8_t fingerprint;
size_t index1;
GetHashes(key, fingerprint, index1);
// Check Bucket 1
Bucket& b1 = Table[index1];
for (int i = 0; i < 4; ++i) {
if (b1.Slots[i] == fingerprint) {
b1.Slots[i] = 0; // Erase it by setting to 0
return true;
}
}
size_t index2 = GetAlternateIndex(index1, fingerprint);
// Check Bucket 2
Bucket& b2 = Table[index2];
for (int i = 0; i < 4; ++i) {
if (b2.Slots[i] == fingerprint) {
b2.Slots[i] = 0; // Erase it by setting to 0
return true;
}
}
// The key was never in the filter
return false;
}
};Benchmarking
Let's put our cuckoo filter to the test. Filters are generally used in scenarios with high miss rates. We'll use a 99% miss rate here.
We will pre-allocate 500,000 strings into a std::unordered_set and our CuckooFilter, and then blast both with 1,000,000 queries where almost every single query is for a key that does not exist:
benchmarks/main.cpp
#include <benchmark/benchmark.h>
#include <unordered_set>
#include <vector>
#include <string>
#include <random>
#include <algorithm>
#include <dsa/CuckooFilter.h>
const int kNumElements = 500'000;
const int kNumQueries = 1'000'000;
std::string GenerateString(std::mt19937& rng) {
const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
std::uniform_int_distribution<int> dist(0, sizeof(charset) - 2);
std::string s;
s.reserve(16);
for (int i = 0; i < 16; ++i) s += charset[dist(rng)];
return s;
}
std::vector<std::string> GenerateData() {
std::vector<std::string> data(kNumElements);
std::mt19937 rng(42);
for (int i = 0; i < kNumElements; ++i) data[i] = GenerateString(rng);
return data;
}
const std::vector<std::string> Data = GenerateData();
std::vector<std::string> GenerateQueries() {
std::vector<std::string> queries;
queries.reserve(kNumQueries);
// 1% Hits, 99% Misses
int hits = kNumQueries * 0.01;
int misses = kNumQueries - hits;
for (int i = 0; i < hits; ++i) queries.push_back(Data[i]);
std::mt19937 rng(1337);
for (int i = 0; i < misses; ++i) {
queries.push_back(GenerateString(rng));
}
return queries;
}
static void BM_StdUnorderedSet_Rejection(benchmark::State& state) {
std::vector<std::string> queries = GenerateQueries();
std::unordered_set<std::string> set(Data.begin(), Data.end());
for (auto _ : state) {
for (const auto& query : queries) {
benchmark::DoNotOptimize(set.contains(query));
}
}
}
static void BM_CuckooFilter_Rejection(benchmark::State& state) {
std::vector<std::string> queries = GenerateQueries();
// CuckooFilter requires power of 2 capacity
// 131,072 buckets * 4 slots = 524,288 total capacity
CuckooFilter filter(131072);
for (const auto& val : Data) filter.Insert(val);
for (auto _ : state) {
for (const auto& query : queries) {
benchmark::DoNotOptimize(filter.Contains(query));
}
}
}
BENCHMARK(BM_StdUnorderedSet_Rejection)
->Unit(benchmark::kMillisecond);
BENCHMARK(BM_CuckooFilter_Rejection)
->Unit(benchmark::kMillisecond);--------------------------------------
Benchmark Time
--------------------------------------
BM_StdUnorderedSet_Rejection 210 ms
BM_CuckooFilter_Rejection 41.7 msWhen the std::unordered_set receives a missed query, it calculates a 64-bit hash, bounds it to an array index, and fetches the memory address. That address is an empty linked list. It takes a full trip to main memory just to realize nothing is there, stalling the pipeline.
The CuckooFilter calculates the hash, extracts the 8-bit fingerprint, and checks the 4 bytes at Index1. The 4-byte bucket is likely already sitting hot in the cache because the entire filter fits in less than 600 Kilobytes of RAM. If the fingerprint isn't there, it executes the single-cycle XOR hack and checks Index2. It confirms a true negative in nanoseconds.
But remember, the filter occasionally returns false positives - that is, Contains() returns true even though the object isn't there. This occurs when two different objects have the same fingerprint and have been allocated to the same bucket. We can trade some memory efficiency for a much lower false positive rate by increasing the signature size from 8 to 16 bits.
Complete Code
Here is the complete implementation of our CuckooFilter:
Files
Summary
In this lesson, we stepped away from software heuristics to build a data structure bound by strict physical limits. We learned that:
- Cuckoo Hashing: By relying on two independent hashes, we can bound our worst-case search time to a maximum of exactly two bucket checks, discarding the unpredictable variance of linear probing
whileloops. - The Fast Rejection Path: Instead of checking slow systems for every query, we can place a tiny filter at the boundary to instantly reject invalid requests.
- Fingerprints: Storing a 1-byte fingerprint instead of an entire string shrinks our memory footprint drastically, allowing millions of records to fit entirely within the CPU's cache.
- The XOR Hack: By using the symmetric nature of bitwise XOR, we can endlessly bounce orphaned fingerprints between their two designated buckets without ever needing to know what the original key was.
We have pushed sequential, scalar code as far as it can go. In the next chapter, we will begin exploring vectorization (SIMD) and the construction of the industry-standard Swiss Table, letting the hardware check dozens of buckets simultaneously in a single clock cycle.