Backend 4 min read 794 words

Probabilistic data structures in Go: counting without quite counting

ES
Probabilistic data structures in Go: counting without quite counting

If someone asks you how many people were at a concert, you don’t count them one by one: you look, make a rough guess and say “about five thousand”. You might be off by a few hundred, but it took you two seconds and you didn’t need to write down anyone’s name. Probabilistic data structures do exactly that: they give an approximate answer, with an error you know in advance, in exchange for using a tiny fraction of the memory.

Anton Zhiyanov, the same author behind Go Concurrency Distilled, has published a walkthrough of five of them along with unsure, a Go package that implements them using only the standard library. It’s one of those articles that explains something you’ve heard of and that, by the end, you finally understand.

The five structures

What helps most to understand what they’re for is seeing how much space they take compared with the “exact” solution (a map or a set), with the article’s own figures for a million elements:

StructureForExactProbabilisticError
Bloom filterIs this element here?~64 MB~1.24 MB1% false positives
Count-min sketchHow many times has it appeared?~64 MB~0.55 MB0.01% of total
HyperLogLogHow many distinct ones are there?~64 MB16 KB~0.8%
T-digestWhat’s the 99th percentile?8 MB~8 KB~0.03% in rank
Top-kWhich are the most frequent?—~360 KB for the top 1000almost always exact

Sixteen kilobytes to count a million distinct elements. Hard to believe until you see how it works.

Bloom filter. A bit array and several hash functions: adding an element turns on a few bits, and querying checks whether they’re all on. It can tell you something is there when it isn’t (false positive), but never that it isn’t there when it is. It’s perfect for skipping an expensive disk or network lookup when you know for sure the data doesn’t exist. You can’t delete or iterate over elements.

Count-min sketch. A table of counters with several rows; each element adds one to a counter per row and the estimate is the minimum of them all. It never undercounts, though it can overcount, and it’s less accurate for rare elements. It’s useful for things like rate-limiting by IP or deciding which key deserves to be cached.

HyperLogLog. It looks at how many leading zeros the hashes have: if you’ve seen a hash starting with lots of zeros, you’ve probably seen lots of distinct elements (like someone guessing they’ve tossed a lot of coins because they got ten heads in a row). It’s what many databases use to estimate cardinalities; DuckDB, for example, stores HyperLogLog statistics in its format, something I mentioned when writing about DuckDB file formats.

T-digest. For percentiles over a stream of numbers, like latencies. It keeps groups of values (centroids) small at the extremes and large in the middle, so it’s most accurate right where it matters: the 99th percentile that wakes you up at night.

Top-k. For finding the most frequent elements (the most requested URLs, the most active users) without keeping a count of all of them. It works best when a few elements stand out well above the rest, which is usually the case with real traffic.

The unsure package

The API is as simple as you’d expect:

filter := bloom.New(1000, 0.01)
filter.Add([]byte("alice"))
filter.Contains([]byte("alice")) // true
sketch := hll.New()
sketch.Add([]byte("alice"))
sketch.Count() // approximately 1

You install it with go get github.com/nalgeon/unsure and each structure lives in its own subpackage (bloom, countmin, hll, tdigest and topk). It has no dependencies, which in Go is always welcome.

The article includes comparisons with the usual libraries (bits-and-blooms, BoomFilters, axiom, influxdata, dgryski…), and it comes out ahead in almost all of them: according to the author’s numbers, the Bloom filter is about four times faster than bits-and-blooms and the HyperLogLog counts hundreds to thousands of times faster than axiom’s. Mind you, these are the author’s own benchmarks of his own package, so before changing anything in production, measure with your own data. He himself admits that in some cases (HyperLogLog with very few elements) the alternative is more accurate and smaller.

Why I like it

The best part of the article isn’t the package, but that it takes the fear out of these structures. There are plenty of everyday problems (counting unique visitors, knowing which endpoints get called the most, computing latency percentiles) that we solve with a map that grows and grows until one day the process runs out of memory. Knowing there’s an alternative that takes kilobytes and is off by less than 1% changes how you approach the problem. You don’t always need the exact figure: often it’s enough to know there were about five thousand people at the concert.