Write amplification: same-age vs. mixed-age writes
Simulate a modelNeeds Python with NumPy
Problem
Flash cannot overwrite a page in place: it writes the new version elsewhere and later erases whole blocks, first copying out the pages in them that are still live. Those copies are write amplification. How many there are depends on what the garbage collector finds when it picks a block: if data written together also dies together, blocks empty out completely and nothing is copied; if pages of different ages share blocks, every block holds some live data, and the fuller the drive, the more of it there is to copy. That is the mechanism behind "keep your SSD under 80%".
The chapter's lab fills a spare SSD and reads its write counters with smartctl. This run uses a simulator instead (no spare drive): erase blocks of 128 pages, out-of-place writes, a greedy collector. The drive is filled to 50–90% with (a) same-age writes (whole files written and later deleted together) and (b) mixed-age writes (single pages overwritten at random), then overwritten for several drive-writes.
The question. How does write amplification change with fill level for each pattern?
Predict
This tests whether "keep your SSD under 80%" really stands for lifetime clustering.
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
write-amplification-same-age-vs-mixed.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Write amplification: same-age against mixed-age writes. A flash translation layer simulator (not a real drive): erase blocks of 128 pages, out-of-place writes, greedy garbage collection that copies the live pages out of the block with the fewest. The drive is filled to 50-90% of its physical pages with logical data, then overwritten for several drive-writes in two patterns: (a) same-age, whole files written and later deleted together (data that dies together sits together); (b) mixed-age, single pages overwritten at random. Write amplification = flash pages written ÷ host pages written. """ import argparse, json, random from collections import deque p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--blocks", type=int, default=512, help="Erase blocks.") p.add_argument("--pages", type=int, default=128, help="Pages per block.") p.add_argument("--fills", default="0.5,0.7,0.8,0.9", help="Logical fill levels (share of physical pages).") p.add_argument("--drive-writes", type=float, default=4, help="Overwrite volume, in multiples of the logical data.") 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() class FTL: def __init__(s): s.B, s.P = args.blocks, args.pages s.valid = [0] * s.B; s.owner = [[None] * s.P for _ in range(s.B)]; s.where = {} s.free = deque(range(s.B)); s.cur = s.free.popleft(); s.off = 0; s.flash = 0; s.host = 0 def _append(s, lpn): if s.off == s.P: if len(s.free) < 2: s._gc() s.cur = s.free.popleft(); s.off = 0 s.owner[s.cur][s.off] = lpn; s.where[lpn] = (s.cur, s.off); s.valid[s.cur] += 1; s.off += 1; s.flash += 1 def _invalidate(s, lpn): if lpn in s.where: b, o = s.where.pop(lpn); s.owner[b][o] = None; s.valid[b] -= 1 def _gc(s): while len(s.free) < 2: victim = min((b for b in range(s.B) if b != s.cur and b not in s.free), key=lambda b: s.valid[b]) live = [l for l in s.owner[victim] if l is not None] for l in live: s._invalidate(l) s.owner[victim] = [None] * s.P; s.valid[victim] = 0; s.free.append(victim) for l in live: s._append(l) # copy live pages out: the amplification def write(s, lpn): s._invalidate(lpn); s._append(lpn); s.host += 1 def trim(s, lpn): s._invalidate(lpn) rows = [] for fill in map(float, args.fills.split(",")): logical = int(args.blocks * args.pages * fill) for pattern in ("same-age (whole files)", "mixed-age (random pages)"): rng = random.Random(args.seed); f = FTL() file_pages = args.pages * 4; files = deque() for start in range(0, logical, file_pages): # initial fill, file by file pages = list(range(start, min(start + file_pages, logical))); files.append(pages) for l in pages: f.write(l) f.flash = f.host = 0; total = int(args.drive_writes * logical) while f.host < total: if pattern.startswith("same"): old = files.popleft() # the oldest file is deleted whole... for l in old: f.trim(l) for l in old: f.write(l) # ...and a new one written in its place files.append(old) else: f.write(rng.randrange(logical)) rows.append({"fill_pct": fill * 100, "pattern": pattern, "write_amplification": f.flash / f.host}) for r in rows: print(f"fill {r['fill_pct']:3.0f}% {r['pattern']:<26} write amplification {r['write_amplification']:.2f}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"wa": 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 write-amplification-same-age-vs-mixed.py uv run write-amplification-same-age-vs-mixed.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --blocks512 | Erase blocks. |
| --pages128 | Pages per block. |
| --fills0.5, 0.7, 0.8, 0.9 | Logical fill levels (share of physical pages). |
| --drive-writes4 | Overwrite volume, in multiples of the logical data. |
| --seed0 | Random seed. |
Our run
Data table
| Fill (%) | series | Flash pages written ÷ host pages |
|---|---|---|
| 50 | same-age (whole files) | 1 |
| 50 | mixed-age (random pages) | 1.4 |
| 70 | same-age (whole files) | 1 |
| 70 | mixed-age (random pages) | 1.91 |
| 80 | same-age (whole files) | 1 |
| 80 | mixed-age (random pages) | 2.84 |
| 90 | same-age (whole files) | 1 |
| 90 | mixed-age (random pages) | 5.75 |
fill 50% same-age (whole files) write amplification 1.00
fill 50% mixed-age (random pages) write amplification 1.40
fill 70% same-age (whole files) write amplification 1.00
fill 70% mixed-age (random pages) write amplification 1.91
fill 80% same-age (whole files) write amplification 1.00
fill 80% mixed-age (random pages) write amplification 2.84
fill 90% same-age (whole files) write amplification 1.00
fill 90% mixed-age (random pages) write amplification 5.75What we found
Same-age writes caused no amplification at any fill level (1× at 90% full). Mixed-age writes amplified more as the drive filled: 1.4× at 50%, 2.84× at 80% and 5.75× at 90%. The fill level matters only when lifetimes are mixed.
Read it
"Keep your SSD under 80%" is a rule about mixed lifetimes. Free space gives the collector blocks that are mostly dead; data whose pages die together empties blocks on its own and needs no headroom. Log-structured writes, whole-file rewrites and separating hot from cold data lower amplification at any fill level.