Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Probabilistic & System-Scale Structures  ›  Lesson

LSM Trees & Write Amplification

LSM Trees 32 minBuffer writes in memory, flush them sorted, then pay to rewrite surviving data again at compaction — trading write cost for a read path a Bloom filter can shrink
You're building a piece ofQuarry — the storage engine
This piece — lsm_report(): Flushes and compacts writes through an LSM pipeline, and measures write amplification and read probes.
Scenario Quarry's engineers want one number for 'how much does this write cost us later' and one number for 'how much does the Bloom filter actually save on reads' — before they tune memtable_limit and compaction_threshold for real traffic.
Your task
Build lsm_report(writes, memtable_limit, compaction_threshold, queries, m, k). Run writes through a memtable -> flush -> cascading-compaction pipeline (run_lsm), measure write amplification (bytes actually written to disk / the number of writes the caller asked for), then look up every key in queries once scanning plainly and once using a Bloom filter per sstable to skip scans, counting real probes each way. Example: lsm_report([('k1','v1'),('k2','v2'),('k3','v3'),('k1','v4'),('k4','v5'),('k5','v6'),('k2','v7'),('k6','v8'),('k7','v9')], 3, 2, ['k7','k1','ghost'], 32, 3) -> {'write_amplification': 1.56, 'probes_without_bloom': 5, 'probes_with_bloom': 2}.

LSM Trees & Write Amplification

In short: a log-structured merge tree never edits a file on disk. A write only ever touches an in-memory memtable; once that fills up, it flushes — sorted, once — into a new sstable at the top level, L0. Once a level holds enough sstables, compaction merges them into one bigger sstable one level down, dropping any stale values along the way. Every flush and every compaction writes bytes to disk, so the same record can get paid for more than once before it settles into its final level — that repeated cost is write amplification. The trade buys something real: writes are always a cheap append, never a random-access rewrite. Reads pay the other side of the bill, scanning sstables newest-first until a key turns up — which is exactly where a Bloom filter per sstable earns its keep, by saying "definitely not here" and skipping the scan entirely.

Stacked levels — memtable on top, L0, L1, L2 below — with one write's path flushing down and a second arrow showing compaction merging two sstables into one, a level lower

Flush a memtable, then pay for it again at compaction

Quarry's engine buffers writes in memory, flushes them sorted, and merges sstables down through the levels once a level gets crowded:

step through it
1def make_sstable(memtable):
2 return sorted(memtable.items())
3 
4def merge_sstables(sstables):
5 merged = {}
6 for sst in sstables:
7 for k, v in sst:
8 merged[k] = v
9 return sorted(merged.items())
10 
11def run_lsm_traced(writes, memtable_limit, compaction_threshold):
12 memtable, levels, bytes_written = {}, [], 0
13 for key, value in writes:
14 memtable[key] = value
15 if len(memtable) >= memtable_limit:
16 sstable = make_sstable(memtable)
17 bytes_written += len(sstable)
18 print(f"flush: {sstable} -> L0 (+{len(sstable)} bytes)")
19 while len(levels) == 0:
20 levels.append([])
21 levels[0].append(sstable)
22 memtable = {}
23 i = 0
24 while i < len(levels) and len(levels[i]) >= compaction_threshold:
25 merged = merge_sstables(levels[i])
26 bytes_written += len(merged)
27 print(f"compact L{i}: {levels[i]} -> {merged} in L{i+1} (+{len(merged)} bytes)")
28 levels[i] = []
29 while len(levels) <= i + 1:
30 levels.append([])
31 levels[i + 1].append(merged)
32 i += 1
33 print("total bytes written:", bytes_written, "for", len(writes), "writes")
34 
35run_lsm_traced([("k1", "v1"), ("k2", "v2"), ("k3", "v3"), ("k1", "v4"),
36 ("k4", "v5"), ("k5", "v6"), ("k2", "v7"), ("k6", "v8"),
37 ("k7", "v9")], memtable_limit=3, compaction_threshold=2)

Nine writes, but 14 bytes end up written to disk: two flushes of 3 entries each (6 bytes), then — because L0 just reached the compaction threshold of 2 sstables — one compaction that merges those two sstables, deduplicating k1 and k2 down to their newest values, into a fresh 5-entry sstable in L1 (5 more bytes). A third flush (3 more bytes) lands the last three writes back in L0, for 14 total. k1 and k2 each got written twice: once at flush, once again at compaction. That's the whole definition of write amplification: 14 / 9 ≈ 1.56 bytes written per byte the caller actually asked to write.

A Bloom filter turns a scan into a skip

Reading a key means checking the memtable first (free — it's already in RAM), then scanning sstables newest-first until the key turns up or every sstable has been checked. Quarry keeps a small Bloom filter (section 8) alongside each sstable so a probe that would come back empty can be skipped instead of scanned:

python

k7 costs 1 probe either way — it's sitting in the newest sstable, so there was never a wasted scan to skip. k1 and ghost both drop from needing a probe into the L0 sstable (which doesn't contain them) down to zero wasted probes there, because each sstable's Bloom filter correctly rules them out before any scanning happens. A filter can only ever save a probe or cost nothing extra — it never causes a real key to be missed.

Quick practice

What does this print?
writes = [("a", 1), ("b", 2), ("a", 3)]
memtable = {}
for k, v in writes:
    memtable[k] = v
print(len(memtable))
What does this print?
levels_before = [[("k1", 1)], [("k1", 2), ("k2", 3)]]
merged = {}
for sst in levels_before:
    for k, v in sst:
        merged[k] = v
print(sorted(merged.items()))

🎯 Your turn

Build lsm_report(writes, memtable_limit, compaction_threshold, queries, m, k): run writes through the flush/compaction pipeline, measure write amplification, then probe every key in queries once plainly and once with a Bloom filter per sstable.

  • lsm_report([('k1','v1'),('k2','v2'),('k3','v3'),('k1','v4'),('k4','v5'), ('k5','v6'),('k2','v7'),('k6','v8'),('k7','v9')], 3, 2, ['k7','k1','ghost'], 32, 3) → {'write_amplification': 1.56, 'probes_without_bloom': 5, 'probes_with_bloom': 2}

Hint: run_lsm does the flush/compaction simulation — reuse it rather than re-deriving it. write_amplification is just bytes_written / len(writes), rounded. Sum probe_count and probe_count_bloom across every query for the two totals.

Press ▶ Run, try the Live App chips, then ✓ Check.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upFlush a memtable into a sorted file

Write make_sstable(memtable), turning a dict into a sorted list of (key, value) tuples — exactly what a flush writes to disk. make_sstable({'b': 2, 'a': 1}) -> [('a', 1), ('b', 2)].

sorting
DrillCompact a level into one file

Write merge_sstables(sstables): given a level's sstables OLDEST first, merge them into one sorted list, where a key present in more than one sstable keeps the value from the NEWEST (last) one. merge_sstables([[('a', 1)], [('a', 2), ('b', 3)]]) -> [('a', 2), ('b', 3)].

distributed-structuressorting
BuildThe flush/compaction pipeline

Write run_lsm(writes, memtable_limit, compaction_threshold). Apply each (key, value) write to a memtable dict; once it reaches memtable_limit entries, flush it as a new sstable into levels[0], then cascade: while a level has >= compaction_threshold sstables, merge all of them into one and push it to the next level. Return {'levels': ..., 'bytes_written': ..., 'memtable': ...}, where bytes_written counts every entry written by every flush AND every compaction.

distributed-structures
BossHow many sstables does a read touch?

Write probe_count(key, memtable, levels): 0 if key is in memtable; otherwise scan sstables newest-first, level by level, counting one probe per sstable actually scanned, stopping the moment key is found. probe_count('x', {'x': '1'}, []) -> 0.

distributed-structures
CapstoneLet the Bloom filter skip the scan

Write probe_count_bloom(key, memtable, levels, m, k), like probe_count but before scanning each sstable, build its m-bit Bloom filter (positions, from section 8) over its keys and skip the sstable entirely — no probe charged — when the filter says key is definitely absent. probe_count_bloom('ghost', {}, [[[['k2','v7']]]], 32, 3) -> 0.

probabilistic-structuresbit-manipulation
lsm_report.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
lsm_report(writes, memtable_limit, compaction_threshold, queries, m, k) → dictWrite amplification for the whole write sequence, plus how many sstables each query actually had to probe, with and without a Bloom filter.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.