Skip to content

bloom filters (and the blocked variant)

nate
Jul 25, 20262 min read1 read

bloom filters (and the blocked variant)

ritten while porting gloom to zig for stream's jss segment archive. interactive companion: the "gloom field guide" artifact (claude.ai).

the contract

answers "have I seen this?" with: no = definitely no, yes = probably. no false negatives ever; false positives at a chosen rate p. stores membership in a few bits per item — 1M DIDs at p=0.001 is ~1.8 MB vs ~30 MB for the strings. classic form (Burton H. Bloom, CACM 1970 — named for him, not the flower; born of hyphenating text against a dictionary too big for core memory): one big bit array, k hash functions, k scattered probes per key.

why "blocked"

the classic form's k scattered probes = up to k cache misses, and on modern hardware the misses dominate the arithmetic. Putze/Sanders/Singler 2007 ("Cache-, Hash- and Space-Efficient Bloom Filters"): hash first to ONE 512-bit block — exactly a cache line — then confine all k probes inside it. one memory fetch per query. cost: blocks fill unevenly (Poisson), so realized fp is worse than the classic formula → compensate with a few % more blocks.

gloom's construction (jcalabro/gloom v0.1.0)

cache-line blocked + "one-hashing" (OHBF, Lu et al. 2015):

  • ONE xxh3-128 per key. hi 64 bits → block via Lemire mulhi reduction (no modulo, no capacity cliff). lo 64 folded to u32 → intra-block probe seed.
  • the 512-bit block is partitioned into k prime-width bands (k=9: 41+43+47+53+59+ 67+71+73+58 = 512); probe i = offset_i + seed % prime_i. distinct primes de-correlate the probes from one seed.
  • OptimalParams grows block count ~12.5% at a time until the Poisson-exact realized fp ≤ target (cap 4× base). float-sensitive — when porting, golden-test against the reference implementation.
  • serialization: version|k|numBlocks|count|words|crc32c(Castagnoli), all LE.

where it earns its keep (jetstream v2)

every sealed jss segment carries gloom filters in its footer: one per segment (sized to unique DIDs) + one per 4096-event block (all identically sized so the region is indexable by multiplication), p=0.001. per-DID archive queries ask the bloom before decompressing a block: "no" skips it for certain; "maybe" costs one decompression. correctness here is all-or-nothing: one wrong bit silently drops someone's data with no error anywhere. which is why stream's zig port (src/internal/gloom.zig) is verified byte-for-byte against Go output and passes upstream's VerifySealedMetadata (row-re-derived bloom membership) on files we write.

lineage

1970 Bloom (CACM) → 2006 Kirsch/Mitzenmacher double-hashing ("hash less") → 2007 blocked (Putze/Sanders/Singler) → 2015 OHBF prime partitions (Lu et al.) → 2019 xxh3 (Collet) → 2026 gloom (Jim Calabro, for the jetstream rewrite; same Jim as uscope, the from-scratch zig debugger).

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions