Probabilistic data structures in Go

Probabilistic data structures give you approximate answers to questions about the data they handle. Does the set contain a certain item? How many unique items are in the collection? Which items appear most often? Unlike deterministic structures like sets and frequency tables, the answers might not be exact, but in return, you can drastically reduce memory usage.

In this post, we'll talk about what probabilistic structures are, how they work, and what kinds of guarantees they provide. I'll also introduce unsure — a package that implements these structures in Go. It's simple (the core logic for each data structure is 100–300 LOC), fast (usually several times faster than other implementations), and has no dependencies except for the standard library.

Bloom filter • Count-min sketch • HyperLogLog • T-digest • Top-k

# Bloom filter

A Bloom filter is a set that trades certainty for space. It can tell you that an item is definitely not in the set, or that it probably is (which might be a false positive). The filter doesn't actually store the items, so it's much more compact than a regular set.

For example, a regular set with 1,000,000 distinct 8-byte strings takes about 64 MB. A Bloom filter for the same strings, with a 1% false positive rate, uses only 1.24 MB (about 10 bits per item) — that's 50 times smaller. The size of the filter doesn't depend on the item size, so the difference gets even bigger with longer strings.

A Bloom filter often resides in memory and helps minimize expensive I/O operations such as disk reads or network calls. You check if an item is in the filter, and if the answer is "no", you skip the costly lookup. If the answer is "yes", you still have to perform the lookup because it might be a false positive.

// A filter for 1000 items with a 1% false positive rate.
filter := bloom.New(1000, 0.01)

filter.Add([]byte("alice"))
filter.Add([]byte("bob"))

filter.Contains([]byte("alice")) // true
filter.Contains([]byte("carol")) // false (or, rarely, true)

The filter is sized for the given number of items with a given false positive rate. You can add more, but the false positive rate will go up.

You can add an item to a Bloom filter and check if an item is in the filter, but you cannot delete an item, count the number of items, or iterate over them.

How it works

Bloom filter1 is a large array of m bits, all set to zero at first. To add an item, you compute k hash values for it and set the k bits at those positions:

        ┌──────┐                       ┌───────┐
item ──►│ hash │──► h1, h2, ..., hk ──►│ mod m │──► i1, i2, ..., ik
        └──────┘                       └───────┘

          i1          i2                  ik
┌───┬───┬─▼─┬───┬───┬─▼─┬───┬───┬───┬───┬─▼─┬───┐
│ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 0 │ 0 │ 1 │ 0 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
  0   1   2                                  m-1

To check if an item is in the filter, you compute the same k hashes and check if all k bits are set:

  • If any of the bits is not set, the item definitely was never added.
  • If all the bits are set, the item was probably added, but it's not guaranteed (the bits could have been set by other items).
          i1          i2                  ik
┌───┬───┬─▼─┬───┬───┬─▼─┬───┬───┬───┬───┬─▼─┬───┐
│ 1 │ 0 │ 1 │ 0 │ 0 │ 1 │ 1 │ 0 │ 1 │ 0 │ 1 │ 0 │  all 1 → probably in the set
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 0 │ 1 │ 0 │ 0 │ 0 │ 1 │ 0 │ 1 │ 0 │ 1 │ 0 │  any 0 → definitely not
└───┴───┴───┴───┴───┴─▲─┴───┴───┴───┴───┴───┴───┘          in the set
                     (!)

The k bits are randomly distributed throughout the entire array. When the filter is larger than the CPU cache, each bit access could result in a cache miss, so a lookup may wait on memory k times.

Unsure uses a blocked Bloom filter2. The bit array is divided into 512-bit blocks, each matching the size of a CPU cache line. When you add or check an item, it is hashed once: part of the hash selects a block, and the rest selects k bits within that block. Each add or check only accesses one cache line, so the filter stays fast even when it's very large.

        ┌──────┐         ┌──► high 32 bits ──► block
item ──►│ hash │──► h ───┤                   ┌─────────┐
        └──────┘         └──► low 32 bits ──►│ × salts │──► i1, i2, ..., ik
                                             └─────────┘

          block
┌───────┬───▼───┬───────┬───────┬───────┬───────┐
│   0   │   1   │   2   │   3   │  ...  │  n-1  │
└───────┴───────┴───────┴───────┴───────┴───────┘
        │       │
┌───────┘       └───────────────────────────────┐
│         i1          i2                  ik    │
├───┬───┬─▼─┬───┬───┬─▼─┬───┬───┬───┬───┬─▼─┬───┤
│ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 0 │ 0 │ 1 │ 0 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
  0   1   2                                  511

In a blocked Bloom filter, some blocks end up with more items than others, which increases the false positive rate. To make up for this, the filter needs to be a bit larger. Compared to a standard Bloom filter, it uses about 3% more memory at a 1% false positive rate, 8% more at 0.1%, and 15% more at 0.01%.

The filter uses the local wyhash3 implementation for hashing. It performs roughly the same as xxh34 but has a much simpler implementation, without SIMD or assembly.

Benchmarks

Here's an excerpt from the full benchmarks5, comparing unsure to bits-and-blooms6 (a classic Bloom filter) and gloom7 (a blocked one) (ns/op, lower is better):

OperationItemsunsurebits-and-bloomsgloom
Add10K93717
1M103619
50M3817063
Contains, hit10K83216
1M93218
50M3412960
Contains, miss10K84127
1M94833
50M3411360

Memory, bits per item at 10M items (lower is better):

False positive rateunsurebits-and-bloomsgloom
10%4.844.805.39
1%9.909.5910.79
0.1%15.4914.3816.18

Unsure is about 4× faster than bits-and-blooms and 2× faster than gloom, using 1–8% more memory than bits-and-blooms and 4–10% less than gloom.

# Count-min sketch

A count-min sketch is a frequency table that trades accuracy for space. It tells you approximately how many times each item has appeared. The estimate is never less than the actual count, but it can be higher. The sketch doesn't actually store the items, so it's much more compact than a regular map of per-item counts.

For example, a regular map counting 1,000,000 distinct 8-byte strings takes about 64 MB. A count-min sketch for the same strings, with estimates within 0.01% of the total count (with 99% probability), uses only 0.55 MB — that's 116 times smaller. The size of the sketch doesn't depend on the number of distinct items or their size, only on the accuracy you choose. So the difference gets even bigger with more or longer strings.

A count-min sketch is useful when you want to track how many times an item shows up in a large stream of events. It's often used to check if something has happened a certain number of times, like rate-limiting an IP after X requests or sending an alert after an error happens Y times. A sketch can also help decide if a key is worth keeping in the cache given how often it was requested.

// Estimates within 0.1% of the total count, with 99% probability.
sketch := countmin.New(0.001, 0.01)

sketch.Add([]byte("alice"))
sketch.Add([]byte("bob"))
sketch.AddN([]byte("alice"), 2)

sketch.Count([]byte("alice")) // 3 (or, rarely, more)
sketch.Count([]byte("bob"))   // 1 (or, rarely, more)
sketch.Count([]byte("carol")) // 0 (or, rarely, more)
sketch.Total()                // 4

The sketch uses two settings: epsilon ε and delta δ. If the actual count for any given item is X and the total of all counts is N, then the estimated count for this item will usually be between X and X+ε·N. However, rarely (with a probability of δ) the estimate could be even higher.

For example, if ε is 0.001, δ is 0.01, and N is 1 million, then the estimate could be off by up to 1,000 (ε·N) with at least 99% probability (1−δ), and by more than 1,000 with at most 1% probability (δ).

The counting error in a sketch depends on the total of all counts, not on each individual item. Therefore, frequent items are estimated more or less accurately relative to their count, but rare items are not — the absolute error is too high for them.

You can increase the per-item counter in a sketch and get the estimated count for an item, but you cannot decrease a counter or iterate over the items.

How it works

Count-min sketch8 is a table of counters with d rows and w columns, all starting at zero. Each row uses its own hash function that maps an item to one of the w counters. To add an item, you increment its counter in each row:

        ┌──────┐                       ┌───────┐
item ──►│ hash │──► h1, h2, ..., hd ──►│ mod w │──► i1, i2, ..., id
        └──────┘                       └───────┘

       ┌───┬───┬─▼─┬───┬───┬───┬───┬───┬───┬───┐
row 1  │ 0 │ 2 │ 4 │ 0 │ 1 │ 0 │ 0 │ 3 │ 0 │ 1 │  4 → 5
       ├───┼───┼───┼───┼───┼───┼─▼─┼───┼───┼───┤
row 2  │ 1 │ 0 │ 0 │ 0 │ 2 │ 0 │ 3 │ 0 │ 1 │ 0 │  3 → 4
       ├───┼───┼───┼───┼───┼───┼───┼───┼─▼─┼───┤
row d  │ 0 │ 1 │ 0 │ 2 │ 0 │ 0 │ 1 │ 0 │ 5 │ 0 │  5 → 6
       └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                 i1              i2      id

To estimate an item's count, you take the smallest value from its d counters:

  • Other items might hash to the same counter, so each counter value is ≥ true count.
  • The smallest counter is the one with the fewest collisions, so it's the best estimate.
       ┌───┬───┬─▼─┬───┬───┬───┬───┬───┬───┬───┐
row 1  │ 0 │ 2 │ 4 │ 0 │ 1 │ 0 │ 0 │ 3 │ 0 │ 1 │  4
       ├───┼───┼───┼───┼───┼───┼─▼─┼───┼───┼───┤
row 2  │ 1 │ 0 │ 0 │ 0 │ 2 │ 0 │ 3 │ 0 │ 1 │ 0 │  3
       ├───┼───┼───┼───┼───┼───┼───┼───┼─▼─┼───┤
row d  │ 0 │ 1 │ 0 │ 2 │ 0 │ 0 │ 1 │ 0 │ 5 │ 0 │  5   min = 3
       └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

With w = e/ε columns, each counter's error is at most ε·N, with probability at least 1 − 1/e. If you use d = ln(1/δ) rows, the chance that all d of the item's counters exceed its true count by more than ε·N is ≤ δ.

Unsure uses a conservative update. When you add n to an item, it first finds the current estimate E (the smallest counter), then increases only the counters that are below E+n, leaving the larger ones unchanged. This keeps the same accuracy guarantee, but the actual error is about 2× lower. The tradeoff is that counts can't be decreased.

       ┌───┬───┬─▼─┬───┬───┬───┬───┬───┬───┬───┐
row 1  │ 0 │ 2 │ 4 │ 0 │ 1 │ 0 │ 0 │ 3 │ 0 │ 1 │  4 → 4
       ├───┼───┼───┼───┼───┼───┼─▼─┼───┼───┼───┤
row 2  │ 1 │ 0 │ 0 │ 0 │ 2 │ 0 │ 3 │ 0 │ 1 │ 0 │  3 → 4
       ├───┼───┼───┼───┼───┼───┼───┼───┼─▼─┼───┤
row d  │ 0 │ 1 │ 0 │ 2 │ 0 │ 0 │ 1 │ 0 │ 5 │ 0 │  5 → 5
       └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

Counters are 32-bit instead of the usual 64-bit, so the sketch uses 2× less memory. The item is hashed once, and each row gets its own position from that hash by salting it (multiplying by a random per-row constant).

        ┌──────┐         ┌─────────┐
item ──►│ hash │──► h ──►│ × salts │──► i1, i2, ..., id
        └──────┘         └─────────┘

The sketch uses wyhash for hashing.

Benchmarks

Here's an excerpt from the full benchmarks9, comparing unsure to BoomFilters10 (a classic count-min sketch with 64-bit counters) (ns/op, lower is better):

OperationεSize (unsure)unsureBoomFilters
Add0.00155 KB1319
0.000015.4 MB1732
0.00000154 MB8198
Count0.00155 KB951
0.000015.4 MB1464
0.00000154 MB52139

Accuracy at ε=0.0001, N=10M additions over 1M distinct items, mean overestimate (lower is better):

DistributionunsureBoomFilters
Uniform139302
Zipf, s = 1.12344
Zipf, s = 1.50.30.8
Size536 KB1080 KB

Unsure is about 1.5× faster for Add and 4× faster for Count, using 2× less memory with 2–2.7× lower error.

# HyperLogLog

HyperLogLog is a unique items counter that trades accuracy for space. It tells you approximately how many distinct items it has seen, no matter how many times each one appeared. HyperLogLog (also called a sketch from here on) doesn't actually store the items, so it's much more compact than a regular set.

For example, a regular set of 1,000,000 distinct 8-byte strings takes about 64 MB. A HyperLogLog sketch counting the same strings with about 0.8% error uses only 16 KB — that's almost 4,000 times smaller. The size of the sketch doesn't depend on the number of items or their size, only on the accuracy you choose. A 16 KB sketch counts a billion items as accurately as a thousand.

HyperLogLog is useful when you need to count unique things in a large stream: unique visitors to a website, unique search queries per day, or distinct values in a database column.

// Standard error about 0.8% of the true count.
sketch := hll.New()

sketch.Add([]byte("alice"))
sketch.Add([]byte("bob"))
sketch.Add([]byte("alice"))
sketch.Add([]byte("carol"))

sketch.Count() // 3 (approximately)

You can merge sketches to gather data from multiple machines or segments (like by hour, page, or region). When you merge two sketches, you get the total number of unique items from both, without counting shared items twice.

home := hll.New() // visitors to the home page
docs := hll.New() // visitors to the docs
// ...
home.Merge(docs)
home.Count() // visitors to either page

The sketch has one setting: the precision p, which gives a relative standard error (stdErr) of 1.04/√(2^p). A common default is p=14, which gives 0.81% error.

With p=14 and 1 million distinct items:

  • 68% of the time the estimate is within 0.81% (8,100) of the true count,
  • 95% within 1.6% (2·stdErr),
  • 99.7% within 2.4% (3·stdErr).

The error is relative to the count, so the standard error is the same 0.81% for a thousand items or a billion.

You can add items to a sketch, get the estimated count, and merge sketches. You cannot check if a specific item was added, remove items, or list them.

How it works

HyperLogLog11 is based on a simple observation. If you hash items to random bit strings, about half of them start with 1, a quarter with 01, an eighth with 001, and so on. Seeing a hash with k leading zeros suggests there were about 2^(k+1) distinct items. Duplicates hash to the same value, so they don't affect the result.

A single observation is unreliable, so the sketch divides the items into m = 2^p groups, where p is the selected precision. The first p bits of the hash pick a group (a register), and the register keeps the highest rank (leading zeros plus one) it has seen in the rest of the hash:

        ┌──────┐   ┌──────────┬────────────────────────┐
item ──►│ hash │──►│ 01101001 │ 0001101...             │
        └──────┘   └────┬─────┴───────────┬────────────┘
                        │ register i      └ rank = 3 zeros + 1 = 4
          ┌───┬───┬───┬─▼─┬───┬───┬───┬───┬───┬───┐
registers │ 3 │ 0 │ 5 │ 2 │ 1 │ 0 │ 4 │ 2 │ 0 │ 1 │  2 → 4
          └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

The count is estimated from all the registers together, using a harmonic mean of 2^register. The standard error is about 1.04/√m. Merging two sketches is just taking the maximum value from each pair of registers.

The original estimator is biased when dealing with small counts (up to a few times m), so in these cases it uses a different formula called linear counting. This switch creates a sudden change in error, and some implementations (like HyperLogLog++12) address it by using large tables of bias corrections based on real data.

Unsure uses the improved estimator by Otmar Ertl13. Instead of a harmonic mean of the registers, it works from a histogram: how many registers have value 0, value 1, and so on. A formula applied to the histogram gives an accurate estimate for any number of items, from zero to billions, so there's no need for a separate formula for small counts or for correction tables.

The sketch updates the histogram whenever registers change. Most adds don't change any registers, so this is almost free. Counting always takes the same amount of time, no matter how big the sketch is. Other implementations scan all the registers on every count, which takes from microseconds to milliseconds.

Registers are one byte each, and the histogram has 64 four-byte counters (256 bytes). The item is hashed once with wyhash, and the 64-bit hash is split into the register index and the rank.

Benchmarks

Here's an excerpt from the full benchmarks14, comparing unsure to axiom15 (HyperLogLog with LogLog-Beta estimator and sparse mode) (ns/op, lower is better):

OperationpSizeunsureaxiom
Add101 KB5.57.6
1416 KB5.67.6
18256 KB8.17.9
Count101 KB6818,300
1416 KB62350,000
18256 KB574,710,000
Merge101 KB7301,710
1416 KB20,10058,600
18256 KB341,000951,000

Accuracy at p=14 (0.81% standard error), relative RMSE over 100 sketches (lower is better):

Distinct itemsunsureaxiom
1,0000.63%0.02%
40,0000.64%0.71%
1,000,0000.79%0.89%

Memory at p=14 after adding the given number of distinct items (lower is better):

Distinct itemsunsureaxiom
10016 KB1.2 KB
1,00016 KB4.2 KB
10,00016 KB16 KB
1,000,00016 KB16 KB

Unsure adds about as fast as axiom, counts 270–83,000× faster, and merges 2.3–2.9× faster, with the same accuracy and memory for counts above the number of registers. When there are fewer items than registers, axiom's sparse mode makes it more accurate and compact.

# T-digest

A t-digest is a summary of a stream of numbers that trades accuracy for space. It can give you an approximate median, 99th percentile, or any other quantile of the data. It can also estimate what fraction of the numbers are below a certain value (the CDF). The digest doesn't actually store the numbers, so it's much more compact than a sorted list.

For example, to find the exact 99th percentile of 1,000,000 numbers, you'd have to keep all of them. With 64-bit floats, that takes 8 MB. A t-digest estimates the same percentile within about 0.03% of the rank (between the 98.97th and the 99.03rd percentile) using only 8 KB — 1,000 times smaller. The size of the digest doesn't depend on the number of values, only on the accuracy you choose. An 8 KB digest summarizes a billion values as well as a million.

A t-digest is useful when you need quantiles over a large stream: latency percentiles for a service, the distribution of request sizes or prices, or the outlier thresholds in sensor data.

// Response times in milliseconds.
digest := tdigest.New()

digest.Add(12.5)
digest.Add(8.1)
digest.Add(130.0)
// ...

digest.Quantile(0.5)  // median
digest.Quantile(0.99) // 99th percentile
digest.CDF(100)       // fraction of responses faster than 100 ms

You can merge digests to gather data from multiple machines or segments (like by hour, endpoint, or region). A merged digest estimates the percentiles of all values, but with less accuracy than individual digests do.

east := tdigest.New() // latencies on one server
west := tdigest.New() // latencies on another
// ...
east.Merge(west)
east.Quantile(0.99) // 99th percentile across both servers

The digest has one setting: the compression δ, which limits the number of centroids (see How it works below) to δ+2. A common default is δ=100, which takes 8 KB. Increasing δ uses more memory but reduces estimation error.

The error is measured in ranks: a rank error of 0.03% at quantile q=0.99 means the value returned by Quantile(0.99) is somewhere between the 98.97th and the 99.03rd percentile of the actual values. Tail estimates, such as q=0.99 or q=0.999, are more accurate than the median (q=0.5).

You can add numbers to a digest, estimate quantiles and the CDF, and merge digests. You cannot remove numbers or list them.

How it works

T-digest16 summarizes the numbers as a sorted list of centroids. Each centroid has a mean and a weight — the number of values it represents. To estimate a quantile, the digest finds the centroids around the requested rank and interpolates between their means.

The trick is in how big the centroids are allowed to grow. A scale function k(q) maps a quantile q to a number from -δ/4 to δ/4, and each centroid may only span 1 unit of k: if a centroid covers the quantiles from q₁ to q₂, then k(q₂) − k(q₁) ≤ 1.

The arcsine scale function, k(q) = δ/(2π)·asin(2q-1), is steep near q=0 and q=1 and flat near the median. This means the centroids it produces are larger in the center and smaller at the ends:

weight
  │                ┌──┐
  │             ┌──┤  ├──┐
  │          ┌──┤  │  │  ├──┐
  │       ┌──┤  │  │  │  │  ├──┐
  │  ┌─┬──┤  │  │  │  │  │  │  ├──┬─┐
  └──┴─┴──┴──┴──┴──┴──┴──┴──┴──┴──┴─┴──► value
    min      q=0.25   median  q=0.75     max

With 1M values and δ=100, the centroids around the median hold about 27,000 values each, those around the 99th percentile about 5,000, and the outermost ones about 500. This keeps the error near the tails small.

Unsure is the merging t-digest from the Dunning and Ertl paper. New values go to a buffer of 5·δ numbers. When the buffer is full, the digest sorts it and merges it with the centroids (already sorted) in a single pass, building new centroids from left to right as long as each one fits within its span of k. Every other pass goes right to left, so that neither tail ends up with fuller centroids. Quantile, CDF and Merge flush the buffer first.

The centroids are kept in two fixed arrays of δ+2 entries (the current list and the next one being built), along with the cumulative weights for a binary search in Quantile and CDF. With δ=100, that's 16·102·2 + 8·103 bytes for the centroids, and 8·500 bytes for the buffer, about 8 KB in total.

Centroids with weight 1 are treated as exact values rather than spread around their mean, and the exact minimum and maximum are used at the ends. So digests with few values give exact results, and Quantile(0) and Quantile(1) are always exact.

Benchmarks

Here's an excerpt from the full benchmarks17, comparing unsure to influxdata18 (merging t-digest with the same scale function) (ns/op, lower is better):

Operationδunsureinflux
Add504478
1004986
50059105
Quantile501113
1001313
5001516
Merge502962,060
1006304,330
5002,72023,950

Accuracy at δ=100, mean rank error over 1M normal values (lower is better):

DigestQuantileunsureinflux
Single0.50.007%0.015%
0.990.018%0.016%
0.9990.011%0.009%
Merged0.50.055%0.037%
0.990.023%0.021%
0.9990.006%0.016%

Memory at δ=100 (lower is better): unsure 8.5 KB, influxdata 14.4 KB.

Unsure adds values about 1.8× faster than influxdata, queries at the same speed, merges 7–9× faster, and uses 40% less memory at about the same accuracy.

# Top-k sketch

A Top-k sketch finds the k most frequent items in a stream (the "heavy hitters"), along with their approximate counts. It uses a fixed amount of memory based only on k, no matter how many different items are in the stream.

To find the exact top items, you'd have to count every unique item, for example with a map of per-item counts. With millions of distinct items, that takes tens or hundreds of megabytes. A Top-k sketch for the top 1000 items takes about 360 KB, and it usually finds all of them.

A Top-k sketch is useful when you want to identify the "hottest" data: the most requested URLs, the most active users, or the most common search queries. Unlike a count-min sketch, it keeps the actual items, so it can list them.

// Track the 2 most frequent items.
sketch := topk.New(2)

sketch.Add([]byte("alice"))
sketch.Add([]byte("bob"))
sketch.Add([]byte("carol"))
sketch.AddN([]byte("alice"), 4)
sketch.AddN([]byte("carol"), 2)

sketch.Top()                  // [{alice 5} {carol 3}]
sketch.Count([]byte("alice")) // 5
sketch.Count([]byte("bob"))   // 0 (not in the top 2)
sketch.Total()                // 9

Top returns up to k items, from the most frequent to the least frequent. Counts are estimates: they're usually exact for the most frequent items, but may be slightly lower than the actual count for less common items.

The sketch works best when the stream is skewed, meaning a few items appear much more often than the others. This is common in real situations. If all items are about equally frequent, there is no clear top k, and the results become less accurate.

You can add items to the sketch and get the top items with their counts, but you can't decrease counts or check items that aren't in the top k.

How it works

HeavyKeeper19 is a table of buckets that estimates item counts. It has d rows and w columns, and each item maps to one bucket in each row. Each bucket holds a fingerprint of the item and a count. To add an item, you update its bucket in each row:

  • If the bucket is empty or owned by the item, increase its count.
  • If the bucket is owned by another item, decay it: decrease its count by 1 with probability b^count (b < 1). When the count reaches 0, the new item takes over the bucket with a count of 1.
          ┌──────┐
add x ──► │ hash │──► h ──► high 32 bits ──► fingerprint = 7
          └──────┘    │
                      │ map to buckets
                   ┌──┴────────────┐
       ┌───────┬───▼───┬───────┬───│───┬───────┐
row 1  │       │ fp=7  │ fp=3  │   │   │ fp=5  │  column 2 is owned by x:
       │       │  41   │   2   │   │   │  980  │  41 → 42
       ├───────┼───────┼───────┼───▼───┼───────┤
row 2  │ fp=9  │       │       │ fp=2  │ fp=4  │  column 4 is owned by another:
       │  17   │       │       │   3   │   1   │  3 → 2 with probability b^3
       └───────┴───────┴───────┴───────┴───────┘
         col 1   col 2   col 3   col 4   col 5

The item's estimated count is the largest count among its buckets. Frequent items quickly reach counts that make decay very unlikely, so they keep their buckets. Rare items keep replacing each other without affecting the frequent ones. An item that becomes frequent later in the stream can still outnumber a less frequent owner and take over its bucket.

Next to the table, the sketch keeps a min-heap with the k items that have the largest estimates so far. After an item's buckets are updated, if its estimate is larger than the smallest count in the heap, the item is added to the heap, and the smallest item is removed. If the item is already in the heap, its count is increased.

                   ┌───────────┐
add x (15) ───────►│ bob    12 │  the root has the smallest count
                   └─────┬─────┘
              ┌──────────┴────────┐
        ┌─────┴─────┐       ┌─────┴─────┐
        │ alice  30 │       │ dave   14 │  15 > 12: x replaces bob at the root
        └─────┬─────┘       └───────────┘  15 > 14: x moves down, dave moves up
      ┌───────┴──────┐
┌─────┴─────┐  ┌─────┴─────┐
│ eve   980 │  │ fay    41 │
└───────────┘  └───────────┘

Unsure uses d = 2 rows of max(16k, 256) buckets, and a decay base of b = 0.8. The probabilities b^count are precomputed as 64-bit thresholds, so decay is cheap: the count c decreases if a random number is below the c-th threshold.

The original HeavyKeeper uses b = 1.08⁻¹ ≈ 0.93, and more rows. A smaller b lets frequent items lock their buckets faster, which makes the sketch more precise on flat streams. However, if b is too small (like 0.5), the first items to claim buckets keep them permanently, so items that become frequent later are never counted, even if they end up being the most frequent. Using b = 0.8 keeps most of the precision and still catches most late-arriving frequent items. As for the rows, two are enough to protect frequent items from collisions in a single row, and adding more rows only makes the sketch slower.

The item is hashed once. The hash is mapped to a position in each row by salting it (multiplying by a random per-row constant). The fingerprint is the top 32 bits of the hash. The heap uses the 64-bit hash to identify items, so it doesn't compare the actual item bytes.

Each bucket is 8 bytes (a 32-bit fingerprint and a 32-bit count), so the table takes 256·k bytes. The heap takes about 100 bytes per item plus the item itself.

The sketch uses wyhash for hashing and wyrand for random numbers.

Benchmarks

Here's an excerpt from the full benchmarks20, comparing unsure to keiler21 (HeavyKeeper with b = 0.9 and d = ln k) and dgryski22 (Filtered Space-Saving23) on the Zipf s=0.8 stream (time/op, lower is better):

Operationkunsurekeilerdgryski
Add1024 ns64 ns59 ns
10025 ns84 ns70 ns
100033 ns130 ns73 ns
Top100.34 µs0.64 µs0.77 µs
1003.7 µs13 µs14 µs
100075 µs270 µs292 µs

Precision is the fraction of the reported top k that is in the true top k (higher is better):

kZipf sunsurekeilerdgryski
1000.60.900.380.19
0.81.000.780.85
1.00.991.001.00
Size39 KB27 KB39 KB
10000.60.970.700.52
0.81.000.981.00
1.01.001.001.00
Size364 KB414 KB453 KB

Count error is the mean of |estimate − true| / true over the reported items (lower is better):

kZipf sunsurekeilerdgryski
1000.614%94%428×
0.80.10%46%230%
1.00.01%0.12%0.0%
10000.62.4%62%18×
0.80.23%1.9%0.31%
1.00.05%0.24%0.02%

Unsure is 2–4× faster than the others for Add and 1.8–4× faster for Top. On flat streams (s=0.6), it finds the true top k with 90–97% precision, while keiler drops to 38–70% precision with a count error of 62–94%, and dgryski to 19–52% with counts 18× or more too high. On skewed streams (s=1.0), all three are about equally accurate.

# Wrapping up

As engineers, we usually want exact answers from our data. But sometimes, allowing a bit of uncertainty can cut memory usage by tens, hundreds, or even thousands of times. That's where probabilistic data structures come in:

  • Bloom filter checks if an item might be in a set.
  • Count-min sketch estimates how many times each item has occurred.
  • HyperLogLog estimates the number of distinct items.
  • T-digest estimates quantiles of a stream of numbers.
  • Top-k sketch identifies the most frequent items in a stream.

To use them in Go, try the nalgeon/unsure package:

go get github.com/nalgeon/unsure

★ Subscribe to keep up with new posts.