Skip to content
About
Main content

Monkey-style filter allocation

Simulate a modelNeeds Python

Problem

An LSM tree keeps a for every 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 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

With three levels and the same memory, how do Monkey's wasted I/Os compare with equal bits per key?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    monkey-style-filter-allocation.py53 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Wasted I/Os per million missing-key lookupsApple M2 · 8 cores · 8 GB
equalmonkey
Data table
Wasted I/Os per million missing-key lookups
LevelsseriesWasted I/Os per million lookups
3equal24.6K
3monkey11.7K
4equal32.8K
4monkey11.7K
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Same memory, two allocations

Same memory, two allocations
LevelsAllocationBits per key, by levelWasted I/Os (formula)Simulated per lookup
3equal10.0, 10.0, 10.024.6K0.025
3monkey19.1, 14.3, 9.511.7K0.012
4equal10.0, 10.0, 10.0, 10.032.8K0.033
4monkey23.8, 19.1, 14.3, 9.511.7K0.012
Output · uv run monkey-style-filter-allocation.py
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.