Bloom filter: measure the false-positive curve and the cache cost
Measure your machineNeeds Python with NumPy
Problem
A Bloom filter answers "is k in the set?" with "no" or "probably". It sets k bits per inserted key in an array of m bits; a lookup checks the same k bits. A key that was never inserted is reported present when all its bits happen to be set by other keys, at a rate of about (1 − e^(−kn/m))^k, which with the best k = (m/n) ln 2 depends only on bits per key. Each lookup touches k random bits, so a filter much larger than the CPU cache costs k cache misses per lookup; a blocked filter puts all k bits of a key in one 64-byte block, paying one miss and a slightly worse false-positive rate.
Insert 1,000,000 random keys with m/n = 5, 8, 10, 14.4 and 20 bits per key, and k = round((m/n) · ln 2). For each setting, test keys that were not inserted and count the fraction reported as present. Then time 10⁷ lookups with a filter of 10 MB and a filter of 1 GB, and with a blocked 1 GB filter.
The question. Does the false-positive rate follow the formula, and how much slower is a lookup once the filter no longer fits in cache?
Predict
Expect about 9% at 5 bits, about 1% at 10 bits, about 0.1% at 14.4 bits (each extra 4.8 bits divides the rate by ten). The large filter should be several times slower per lookup because of cache misses. A blocked version should drop the large-filter time close to one miss per lookup, with a slightly higher false-positive rate.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
bloom-filter-measure-the-false-positive.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Bloom filter: measure the false-positive rate, and the cost of size. 1,000,000 keys at 5 to 20 bits per key with k = round(m/n ln 2); the false-positive rate on 1,000,000 absent keys. Then 10^7 lookups in a 10 MB and a 1 GB filter (NumPy), and in a blocked filter where all k bits of a key sit in one 64-byte block. """ import argparse, json, time import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--keys", type=int, default=1_000_000, help="Keys inserted.") p.add_argument("--bits", type=lambda s: [float(v) for v in s.split(",")], default=[5, 8, 10, 14.4, 20], help="Bits per key.") p.add_argument("--lookups", type=int, default=10_000_000, help="Lookups for the timing part.") p.add_argument("--big-mb", type=int, default=1024, help="Size of the large filter, MB.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) M1, M2 = np.uint64(0x9E3779B97F4A7C15), np.uint64(0xC2B2AE3D27D4EB4F) def hashes(keys): h1 = (keys * M1) ^ (keys >> np.uint64(29)); h2 = (keys * M2) | np.uint64(1) return h1, h2 def positions(keys, k, m): h1, h2 = hashes(keys) return [((h1 + np.uint64(i) * h2) % np.uint64(m)).astype(np.int64) for i in range(k)] ins = rng.integers(1, 2**62, args.keys, dtype=np.uint64); absent = rng.integers(2**62, 2**63, args.keys, dtype=np.uint64) rows = [] for b in args.bits: m = int(b * args.keys); k = max(1, round(b * np.log(2))) bits = np.zeros(m, dtype=bool) for pos in positions(ins, k, m): bits[pos] = True present = np.ones(args.keys, dtype=bool) for pos in positions(absent, k, m): present &= bits[pos] fp = float(present.mean()) rows.append({"bits_per_key": b, "k": k, "fp_pct": fp * 100, "formula_pct": (1 - np.exp(-k / b)) ** k * 100}) print(f"{b:5.1f} bits/key, k = {k:2d}: false positives {fp:7.3%} (formula {rows[-1]['formula_pct']:6.3f}%)") timing = [] q = rng.integers(1, 2**62, args.lookups, dtype=np.uint64) for label, mb, blocked in (("10 MB filter", 10, False), (f"{args.big_mb:,} MB filter", args.big_mb, False), (f"{args.big_mb:,} MB blocked filter", args.big_mb, True)): m = mb * 8 * 2**20; bits = np.zeros(m // 8, dtype=np.uint8); k = 7 h1, h2 = hashes(q) t = time.perf_counter() ok = np.ones(len(q), dtype=bool) if blocked: block = (h1 % np.uint64(m // 512)).astype(np.int64) * 512 # 64-byte block = 512 bits for i in range(k): pos = block + ((h2 >> np.uint64(9 * i)) % np.uint64(512)).astype(np.int64) ok &= (bits[pos >> 3] >> (pos & 7).astype(np.uint8)) & 1 == 1 else: for i in range(k): pos = ((h1 + np.uint64(i) * h2) % np.uint64(m)).astype(np.int64) ok &= (bits[pos >> 3] >> (pos & 7).astype(np.uint8)) & 1 == 1 dt = time.perf_counter() - t timing.append({"filter": label, "ns_per_lookup": dt / len(q) * 1e9}); print(f"{label:<26} {dt / len(q) * 1e9:6.1f} ns per lookup") del bits if args.json: with open(args.json, "w") as f: json.dump({"tables": {"rates": rows, "timing": timing}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run bloom-filter-measure-the-false-positive.py uv run bloom-filter-measure-the-false-positive.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --keys1,000,000 | Keys inserted. |
| --bits5, 8, 10, 14.4, 20 | Bits per key. |
| --lookups10,000,000 | Lookups for the timing part. |
| --big-mb1024 | Size of the large filter, MB. |
| --seed0 | Random seed. |
Our run
Data table
| Bits per key | Measured | Formula |
|---|---|---|
| 5 | 9.21 | 9.18 |
| 8 | 2.14 | 2.16 |
| 10 | 0.812 | 0.819 |
| 14.4 | 0.102 | 0.099 |
| 20 | 0.005 | 0.007 |
False positives
| Bits per key | k | Measured | Formula |
|---|---|---|---|
| 5 | 3 | 9.21% | 9.18% |
| 8 | 6 | 2.14% | 2.16% |
| 10 | 7 | 0.812% | 0.819% |
| 14.4 | 10 | 0.102% | 0.099% |
| 20 | 14 | 0.005% | 0.007% |
Lookup time
| Filter | ns per lookup |
|---|---|
| 10 MB filter | 34.8 |
| 1,024 MB filter | 116 |
| 1,024 MB blocked filter | 67.2 |
5.0 bits/key, k = 3: false positives 9.212% (formula 9.185%)
8.0 bits/key, k = 6: false positives 2.136% (formula 2.158%)
10.0 bits/key, k = 7: false positives 0.812% (formula 0.819%)
14.4 bits/key, k = 10: false positives 0.102% (formula 0.099%)
20.0 bits/key, k = 14: false positives 0.005% (formula 0.007%)
10 MB filter 34.8 ns per lookup
1,024 MB filter 115.6 ns per lookup
1,024 MB blocked filter 67.2 ns per lookupWhat we found
The measured rates sat on the formula: 9.21% at 5 bits, 0.812% at 10 bits and 0.102% at 14.4 bits, each 4.8 bits dividing the rate by about ten. A lookup in the 10 MB filter cost 34.8 ns and in the 1 GB filter 116 ns, several times slower; the blocked 1 GB filter brought it down to 67.2 ns.
Read it
The false-positive rate is set by bits per key alone, so a filter is sized from the error you can afford, not from the data size. Its speed is set by where those bits live. A filter that fits in cache costs a few nanoseconds; one that does not costs a cache miss per probed bit, and a blocked layout buys most of that back by keeping one key's bits on one line.
On a large filter the bottleneck is memory latency, which no hash function tuning will fix.