Monkey-style filter allocation
Simulate a modelNeeds Python
Problem
An LSM tree keeps a Bloom filter for every sorted run so that a lookup for a missing key can skip runs without reading them. Each false positive is a wasted read, and a lookup for a missing key pays the sum of the false-positive rates across all levels. With equal bits per key everywhere, the levels all have the same rate, so the total grows with the number of levels. Since level sizes grow by a factor of T, almost all the keys are in the last level; taking a few bits per key from the largest level costs it little but can buy the small levels a much lower rate. That is the Monkey allocation: false-positive rates proportional to level size.
Write a small simulation of a 3-level LSM with 10,000, 100,000 and 1,000,000 keys. Give the same total memory budget in two ways: (a) equal bits per key at every level, (b) false-positive rates proportional to level size (about 4.8 bits per key fewer for each level larger). Count wasted I/Os for 1 million lookups of missing keys. Then add a fourth level.
The question. How many wasted I/Os does each allocation cost, and what happens to each total when a fourth level is added?
Predict
Expect (b) to waste noticeably fewer I/Os. With a fourth level, the total false-positive rate in (b) should stay about the same, while in (a) it grows with the number of levels.
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
monkey-style-filter-allocation.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Monkey-style filter allocation. A 3-level LSM (10,000, 100,000 and 1,000,000 keys) with one Bloom filter per level and one total memory budget, split two ways: equal bits per key at every level, or false-positive rates proportional to level size (about 4.8 bits per key fewer for each larger level). Wasted I/Os for lookups of missing keys, then the same with a fourth level. Computed from the Bloom false-positive formula, and checked by simulation. """ import argparse, json, math, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--bits-per-key", type=float, default=10, help="Average bits per key over all levels (the budget).") p.add_argument("--lookups", type=int, default=1_000_000, help="Lookups of missing keys.") 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() random.seed(args.seed) LN2SQ = math.log(2) ** 2 fpr = lambda bits_per_key: math.exp(-bits_per_key * LN2SQ) def allocate(levels, mode): total = args.bits_per_key * sum(levels) if mode == "equal": return [args.bits_per_key] * len(levels) # Monkey: FPR_i proportional to n_i; solve for the constant c with sum n_i * bits(c * n_i) = total lo, hi = 1e-12, 1.0 for _ in range(100): c = math.sqrt(lo * hi) used = sum(n * (-math.log(min(1.0, c * n / max(levels))) / LN2SQ) for n in levels) lo, hi = (lo, c) if used < total else (c, hi) return [-math.log(min(1.0, c * n / max(levels))) / LN2SQ for n in levels] rows = [] for levels in ([10_000, 100_000, 1_000_000], [10_000, 100_000, 1_000_000, 10_000_000]): for mode in ("equal", "monkey"): bits = allocate(levels, mode) expected = sum(fpr(b) for b in bits) # wasted I/Os per missing-key lookup simulated = sum(sum(random.random() < fpr(b) for b in bits) for _ in range(args.lookups // 10)) / (args.lookups // 10) rows.append({"levels": len(levels), "allocation": mode, "bits": ", ".join(f"{b:.1f}" for b in bits), "wasted_per_lookup": expected, "simulated": simulated, "wasted_per_million": expected * 1e6}) print(f"{len(levels)} levels, {mode:<7} bits/key per level [{rows[-1]['bits']}] " f"wasted I/Os per million missing-key lookups {expected * 1e6:9,.0f} (simulated {simulated * 1e6:9,.0f})") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"allocations": rows}}, 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 monkey-style-filter-allocation.py uv run monkey-style-filter-allocation.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --bits-per-key10 | Average bits per key over all levels (the budget). |
| --lookups1,000,000 | Lookups of missing keys. |
| --seed0 | Random seed. |
Our run
Data table
| Levels | series | Wasted I/Os per million lookups |
|---|---|---|
| 3 | equal | 24.6K |
| 3 | monkey | 11.7K |
| 4 | equal | 32.8K |
| 4 | monkey | 11.7K |
Same memory, two allocations
| Levels | Allocation | Bits per key, by level | Wasted I/Os (formula) | Simulated per lookup |
|---|---|---|---|---|
| 3 | equal | 10.0, 10.0, 10.0 | 24.6K | 0.025 |
| 3 | monkey | 19.1, 14.3, 9.5 | 11.7K | 0.012 |
| 4 | equal | 10.0, 10.0, 10.0, 10.0 | 32.8K | 0.033 |
| 4 | monkey | 23.8, 19.1, 14.3, 9.5 | 11.7K | 0.012 |
3 levels, equal bits/key per level [10.0, 10.0, 10.0] wasted I/Os per million missing-key lookups 24,578 (simulated 24,780)
3 levels, monkey bits/key per level [19.1, 14.3, 9.5] wasted I/Os per million missing-key lookups 11,664 (simulated 11,600)
4 levels, equal bits/key per level [10.0, 10.0, 10.0, 10.0] wasted I/Os per million missing-key lookups 32,770 (simulated 32,940)
4 levels, monkey bits/key per level [23.8, 19.1, 14.3, 9.5] wasted I/Os per million missing-key lookups 11,745 (simulated 11,580)What we found
With three levels, equal allocation wasted 24.6K I/Os per million lookups and the Monkey allocation 11.7K, from the same memory. Adding a fourth level raised the equal allocation to 32.8K, while Monkey's total barely moved (11.7K). The simulated filters agreed with the formula in every case, as the table shows.
Read it
The bits are cheap where the keys are few. Equal bits per key looks fair, but it spends the budget evenly across keys instead of evenly across levels, and the lookup pays per level. Moving bits from the last level, which holds about 90% of the keys, to the small ones turns a cost that grows with the depth of the tree into one that is nearly constant.
The same reasoning applies to any cascade of filters in front of reads: what matters is the sum of the false-positive rates a lookup crosses, not the rate of any one filter.