diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml
index 37d2da7..e940bb2 100644
--- a/.github/workflows/book.yml
+++ b/.github/workflows/book.yml
@@ -23,6 +23,22 @@ concurrency:
cancel-in-progress: true
jobs:
+ # The reading guides' depth rules (CLAUDE.md § Reading-guide depth) have a
+ # mechanical part — each step declaring its input and output, a collapsed
+ # answer under every checklist item, a line-number gutter on every quoted
+ # snippet. Across 230 guides those survive only if a script enforces them.
+ # `--all` drops the ratchet the rollout ran behind: every guide is converted,
+ # so a file that does not follow the rules is a new one that skipped them
+ # rather than one the rollout has not reached. Nothing here needs a
+ # toolchain, so it runs first and fast.
+ depth:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Reading guides follow the depth rules
+ run: python3 tools/check-reading-depth.py --check --all
+
build:
runs-on: ubuntu-latest
steps:
diff --git a/.gitignore b/.gitignore
index acbd3c6..5c89f22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ target/
/book/
/mermaid.min.js
/mermaid-init.js
+/.cache/
diff --git a/CLAUDE.md b/CLAUDE.md
index 8c44b8f..4abca0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,23 @@ A self-paced database-internals learning path, rendered as an mdBook (`book.toml
- **Generators are seeded**, so every figure reproduces exactly apart from timings. Lockfiles are committed for the same reason.
- **Exercise lanes must degrade, not crash.** A bench binary on a fresh clone prints its provided lanes and a `[stub — ...]` note for the rest, and exits 0. Never let a `todo!()` panic hide a measurement above it.
+## Reading-guide depth
+
+The `reading-*.md` chapters teach from zero: a reader who knows systems but not the chapter's theory must be able to finish without leaving the page. `topics/00-performance-toolbox/reading-criterion.md` is the reference implementation of these rules; match it.
+
+- **Define every term at first use.** A term of art (t-test, p-value, quartile, IQR, MAD, standard error, null hypothesis, arithmetic intensity, coordinated omission) gets a **bold** name and a one-sentence plain-language definition at the point it first appears, *before* any argument leans on it. A step may use only terms defined in an earlier step or defined on the spot. Borrowed jargon — using a word the guide never defined because the source material used it — is the failure this rule exists to stop.
+- **Every step declares its input and output.** Each `### Step N` opens with a `> **In:** … **Out:** …` blockquote naming the dataset it consumes, *which earlier step produced it*, and what it emits. When one stage forks into two datasets used by different downstream steps, the fork gets its own numbered step. "Is this the same data as the previous section?" must never be left to the reader to infer.
+- **A formula gets its symbols named and one worked example.** Quote it as the source actually computes it, name every symbol, then run it once on 3–5 concrete numbers so a real answer comes out. Arithmetic printed in a guide is verified like any other number in this repo — compute it, don't estimate it.
+- **Anchors are verified against the pinned clone, file *and* line.** Citing the right line of the wrong file is the same error class as inventing a number. Re-grep every anchor before committing; state the version the line numbers belong to.
+- **A quoted snippet carries the line numbers it actually occupies, and names the one to look at.** Put the real number in the gutter of every line, mark elided ranges (`// ... 131–139: bookkeeping ...`) rather than silently closing a gap, and say in the prose which line carries the argument ("the line to focus on is 277, its only `return`"). A snippet anchored to the function signature while quoting code forty lines below it leaves the reader unable to find anything. Pseudocode gets a `// ILLUSTRATION — not quoted from the crate` header and a pointer to the real code.
+- **Describe what the code does, not what the technique usually does.** criterion 0.5.1's `Slope::fit` is a one-field struct fitting through the origin, so the textbook "the intercept absorbs the overhead" account of least squares is simply false there. Read the implementation before writing the explanation, and prefer the honest, weaker claim over the tidy, wrong one.
+- **Every `## Done when` box carries its answer in a collapsed `` block**, introduced by "Answer each before unfolding it." The checklist is a self-test, so the answer must be reachable without leaving the page but never visible by accident. Answers restate the reasoning rather than pointing back at a step number, and are held to the same standard as the body: real anchors, real numbers, the honest claim.
+- **Never trade a definition, a worked example or an answer for brevity.** These chapters have no length target — a guide that assumes vocabulary is not shorter, it is unfinished. Cut redundancy instead.
+
+The mechanical half of these rules is enforced by `python3 tools/check-reading-depth.py` (step In/Out blockquotes, collapsed answers under every `Done when` item, line-number gutters on quoted snippets, the section spine); run it on a guide before committing it. `--check` is a ratchet — a guide that has started following the rules must follow all of them, and the guides the rollout has not reached yet are reported without failing. The other half — definitions, worked arithmetic, honest claims — is judgement and stays the writer's job.
+
+Anchors are checked with `python3 tools/pinned-source.py`, which opens a file at the revision the pin table records (`show`, `grep`, `check`, `list`). It uses a real clone under `~/repos` when one is present and otherwise fetches that same commit into a gitignored `.cache/`, so an anchor can be verified on a machine that has not cloned 85 upstream repos. A repo that is not in the pin table — a crate read from the cargo registry — needs `--ref` and a version stated in the guide.
+
## Topic package shape
Each `topics/NN-name/` contains: `README.md` (study guide, opening with *the problem, measured* — the provided benchmark lane's real output), four to seven `reading-*.md` guides in the concept-first format (framing lead → "the problem in one sentence" → numbered `### Step N` sections → how to read the source → questions → `## Done when` checklist → references), `notes.md` (a `## Baseline (provided lane, , measured )` section recording the real output, *then* the reader's prediction worksheet — leave those cells empty, they are the exercise), and `experiments/` — a Rust crate with **lane 1 implemented and two lanes stubbed**, where the stub tests are the specification and the reference numbers live in `notes.md`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 675f938..c0b47b2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,7 +43,16 @@ Every topic follows the same shape, because the shape is what makes it checkable
format: an H1 with the idea in it, a framing lead, **the problem in one sentence**,
then `### Step N` sections that build each concept using only terms defined in
earlier steps, then how to read the source material with the concepts in hand,
- questions to answer, a "done when" checklist, and references.
+ questions to answer, a "done when" checklist, and references. The depth rules
+ that make "using only terms defined in earlier steps" enforceable — define every
+ term at first use, declare each step's input and output, work every formula on
+ concrete numbers — are in
+ [CLAUDE.md](https://github.com/AviAvni/database-learning-path/blob/master/CLAUDE.md#reading-guide-depth)
+ (an absolute link because `CLAUDE.md` is not a book chapter), with
+ [reading-criterion.md](topics/00-performance-toolbox/reading-criterion.md) as the
+ reference chapter. Their mechanical half is checked by
+ `python3 tools/check-reading-depth.py` — run it on a guide before committing, and
+ see [Building the book](#building-the-book) for the CI gate.
- **`notes.md`** — a `## Baseline (provided lane, , measured )` section
recording the provided lane's real output with the analysis, then a
predictions-vs-measurements worksheet. **The worksheet's cells are meant to be
@@ -80,6 +89,18 @@ legitimate shape for a topic; inventing a number to fill the slot is not.
`file:line` anchors in the guides mean something. Regenerate it with
`python3 tools/pin-table.py` after cloning or updating a reference repo — putting
a SHA in each guide instead would mean thousands of them drifting separately.
+ To read a file at that pinned commit — to write an anchor, or to check one that
+ is already there — use `python3 tools/pinned-source.py`:
+
+ ```bash
+ tools/pinned-source.py show lmdb mdb.c -r 1350:1365 # with real line numbers
+ tools/pinned-source.py grep lmdb 'mdb_env_pick_meta' --path mdb.c
+ tools/pinned-source.py check lmdb mdb.c:1356 --contains 'meta page'
+ ```
+
+ It reads your clone when you have one and otherwise fetches the same commit into
+ a gitignored `.cache/`, so anchors stay checkable without cloning every upstream
+ repo the guides cite.
- **Generators are seeded.** Anyone must be able to reproduce a figure exactly.
- **Notes capture *why* a design wins** and what it trades away — not summaries.
@@ -94,7 +115,14 @@ mdbook serve # or: mdbook build
CI ([.github/workflows/book.yml](.github/workflows/book.yml)) builds HTML and PDF on
every push to `master` and deploys to GitHub Pages. Before committing content, build
locally and check that mermaid diagrams render and internal links resolve — a broken
-link is invisible in markdown and obvious in the book.
+link is invisible in markdown and obvious in the book. The same workflow runs
+`tools/check-reading-depth.py --check`, which holds every reading guide that has
+started following the depth rules to all of them:
+
+```bash
+python3 tools/check-reading-depth.py topics/03-btree-internals/ # one topic
+python3 tools/check-reading-depth.py --stats # rollout progress
+```
A second workflow ([verify.yml](.github/workflows/verify.yml)) runs
`./verify.sh --summary` and a `-D warnings` build of all 45 crates on every push and
diff --git a/FINDINGS.md b/FINDINGS.md
index 473bcde..f076dcc 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -28,7 +28,7 @@ instead.
| 9 | [Concurrency](topics/09-concurrency/README.md) | A global mutex gets **2.9× slower** from 1 to 16 threads (8.65 → 2.96 Mops/s). Padding "independent" counters to 128 B is worth **17.8×**; 64 B only half-fixes it on M-series. | `./verify.sh 09` |
| 11 | [Execution Models](topics/11-execution-models/README.md) | Volcano tops out at **103 M rows/s**, and gets *slower* as selectivity rises (74.7 M at 95%) — surviving the filter is what costs, not the filter. | `./verify.sh 11` |
| 12 | [Columnar Analytics](topics/12-columnar-analytics/README.md) | The scan floor is **24–57 GB/s** on a 150 GB/s machine. This lane previously printed **19,047,619 GB/s** — a hoisted loop, caught by its own implausibility. | `./verify.sh 12` |
-| 13 | [Graph Engines](topics/13-graph-engines/README.md) | The same two-hop query is **101× slower** from supernodes than from random nodes (4.9 µs → 495 µs) — and reaches *fewer* distinct nodes. | `./verify.sh 13` |
+| 13 | [Graph Engines](topics/13-graph-engines/README.md) | The same two-hop query is **101× slower** from supernodes than from random nodes (4.9 µs → 495 µs), because it reaches **77× more** nodes per query — 78,907 against 1022. | `./verify.sh 13` |
| 14 | [Vector Search](topics/14-vector-search/README.md) | Brute force: **117 QPS** at recall 1.000. That single point is what every ANN index is betting against. | `./verify.sh 14` |
| 15 | [Replication & Consensus](topics/15-replication-consensus/README.md) | Follower fsync policy alone spans **59×** (341 → 20,174 entries/s). Batching fixes the median and leaves the p99 at 2980 µs. | `./verify.sh 15` |
| 16 | [Testing & Correctness](topics/16-testing-correctness/README.md) | Seeded crash testing catches planted bugs at **48.8% to 99.6%** per seed — same harness, four wildly different odds of ever finding out. | `./verify.sh 16` |
@@ -38,7 +38,7 @@ instead.
| 20 | [GraphBLAS](topics/20-graphblas/README.md) | SpMV bandwidth decays **20.7 → 12.3 GB/s** as the graph grows. Hypersparse indexing is **50× smaller** (80.4 MB → 1.59 MB) and sweeps rows **175× faster**. | `./verify.sh 20` |
| 21 | [Formal Methods](topics/21-formal/README.md) | The hand-ordered rewriter answers `(a*2)/2` with `(a << 1) / 2` and stops. One locally-excellent rewrite destroys the cancellation — the phase-ordering trap, in four lines. | `./verify.sh 21` |
| 22 | [Standard Benchmarks](topics/22-benchmarks/README.md) | TPC-H Q1 and Q6 measured at **5.2–5.7** and **9.0–14.4 GB/s** effective; YCSB-E's p999 is **12.9 µs** against read-only's 4.0 µs. | `./verify.sh 22` |
-| 23 | [Full-Text Search](topics/23-fulltext/README.md) | Exhaustive BM25 spans **0.009 ms to 10.378 ms** across four two-term queries — 272,310 postings against 159. Term rarity, not query complexity. | `./verify.sh 23` |
+| 23 | [Full-Text Search](topics/23-fulltext/README.md) | Exhaustive BM25 spans **0.009 ms to 10.378 ms** across four queries — 272,310 postings against 159. Term rarity, not query complexity. | `./verify.sh 23` |
| 24 | [Graph Algorithms](topics/24-graph-algorithms/README.md) | Same node and edge count, RMAT vs uniform: **15.6 M triangles vs 5428**, and 447 ms vs 195 ms. Degree skew is the workload. | `./verify.sh 24` |
| 25 | [Graph ML](topics/25-graph-ml/README.md) | The message-passing kernel *is* an SpMM: **4.31 ms at 16.82 GFLOP/s**, against 5.65 ms for the dense transform beside it. | `./verify.sh 25` |
| 26 | [Probabilistic Structures](topics/26-probabilistic/README.md) | A point miss costs **246 ns** (binary search) or **299 ns** (BTreeMap); a 224 MB HashSet does it in **28 ns**. That gap is what a filter is bidding for. | `./verify.sh 26` |
@@ -47,7 +47,7 @@ instead.
| 29 | [Distributed Transactions](topics/29-distributed-txn/README.md) | The workload's own conflict rate goes **0.3% → 99.6%** as Zipf θ moves 0.5 → 1.3. Contention is a property of the data, before any protocol. | `./verify.sh 29` |
| 30 | [Time-Series](topics/30-timeseries/README.md) | delta+varint gives **11.00 B/sample for all four shapes** — a constant series compresses exactly as well as random noise, because only the timestamp is being compressed. | `./verify.sh 30` |
| 31 | [CRDTs](topics/31-crdts/README.md) | Last-write-wins on 10 keys with per-write sync loses **94.98%** of writes — 37,991 of 40,000 acknowledged writes that no replica remembers. | `./verify.sh 31` |
-| 32 | [HTAP](topics/32-htap/README.md) | One copy, one coarse lock: adding full scans takes writes from **10.5 M per 2 s to 94**, and p99 from 334 ns to **2.7 s**. Every scan is a write outage. | `./verify.sh 32` |
+| 32 | [HTAP](topics/32-htap/README.md) | One copy, one coarse lock: adding full scans takes writes from **11.4 M per 2 s to 69**, and p99 from 333 ns to **7.49 s**. Every scan is a write outage. | `./verify.sh 32` |
| 33 | [Temporal Graphs](topics/33-temporal-graphs/README.md) | Static reachability reports 25,031 reachable pairs where time-respecting paths number **137** — **99.5% false positives** on the sparse contact graph. | `./verify.sh 33` |
| 34 | [Debugging & Diagnosis](topics/34-debugging/README.md) | A closed-loop benchmark reports **p99 = 1.0 µs** where an open-loop one reports **90 ms** on identical work — coordinated omission, a 90,000× lie. | `./verify.sh 34` |
| 35 | [Overload Control](topics/35-overload/README.md) | A 10-second outage ends at t=40 s. At 140 QPS (of 300 capacity) goodput stays at **zero until t=161 s**; at 280 QPS it **never recovers** — the outage outlives its own trigger. | `./verify.sh 35` |
@@ -57,7 +57,7 @@ instead.
| 39 | [Fraud & Identity Graphs](topics/39-fraud-identity-graphs/README.md) | Two row-based rankers fail in *opposite* regimes: degree ranking scores **0.00** precision without camouflage, obscurity ranking **0.00** with it. | `./verify.sh 39` |
| 40 | [Security & Attack Graphs](topics/40-security-attack-graphs/README.md) | A directory reporting **8 privileged accounts, forever** has **1969 of 2000 users** holding a path to Domain Admin — and your exposure number depends on how long the collector ran. | `./verify.sh 40` |
| 41 | [On-Chain Analytics](topics/41-onchain-analytics/README.md) | The industry-default haircut rule marks **98% of addresses** tainted from one theft; 658 of them are under 0.1% tainted. An 1816 court case does better. | `./verify.sh 41` |
-| 42 | [Recommendations & Social](topics/42-recommendations-social/README.md) | Recommending bestsellers to everyone gets **35.3% hit-rate@50** with **92.2% overlap** between users' lists. Popularity is not a weak baseline. | `./verify.sh 42` |
+| 42 | [Recommendations & Social](topics/42-recommendations-social/README.md) | Recommending bestsellers to everyone gets **34.0% hit-rate@50** with **92.3% overlap** with the global bestseller list. Popularity is not a weak baseline. | `./verify.sh 42` |
| 43 | [Ops Dependency Graphs](topics/43-ops-dependency-graphs/README.md) | One gray failure: **34 of 55 services alert** and the broken one is not among them — it ranks 35th by failure count, 41st by error rate, at exactly the baseline. | `./verify.sh 43` |
## How to read this table
diff --git a/PLAN.md b/PLAN.md
index 60458c6..97a1dbf 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -109,7 +109,7 @@ flowchart TD
- **Concepts:** memtable→SST lifecycle, leveled vs tiered vs FIFO compaction, bloom filters (and Monkey's optimal allocation), fractional cascading, compaction debt/write stalls, SST formats & block cache.
- **Read code:** fjall (read it ALL — it's small), RocksDB `db/compaction/`, `table/block_based/`.
-- **Papers:** "Monkey: Optimal Navigable Key-Value Store" (SIGMOD'17), "Dostoevsky" (SIGMOD'18), RocksDB paper (TODS'21), "Constructing and Analyzing the LSM Compaction Design Space" (VLDB'21).
+- **Papers:** "Monkey: Optimal Navigable Key-Value Store" (SIGMOD'17), "Dostoevsky" (SIGMOD'18), RocksDB paper (FAST'21, also ACM Transactions on Storage 17(4)), "Constructing and Analyzing the LSM Compaction Design Space" (VLDB'21).
- **Build & bench:** implement a mini-LSM (memtable + SSTs + leveled compaction + bloom filters) — optionally follow skyzh/mini-lsm course; measure write amp with different compaction strategies.
- **Capstone M4:** LSM-backed alternative persistence (graph snapshots as SSTs); benchmark B+tree vs LSM backends on graph mutation + bulk-load workloads.
@@ -237,7 +237,7 @@ flowchart TD
**Why:** The last 10x on a single core. Touched in topic 11 — this is the dedicated deep dive: writing kernels that saturate the CPU.
-- **Concepts:** SIMD fundamentals (AVX2/AVX-512 vs ARM NEON/SVE — know both, you're on ARM), autovectorization and why it fails, Rust portable SIMD (`std::simd`) vs intrinsics, branchless selection (masks + compress), SIMD hash probing (SwissTable), SIMD string parsing/comparison, bit-packed decoding at SIMD speed (FastLanes), gather/scatter costs, instruction-level parallelism & dependency chains, Mojo's SIMD-first design (`SIMD[type, width]` as a first-class parametric type — compare its ergonomics vs `std::simd` and intrinsics).
+- **Concepts:** SIMD fundamentals (AVX2/AVX-512 vs ARM NEON/SVE — know both, you're on ARM), autovectorization and why it fails, Rust portable SIMD (`std::simd`) vs intrinsics, branchless selection (masks + compress), SIMD hash probing (SwissTable), SIMD string parsing/comparison, bit-packed decoding at SIMD speed (FastLanes), gather/scatter costs, instruction-level parallelism & dependency chains, Mojo's SIMD-first design (`SIMD[dtype, size]` as a first-class parametric type — compare its ergonomics vs `std::simd` and intrinsics).
- **Read code:** polars `crates/polars-compute/` kernels, simdjson (the masterclass — read with the paper), hashbrown SIMD group probing, DuckDB compressed-scan kernels, usearch/SimSIMD distance functions, memchr crate, Mojo stdlib + Modular's matmul optimization blog series.
- **Papers:** "Rethinking SIMD Vectorization for In-Memory Databases" (SIGMOD'15), "Parsing Gigabytes of JSON per Second" (simdjson, VLDB'19), "The FastLanes Compression Layout" (VLDB'23).
- **Build & bench:** write filter-selection and dot-product kernels four ways: naive scalar, autovectorized, `std::simd`, NEON intrinsics; bench with `perf stat` (IPC, vector-lane utilization); then SIMD-ize a bit-packing decoder and compare against topic 12's scalar version; port one kernel to Mojo and compare both the numbers and the code you had to write.
diff --git a/SESSION-LOG.md b/SESSION-LOG.md
index 9d7a6cc..e307467 100644
--- a/SESSION-LOG.md
+++ b/SESSION-LOG.md
@@ -9,6 +9,131 @@ Every performance figure quoted below is reproducible with `./verify.sh` (see
[README.md](README.md)); timings depend on hardware, everything else is seeded.
+## 2026-08-08 — topics 34–43 — the last 40 guides, and the rules turned on for the whole book
+
+**Final batch of the rollout: topics 34 through 43, 40 guides, 9,163 lines of prose becoming 14,975.** With these the ratchet reads **230 of 230**, and `.github/workflows/book.yml`'s depth job is switched from `--check` (started files only) to `--check --all`, so a new guide that skips the rules now fails CI rather than being quietly exempt. Same contract and method as batches 1–3: one agent per topic on disjoint directories, `tools/check-reading-depth.py` printing *N/N* as the gate, two or three anchors per topic re-verified against the pin afterwards. Every spot-check confirmed the agent.
+
+**Three claims were the exact reverse of the source.** Topic 43's gray-failure guide sent readers to "§2 for the model, §1 and §3 for the examples"; §2 is the four case studies and §3 is the model (3.1 Terminology, 3.2 Differential observability, 3.3 Temporal evolution). The same guide collapsed §3.1's **observer** and **reactor** into one entity — the observer gathers information, the reactor acts on it, and the gap between them is the whole paper. Topic 40's bloodhound guide used `Contains` as its example of an edge kind *excluded* from pathfinding; at pin `1968388` it is element 60 of `PathfindingRelationships` (`ad.go:1161`), so the example is now `GetChanges`/`GetChangesAll`, which are in `Relationships` and `ACLRelationships` but genuinely not in pathfinding — because only their conjunction is dangerous, and post-processing synthesizes it into `DCSync`.
+
+**Two control laws were named after the wrong algorithm.** CockroachDB's KV slot adjuster is **AIAD**, not AIMD: `total--` at `kv_slot_adjuster.go:72`, `total++` at `:91`, and the code's own comments say "additive decrease" and "additive increase". Topic 35 carries genuine AIMD as well — DAGOR's Algorithm 1, (1−α)·N down and +β·N up, §4.2.3 — which is precisely why the distinction had to be right. And topic 43's Pivot Tracing 600 → 6 tuples/s headline belongs to **process-level (intermediate) aggregation** of *emitted* tuples (§4), not to the Table 3 rewrites, which reduce tuples *packed into the baggage*; four places in that topic's README and two in its notes credited the wrong optimization, and one exercise went further and called it tuples crossing the join.
+
+**Three quotations did not survive being checked against the paper.** Topic 35's README quoted Bronson et al. as saying metastable failures "account for many of the largest outages at major web companies" — §1 says they "have caused widespread outages at large internet companies, lasting from minutes to hours". Topic 39's Fellegi-Sunter guide had the decision rule as "at or above T_μ → match, at or below T_λ → nonmatch", which moves both thresholds into the automatic decisions; Eq. 2 is strict at both ends, and the clerical-review band **includes** its boundaries. Its controller definition dropped "(or in exceptional cases multiple entities)", which is exactly the case where Heuristic 1 over-merges, and its safety argument said "private key" where the paper says "private **signing** key" — signing is what a multi-input spend requires and therefore what makes the heuristic sound.
+
+**Magnitudes.** WeChat's Chinese New Year peak is ≈10× the daily **average**, not 10× the daily peak (§2.3 gives both: peak hours ≈3× average). SLEUTH's 250 bytes and 3 KB per edge belong to **STINGER and NetworkX**, the two main-memory-optimized stores, not to "a general graph database" — Neo4J and Titan are dismissed with no figure at all, so SLEUTH's 10 bytes/edge is 25× better than the *best* main-memory store, which is the stronger claim. Pixie's Algorithm 1 is **eleven** lines, not twenty. BloodHound's `PathfindingRelationships` holds **64** kinds, not 63 (`Relationships` 88, `ACLRelationships` 30, `PostProcessedRelationships` 31, 104 `graph.StringKind` constants in total). And topic 43's dapper guide asserted "113 dependency edges" twice, sourced from nothing — the README says 152 configured, and the claim the exercise rests on is edge recall = 1.000, which is count-agnostic.
+
+**Two papers contradict themselves, and the guides now say so rather than picking a side silently.** Pixie prints `C = max_{p∈P}|E(p)|` in Eq. 1, which makes step allocation *linear* in degree, and then claims sub-linearity in the next sentence; sub-linearity needs `C = max_p log|E(p)|`, which is what this repo's crate implements. The guide states both and works the example that settles it — degrees 1 and 10,000 with a 10,000-step budget give the low-degree pin 0 steps under the printed definition and ~5 under the intended one. Pixie also cites "lines 12-15 of Algorithm 2" where its printed algorithm has lines 9-14; the guide cites the printed lines and explains the discrepancy.
+
+**Anchors that had drifted.** DataFusion retired `EnforceDistribution` into `EnsureRequirements` — the old top-level `physical-optimizer/src/enforce_distribution.rs` path does not resolve at the pin, the struct is at `ensure_requirements/mod.rs:166` (`:159` is inside its doc comment), and the hash `RepartitionExec` goes in at `enforce_distribution.rs:1291` behind the `should_add_hash_repartition` guard at `:1281`. Four more repartition anchors pointed at doc comments rather than code: `new_hash_partitioner` is `:679` not `:667`, `new_round_robin_partitioner` is `:710` not `:699`, and the strength-reduced modulo is `partition_reducer.partition_indices` at `:862` — `:675` is prose. `REPARTITION_RANDOM_STATE` is `SeededRandomState::with_seed(0)` (`mod.rs:592`), not a plain `RandomState`, which is the difference between a reproducible partitioning and an irreproducible one. Splink's `expectation_maximisation.py:18` is an *import* line, not the E-step (`:268`); `PostgresDialect` is `dialects.py:573` and the cited `:674` is past the end of a 672-line file; `graph_operations/connected_components.py` does not exist. Six GraphRAG-SDK paths were missing a directory component, and `vector_store.py:485` is `fulltext_search` — the *query* side — where the guide claimed index creation, which is `:133`. Topic 43's three exercise stubs were missing the crate's `src/` component.
+
+**Numbers corrected in files outside the guides.** `FINDINGS.md` row 42 read "35.3% hit-rate@50 with 92.2% overlap between users' lists"; no run in this repo produced 35.3/92.2 — README's lane-1 table, notes.md and this log all say **0.340** and **0.923** — and the ~0.92 figure is `popularity_overlap`, the overlap with the *global bestseller list*, not between users (that measure is `personalization`, 0.155). Corrected rather than annotated, on the same grounds as rows 23 and 32: it is not a stale measurement, it is a row that matches nothing. Topic 42's README attributed the "classical random walks low degree nodes contribute less signal" quote to §3.1; it is §1. Topic 38's 281-minute indexing figure is for the **Podcast** dataset at a **600-token** chunk window — the 8k window is generation-side — and its notes cited `/tmp/*.pdf` as provenance. Topic 34's `PERF_TIMER_GUARD` row and topic 36's fabricated Twitter α=1.7 were fixed in batch 4's earlier commits.
+
+**Arithmetic worked rather than asserted**, per rule 3: DAGOR's hidden capacity 300/(1+retries) = 150 QPS with the retry threshold at 280 QPS, 0.5^k shedding, and 30 s × 280 QPS = 8,400 queued; Fellegi-Sunter's per-field weights giving 35.93 bits for the last/first/dob-agree, city-disagree, phone-agree pattern against 45.36 all-agree; the haircut's 10× per-hop dilution reaching 0.1% in three hops, which is where topic 41's 658 sub-0.1% UTXOs come from; BlockSci's 8 B × 1.198e9 = 9.58 GB, i.e. 50.09 → 40.50 GB; Sherlock's (2r)^k = ~80,000 states for r=200, k=2 against 3^200 ≈ 10^95; and dapper's 40000/1024 = 39 traces with a rare path at ≈0.001.
+
+**Two provenance gaps are disclosed rather than papered over.** Graefe's SIGMOD-1990 Volcano paper is unreachable (ACM 403s; every mirror carries the TKDE-1994 edition, which has no §5 micro-benchmark table), so topic 37's 25.73 µs/record and packet-size sweep are kept on the strength of a prior session's verification, with every Volcano *concept* re-checked against TKDE and a note asking someone with ACM access to spot-check. Ammann/Wijesekera/Kaushik CCS'02 is not open-access anywhere reachable, so topic 40's 5948/68364, 229 bits and `O(|A|^2·|E|)` are cross-corroborated against MulVAL CCS'06 §2 rather than re-read at source; no new Ammann number was introduced.
+
+**One rendering bug found by the final `mdbook build`, and it was corpus-wide.** `Answer
` opens a CommonMark HTML block that runs to the next **blank line**, so an answer starting on the very next line is raw HTML: backticks stay literal, and any bare `<...>` becomes a tag. `Vec` in topic 23's roaring guide opened a `` element that swallowed the ` ` after it. 118 blocks across 21 files were missing the blank line after `` and 58 more files were missing the one before ` `; both are normalised to the reference chapter's shape. Four bare angle brackets in prose took backticks (`Vec`, and `C=A*B` / `C=A*B` in topic 20's GraphBLAS quotation, where `` was being read as the start of ` A["avg_times[] = times[i] / iters[i]
(mod.rs:124–129)"]
+ S --> D["data = (iters, times) pairs
(mod.rs:140)"]
+ A --> T["Step 6 · tukey::classify
outlier labels"]
+ A --> E["Step 7 · estimates()
mean · median · std dev · MAD"]
+ A --> C["Step 9 · compare.rs
t-test vs baseline"]
+ D --> R["Step 5 · regression()
slope = ns/iteration"]
+ R --> H["headline: time: [lo mid hi]"]
+```
+
+Note the asymmetry, because it surprises everyone: the **headline number you
+read comes from `data`** (the slope), while the **regression detection that
+tells you it got slower runs on `avg_times`** (the mean). Two different
+statistics of the same run. Step 9 comes back to this.
+
+Why it matters: "which numbers is this step actually looking at?" is the
+question that makes the rest of the pipeline legible. Answer it once, here.
+
+### Step 5 — the slope is the per-iteration cost
-### Step 4 — linear regression: the slope is the per-iteration cost
+> **In:** `data`, the `(iters, times)` pairs from Step 4 — *not* `avg_times`.
+> **Out:** one number: ns per iteration, plus (via Step 7) its interval.
-Every sample's total time is really `total_time = overhead + cost × iters`:
+Every sample's total time is really `total_time ≈ overhead + cost × iters`:
a fixed per-sample overhead (reading the clock, loop setup) plus the true
-per-iteration cost times the batch size. Plot the 100 samples as points and
-fit a straight line through them — the **slope** of that line is the
-per-iteration cost, and the fixed overhead lands in the **intercept**, where
-it can't contaminate the answer:
-
-```
-total_time why slope beats mean-of-averages:
- │ ×
- │ × slope = ns per iteration ← the answer
- │ ×
- │ ×
- │ ×
- ├─────────────────────────── iters
- ╵← intercept = fixed per-sample overhead
- (mean of averages absorbs it; the slope ignores it)
-```
-
-Compare the naive alternative: averaging the `avg_times` from Step 3 spreads
-that fixed overhead across every sample and *inflates* the answer — worst for
-the smallest batches. In the code this is `regression()`
-(`analysis/mod.rs:269`); it's only valid when `iters` actually varies, so
-criterion checks for linear sampling at line 152. The headline
-`time: [lo mid hi]` criterion prints is built from this slope.
+per-iteration cost times the batch size. **Linear regression** means fitting
+a straight line to those points; the **slope** of the line — how much `y`
+grows per unit of `x` — is the per-iteration cost.
+
+**Least squares** picks the line that minimises the sum of squared vertical
+distances from the points to it. Criterion's entire regression is four lines —
+**focus on 27**, which is the estimate:
+
+```rust
+// stats/bivariate/regression.rs:20–28 (21–22 unpack xs/ys; 23 and 26 blank)
+20 pub fn fit(data: &Data<'_, A, A>) -> Slope {
+24 let xy = crate::stats::dot(xs, ys); // Σ xᵢyᵢ
+25 let x2 = crate::stats::dot(xs, xs); // Σ xᵢ²
+27 Slope(xy / x2) // m = Σxᵢyᵢ / Σxᵢ² ← the whole fit
+28 }
+```
+
+Read the type first: `struct Slope(pub A)` — **one** field. This is a fit
+of `y = m·x`, a line **forced through the origin**. There is no intercept
+term, so criterion is not estimating the per-sample overhead and setting it
+aside; it is assuming it away. What saves the estimate is the shape of
+`m = Σxᵢyᵢ / Σxᵢ²`: each point's influence is weighted by `xᵢ²`, so the
+biggest batches dominate — and the biggest batches are exactly the ones where
+a fixed overhead is proportionally smallest.
+
+Compare the naive alternative, `mean(avg_times)`, on three points with a true
+cost of 100 ns/iter and a 500 ns per-sample overhead:
+
+```
+iters 10 20 30
+times 1500 2500 3500 ns (= 100·iters + 500)
+avg 150 125 116.67 ns/iter
+
+mean(avg_times) = (150 + 125 + 116.67) / 3 = 130.56 → +30.6%
+slope = (10·1500 + 20·2500 + 30·3500) / (10² + 20² + 30²) = 121.43 → +21.4%
+
+same arithmetic on criterion's real ladder (d = 15, cost 70.1 µs, 1 µs overhead):
+mean(avg_times) = 70,103.46 ns → +0.0049% | slope = 70,101.00 ns → +0.0014%
+```
+
+Neither is exact — that is the honest version of this story, and it follows
+directly from there being no intercept. But the mean is dragged up hardest by
+the *smallest* batch (the 150), where the overhead is a third of the
+measurement, while the slope barely notices it; on the real ladder that leaves
+the slope ~3.5× less biased.
+
+This is why linear sampling matters: the fit is only meaningful when `iters`
+actually varies, so criterion computes it only under linear sampling
+(`analysis/mod.rs:152`). Under flat sampling `estimates.slope` stays `None`
+and the headline silently falls back to the mean — `typical()` is
+`self.slope.as_ref().unwrap_or(&self.mean)` (`estimate.rs:114`).
Why it matters: the slope is a per-iteration estimate that a constant
-measurement tax cannot bias — the mean has no such immunity.
+measurement tax barely moves, because the largest batches outvote the
+smallest ones. The mean has no such defence.
-### Step 5 — outliers: label them, never delete them
+### Step 6 — outliers: label them, never delete them
+
+> **In:** `avg_times` from Step 4.
+> **Out:** the same 100 values, each tagged with a label. Nothing is removed.
An **outlier** is a sample far outside the bulk of the data — usually one of
Step 1's noise events (a preemption, a throttle step) landing inside a batch.
-Criterion classifies them with **Tukey fences** (`tukey::classify`,
-`analysis/mod.rs:141`): compute the quartiles, and flag anything beyond
-1.5× the interquartile range as *mild*, beyond 3× as *severe*. That's the
-"Found N outliers among 100 measurements" line in the output.
-
-The crucial policy: outliers are **labeled and reported, never removed**.
-Deleting the samples you don't like is how benchmarks lie — maybe that "noise"
-is your allocator hitting a slow path every 64th call, i.e. real behavior.
-
-Why it matters: you get told the data is contaminated *and* you get to see
-by how much, instead of the tool silently editing reality.
-
-### Step 6 — bootstrap resampling: a confidence interval with no assumptions
-
-A **confidence interval (CI)** is a range — "95% CI [69.6, 70.5] µs" — meaning
-the procedure that produced it captures the true value 95% of the time.
-Textbook CIs assume the noise is normally distributed (the bell curve);
-latency noise isn't (it's skewed — there's a floor but no ceiling). The
-**bootstrap** sidesteps the assumption entirely: pretend your 100 samples
-*are* the population, resample 100 values from them **with replacement**
-(the same sample may be drawn twice, others not at all), recompute the
-statistic, and repeat 100,000 times. The spread of those 100,000 recomputed
-statistics is an empirical distribution *of the statistic itself* — read
-the CI straight off its percentiles:
+To say "far outside" precisely you need three definitions:
+
+- A **percentile** is the value below which a given share of the sorted data
+ falls; the **median** is the 50th percentile.
+- The **quartiles** are the 25th and 75th percentiles, written **q1** and
+ **q3**. Between them sits the middle half of the data.
+- The **interquartile range (IQR)** is `q3 − q1` — the width of that middle
+ half, and a measure of spread that a few wild values cannot inflate.
+
+**Tukey's method** (`stats/univariate/outliers/tukey.rs:254`) builds four
+**fences** from those, and labels each point by which fences it falls outside:
+
+```
+inner fences: q1 − 1.5·IQR and q3 + 1.5·IQR outside → mild outlier
+outer fences: q1 − 3·IQR and q3 + 3·IQR outside → severe outlier
+```
+
+Worked on nine sorted `avg_times` in µs (criterion interpolates percentiles,
+but with 9 points the quartiles land exactly on data points):
+
+```
+sample 69.6 69.8 69.9 70.0 70.1 70.2 70.4 70.6 78.3
+ q1 median q3
+q1 = 69.9 q3 = 70.4 IQR = 0.5
+
+inner fences 69.15 .......................... 71.15
+outer fences 68.40 .......................... 71.90
+ 78.3 → SEVERE (high)
+```
+
+That is the "Found N outliers among 100 measurements" line in the output
+(`report.rs:463`).
+
+The crucial policy: outliers are **labeled and reported, never removed**. The
+classified sample flows onward with every point intact. Deleting the samples
+you don't like is how benchmarks lie — maybe that "noise" is your allocator
+hitting a slow path every 64th call, i.e. real behavior.
+
+Why it matters: you get told the data is contaminated *and* by how much,
+instead of the tool silently editing reality.
+
+### Step 7 — bootstrap resampling: a confidence interval with no assumptions
+
+> **In:** `avg_times` (Step 4) for the mean/median/spread estimates; `data`
+> (Step 4) for the slope's interval.
+> **Out:** for each statistic, a whole distribution of plausible values — from
+> which the printed `[lo mid hi]` brackets are read.
+
+Vocabulary first, because five terms arrive at once:
+
+- The **population** is what you wish you could measure: every run your
+ function could ever have. Your 100 samples are a **sample** from it.
+- A **statistic** is any number computed from a sample — mean, median, slope.
+- A **point estimate** is that single computed number; it says nothing about
+ how much it would have wobbled had you run again.
+- The **sampling distribution** is the spread you *would* see in that statistic
+ across many repeat runs. Getting at it is the whole game.
+- A **confidence interval (CI)** at **confidence level** 95% is a range
+ produced by a procedure that captures the true value 95% of the time.
+
+Textbook CIs get there by assuming the noise is normally distributed (the bell
+curve). Latency noise isn't: it is skewed, because there is a floor on how
+fast code can run but no ceiling on how slow. The **bootstrap** sidesteps the
+assumption entirely — pretend your 100 samples *are* the population, and
+simulate the repeat runs by drawing from them:
```rust
-fn bootstrap_ci(sample: &[f64], nresamples: usize) -> (f64, f64) {
- let n = sample.len();
- let mut stats = Vec::with_capacity(nresamples);
- for _ in 0..nresamples { // 100_000 in criterion
- // resample WITH replacement, same size — pretend the sample IS the population
- let stat = mean((0..n).map(|_| sample[rand_below(n)]));
- stats.push(stat); // distribution OF THE STATISTIC
- }
- stats.sort_by(|a, b| a.partial_cmp(b).unwrap());
- (percentile(&stats, 2.5), percentile(&stats, 97.5)) // CI = its percentiles —
-} // no normality assumed
-```
-
-Criterion bootstraps *everything*: `estimates()` (`analysis/mod.rs:300`)
-bootstraps the mean/median/std-dev/MAD of the per-iteration averages
-(line 321), and the headline `time: [lo mid hi]` is the **slope's** bootstrap
-CI — resample the (iters, times) points, refit the line each time.
+// ILLUSTRATION — not quoted from the crate; criterion's real loop is
+// stats/univariate/resamples.rs:37–41, wrapped by Sample::bootstrap
+let n = sample.len();
+for _ in 0..nresamples { // 100_000 in criterion
+ // resample WITH REPLACEMENT, same size — a value may be drawn twice and
+ // others not at all; that variation IS the simulated re-run
+ stats.push(mean((0..n).map(|_| sample[rand_below(n)])));
+} // stats = distribution OF THE STATISTIC
+stats.sort_by(|a, b| a.partial_cmp(b).unwrap());
+(percentile(&stats, 2.5), percentile(&stats, 97.5)) // 95% CI = its own percentiles;
+ // no normality assumed
+```
+
+Criterion bootstraps *everything*. `estimates()` (`analysis/mod.rs:300`)
+resamples `avg_times` 100,000 times and recomputes four statistics each time
+(line 321):
+
+- the **mean** and the **standard deviation** (the square root of the average
+ squared distance from the mean — the everyday measure of spread);
+- the **median** and the **median absolute deviation (MAD)** — the median of
+ each point's distance from the median, scaled by 1.4826 so that on normal
+ data it lands on the same scale as the standard deviation
+ (`stats/univariate/sample.rs:64`). Median and MAD are the outlier-resistant
+ pair; mean and std dev are not.
+
+Each of those four gets its own bootstrap distribution, and hence its own CI.
+The **standard error** criterion also reports is just the standard deviation
+*of the bootstrap distribution* — how much the statistic itself moves around
+(`analysis/mod.rs:283`).
+
+The slope gets the same treatment with one difference: `regression()`
+(`analysis/mod.rs:269`) resamples the `(iters, times)` **pairs together**
+— index `i` drags both coordinates along (`stats/bivariate/resamples.rs:36–41`)
+— and refits the line on each resample. The headline `time: [lo mid hi]` is
+that slope distribution's 2.5th, point, and 97.5th values.
Why it matters: this is the engine under every bracketed range criterion
prints, and it works on ugly, skewed, real-world timing data.
-### Step 7 — why a CI beats taking the minimum
+### Step 8 — why a CI beats taking the minimum
+
+> **In:** Step 7's interval versus the rival proposal.
+> **Out:** the reason this chapter exists.
The rival school (older Python `timeit` advice) says: noise only ever *adds*
time, so report the minimum — it's the closest to the true cost. Criterion
@@ -174,33 +384,148 @@ rejects that, for four reasons:
2. **Noise isn't strictly additive.** Frequency scaling (Step 1) means early
samples can run at a *higher* clock (pre-thermal-throttle) — the min can
be an unrepresentatively lucky sample, and on modern laptops often is.
-3. **Min is statistically fragile for comparison.** It's an extreme-value
- statistic with no usable sampling distribution — you can't compute a
- p-value on "min got 2% slower." Step 8's machinery only works because
- mean/slope have bootstrap distributions.
+3. **Min is statistically fragile for comparison.** It is an **extreme-value
+ statistic** — a statistic determined entirely by one observation, which
+ makes its sampling distribution (Step 7) both wild and dependent on sample
+ size. So you cannot put a number on how surprising "min got 2% slower" is —
+ Step 9 does exactly that, and its machinery only works because mean and
+ slope have well-behaved bootstrap distributions.
4. **A point estimate hides confidence.** `[69.6 70.1 70.5] µs` says the
measurement is tight; a bare `69.6` hides whether the spread was 1% or 40%.
Why it matters: this is the study-guide question, and it's the philosophical
core — a benchmark result without an uncertainty estimate is an anecdote.
-### Step 8 — regression detection: two gates, not one
+### Step 9 — regression detection: two gates, in order
+
+> **In:** this run's `avg_times` (Step 4) **and** the baseline's `avg_times`,
+> recomputed from the saved `sample.json` (`compare.rs:44–49`).
+> **Out:** one of three verdicts — *no change detected*, *within noise
+> threshold*, or *improved/regressed*.
+
+First, the lineage trap from Step 4: the comparison runs on **`avg_times`**,
+i.e. on means. The slope that produced your headline number is not what gets
+compared. A benchmark can print a slope-based time while being judged on its
+mean.
Detecting "did my change make this slower?" needs two separate questions,
because a difference can be statistically real yet too small to care about,
-or large but pure noise. Criterion (line 188 → `compare.rs`) loads the saved
-baseline and applies two gates:
+or large but pure noise.
+
+**Gate 1 — is the difference real?** This is a **t-test**: given how much
+each of two sets of numbers scatters internally, is the gap between their
+averages bigger than that scatter can explain? The **t-statistic** is that
+gap measured in units of its own uncertainty (`sample.rs:171`):
+
+```
+t = (x̄ − ȳ) / √(s²ₓ/nₓ + s²ᵧ/nᵧ)
+
+ x̄, ȳ = the two means s² = VARIANCE — the mean squared distance from
+ nₓ, nᵧ = the two counts the mean, with an n−1 divisor (sample.rs:187)
+```
+
+Worked on three new samples against three baseline samples, in µs:
+
+```
+new = [70.1, 70.4, 69.8] x̄ = 70.1000 s²ₓ = 0.090000
+base = [69.2, 69.5, 69.1] ȳ = 69.2667 s²ᵧ = 0.043333
+
+numerator = 70.1000 − 69.2667 = 0.8333
+denominator = √(0.090000/3 + 0.043333/3) = √0.044444 = 0.2108
+t = 0.8333 / 0.2108 = 3.95
+```
+
+So the gap is about four times the size of its own uncertainty. Is four a
+lot? A textbook would look that up in a table — which means assuming a
+distribution, exactly what Step 7 refused to do. Criterion bootstraps instead.
+
+The **null hypothesis** is the boring explanation: there is no real
+difference, both sets came from the same population. `mixed::bootstrap`
+(`mixed.rs:11`) *builds* that world and measures it:
+
+```rust
+// stats/univariate/mixed.rs — the pooling at 27–28, then the resample loop.
+// Lines 66–70 are the non-rayon path; 38–42 are the identical rayon path.
+27 c.extend_from_slice(a);
+28 c.extend_from_slice(b); // POOL both — erase which run each value came from
+ // ... 29–65: wrap the pool as a Sample, then rayon/non-rayon dispatch ...
+66 let resample = resamples.next(); // draw n_a + n_b, w/ replacement
+67 let a: &Sample = Sample::new(&resample[..n_a]); // arbitrarily call these "new"
+68 let b: &Sample = Sample::new(&resample[n_a..]); // and these "base"
+70 statistic(a, b) // recompute t
+```
-1. **Is the difference real?** A bootstrapped two-sample t-test
- (`compare.rs`, line 200: `p_value`) — bootstrap the "no difference"
- hypothesis and ask how often chance alone produces a gap this big.
-2. **Is it big enough to care?** A bootstrapped relative-change estimate
- compared against `noise_threshold`.
+Pooling then re-splitting at random is what makes it a null distribution:
+the two halves now differ by chance alone. Running it 100,000 times shows
+exactly how big a `t` chance alone produces.
-Both must pass — e.g. `+3781% (p = 0.00 < 0.05)`: significant *and* large.
+The **p-value** is then just a rank — the share of those 100,000 chance-only
+`t` values that are at least as extreme as the real one
+(`stats/mod.rs:63`):
+
+```rust
+// stats/mod.rs, Distribution::p_value — 68–73 map Tails to 1 or 2
+67 let hits = self.0.iter().filter(|&&x| x < t).count();
+74 A::cast(cmp::min(hits, n - hits)) / A::cast(n) * tails // tails = 2
+```
+
+`min(hits, n − hits)` takes whichever tail the observation sits in, and the
+`× 2` makes it **two-tailed** — criterion asks "different?", not "slower?",
+so a speed-up is as detectable as a regression. A p-value of 0.00 means
+essentially none of the 100,000 chance-only worlds produced a gap this big.
+
+The gate: **`p_value < significance_level`** (default 0.05,
+`analysis/mod.rs:200` computes it, `report.rs:598` tests it). Fail, and
+criterion prints `No change in performance detected.` and stops — gate 2 is
+never consulted.
+
+**Gate 2 — is it big enough to care?** Only reached if gate 1 passed. This
+one ignores t entirely and looks at the bootstrapped **relative change** in
+the mean — `a.mean() / b.mean() - 1.` resampled 100,000 times
+(`compare.rs:108–121`) — and compares its *confidence interval* against the
+**noise threshold**, the relative change below which you have declared you do
+not care (default 0.01, i.e. 1%). From `report.rs:779`:
+
+```rust
+// report.rs:784–790, inside compare_to_threshold (declared at 779;
+// 780–782 pull lb/ub off the confidence interval)
+784 if lb < -noise && ub < -noise { // ENTIRE interval below −1%
+785 ComparisonResult::Improved
+786 } else if lb > noise && ub > noise { // ENTIRE interval above +1%
+787 ComparisonResult::Regressed
+788 } else {
+789 ComparisonResult::NonSignificant // "Change within noise threshold."
+790 }
+```
+
+Note it tests **both bounds**, not the point estimate: an interval straddling
+the threshold is not enough. So the three verdicts, in order:
+
+| Gate 1 (`p < 0.05`) | Gate 2 (whole CI past ±1%) | Printed |
+|---|---|---|
+| fail | not evaluated | `No change in performance detected.` |
+| pass | fail | `Change within noise threshold.` |
+| pass | pass | `Performance has improved.` / `regressed.` |
+
+A line like `+3781% (p = 0.00 < 0.05)` is a run that cleared both.
Why it matters: one gate alone produces either false alarms on every 0.3%
-wobble or silence on real 5% regressions.
+wobble or silence on real 5% regressions — and knowing they are sequential
+tells you which message means which failure.
+
+## The knobs and their defaults
+
+All set in `lib.rs:427–433`, all overridable per-benchmark or on the CLI:
+
+| Knob | Default | Step | What it controls |
+|------|---------|------|------------------|
+| `warm_up_time` | 3 s | 2 | how long the unrecorded calibration loop runs |
+| `sample_size` | 100 | 3 | how many batches (`n` in the `d` formula) |
+| `measurement_time` | 5 s | 3 | the budget the batch ladder is sized to fill |
+| `nresamples` | 100,000 | 7 | bootstrap resamples per statistic |
+| `confidence_level` | 0.95 | 7 | the width of every printed `[lo hi]` bracket |
+| `significance_level` | 0.05 | 9 | gate 1's p-value cutoff |
+| `noise_threshold` | 0.01 | 9 | gate 2's "too small to care" band |
## Where each step lives in the code
@@ -209,46 +534,195 @@ step above maps to a call in it:
```mermaid
flowchart TD
- S["1 · routine.sample() (line 83)
(iters, times) — iters grows [d, 2d, 3d, ...]"]
- S --> N["2 · avg_times[i] = times[i] / iters[i] (124–129)"]
- N --> T["3 · tukey::classify (141)
label outliers — NEVER remove"]
- N --> E["4a · estimates() (300)
mean/median/MAD, each bootstrapped (321)"]
- S --> R["4b · regression() (269)
slope of total_time vs iters = ns/iter"]
- R --> H["headline: time: [lo mid hi] = slope's bootstrap CI"]
- E --> C["5 · compare.rs (188)
bootstrapped t-test vs saved baseline
+ noise_threshold gate"]
- H --> C
+ S["3 · routine.sample() (mod.rs:83)
iters grows d, 2d, 3d, ..."]
+ S --> N["4 · avg_times = times[i] / iters[i] (124–129)"]
+ S --> D["4 · data = pairs (140)"]
+ N --> T["6 · tukey::classify (141)
label outliers — NEVER remove"]
+ N --> E["7 · estimates() (300)
mean/median/std-dev/MAD, bootstrapped (321)"]
+ D --> R["5 · regression() (269)
slope of total_time vs iters = ns/iter"]
+ R --> H["headline: time: lo mid hi = slope's bootstrap CI"]
+ N --> C["9 · compare.rs (188)
gate 1: bootstrapped t-test (compare.rs:72)
p_value (mod.rs:200)"]
+ C --> G["9 · gate 2: noise threshold
(report.rs:779)"]
```
Suggested reading order in the crate:
-1. `analysis/mod.rs::common` — the spine (Steps 3, 4, 5, 6 in sequence)
-2. `stats/univariate/outliers/tukey.rs` — Step 5's fences, ~100 lines
-3. `stats/bivariate/regression.rs` — `Slope::fit` is ~10 lines of
- least-squares (Step 4)
-4. `analysis/compare.rs` + `stats/univariate/mixed.rs` — Step 8's
- bootstrapped t-test
-5. `routine.rs::warm_up` — see that warm-up (Step 2) is really
- iteration-count calibration
+1. `analysis/mod.rs::common` — the spine; watch for the Step 4 fork at 124–140
+2. `stats/bivariate/regression.rs` — `Slope::fit`, three lines; note the
+ one-field struct (Step 5)
+3. `stats/univariate/outliers/tukey.rs` — the fences, with an ASCII diagram in
+ the module docs (Step 6)
+4. `analysis/compare.rs` + `stats/univariate/mixed.rs` — the bootstrapped
+ t-test; `mixed.rs` is where the pooling happens (Step 9)
+5. `routine.rs::warm_up` — confirm that warm-up is really calibration (Step 2)
+
+## Questions to answer
+
+- Why does criterion report a confidence interval rather than a minimum?
+ (Step 8 — the README's question for this chapter.)
+- Your benchmark prints a headline time built from the slope, but the
+ regression verdict is computed from the mean. Construct a sample where
+ those two disagree about the direction of a change. What would the run
+ print?
+- `Slope::fit` has no intercept. What does that assume about the per-sample
+ overhead, and which batch in the ladder is hurt most when it is wrong?
+- Read `Slope::r_squared` (`regression.rs:33`). Line 48 assigns
+ `ss_tot = ss_res + ...` where every other line accumulates. Is that a bug?
+ Trace its callers (`report.rs:708`, `html/mod.rs:373`) and decide whether it
+ can affect a reported *time* or only a plot label. This is the habit the
+ chapter is really teaching: read the implementation, not the textbook
+ description of the technique.
## Takeaway
-Criterion is built on three ideas: **bootstrap instead of normality assumptions, slope
-instead of mean, label outliers instead of dropping them.**
+Criterion is built on three ideas: **bootstrap instead of normality
+assumptions, slope instead of mean, label outliers instead of dropping
+them.** With the caveat you now know: the slope is what it *prints*, the mean
+is what it *compares*.
## Done when
+Answer each before unfolding it.
+
- [ ] You can explain why criterion times *batches* and fits a line, rather than timing one iteration.
-- [ ] You can give the three reasons taking the minimum is the wrong estimator, not just a noisy one.
+
+ Answer
+
+ One iteration is both too short and too noisy: the clock's own resolution is
+ ~20–40 ns, so a short function mostly measures the timer, and any single
+ reading carries whatever the machine was doing at that instant (Step 1).
+ Timing a batch amortises both. The batch sizes then grow *linearly*
+ (`d, 2d, …, 100d`) specifically so that plotting total time against batch
+ size and fitting a line recovers ns-per-iteration as the **slope** — an
+ estimate the largest batches dominate, so a fixed per-sample overhead barely
+ moves it (Steps 3 and 5).
+
+
+
+- [ ] You can name, for each stage of the pipeline, whether it consumes `avg_times` or the raw `(iters, times)` pairs — and say why the headline and the regression verdict come from different ones.
+
+ Answer
+
+ `avg_times` feeds `tukey::classify` (Step 6), `estimates()` (Step 7) and the
+ baseline comparison (Step 9). The raw `(iters, times)` pairs feed
+ `regression()` (Step 5) and nothing else.
+
+ The headline `time: [lo mid hi]` is the **slope's** bootstrap CI, because
+ `typical()` returns the slope when it exists (`estimate.rs:114`). The
+ regression verdict is a t-test on **`avg_times`**, i.e. on means. Same run,
+ two different statistics — and under flat sampling there is no slope at all,
+ so the headline silently falls back to the mean.
+
+
+
+- [ ] You can say what fitting through the origin assumes, and why the slope is still less biased than the mean of the per-iteration averages.
+
+ Answer
+
+ `Slope` is a one-field struct and `fit` returns `Σxᵢyᵢ / Σxᵢ²`, so the model
+ is `y = m·x`: it assumes total time is *exactly* cost × iters, i.e. zero
+ per-sample overhead. When overhead exists the slope is biased upward too —
+ it is not immune, and the chapter's three-point example shows it landing at
+ +21.4%.
+
+ It is less biased because each point's influence is weighted by `xᵢ²`, so the
+ largest batches — where a fixed overhead is proportionally smallest —
+ dominate. `mean(avg_times)` weights every batch equally, so the *smallest*
+ batch, where overhead is proportionally largest, drags it up hardest: +30.6%
+ on the same three points.
+
+
+
+- [ ] You can give the reasons taking the minimum is the wrong estimator, not just a noisy one.
+
+ Answer
+
+ It estimates best-case-ever, a state production code never runs in. Noise is
+ not purely additive — early samples can run at a *higher* pre-throttle clock,
+ so the min can be an unrepresentatively lucky sample. It is an extreme-value
+ statistic determined entirely by one observation, so its sampling
+ distribution is wild and sample-size-dependent, which is why no p-value can
+ be put on a change in it. And a bare point estimate hides whether the spread
+ was 1% or 40%.
+
+
+
- [ ] You can state what a bootstrapped confidence interval assumes about the distribution (nothing) and what it therefore cannot rescue you from.
-- [ ] You can name criterion's two regression gates and say why "statistically significant" alone is not one of them.
+
+ Answer
+
+ It assumes nothing about the *shape* of the noise — that is the point of
+ resampling the observed data instead of consulting a normal-distribution
+ table. What it does assume is that your sample is representative of the
+ population, and it cannot rescue you from a sample that isn't: a
+ systematically throttled machine, a benchmark measuring the wrong thing,
+ coordinated omission, or too few samples. Resampling a biased sample yields
+ a confidently narrow interval around the wrong number.
+
+
+
+- [ ] You can explain how pooling two samples and re-splitting them at random manufactures a null hypothesis, and what the p-value counts.
+
+ Answer
+
+ Concatenating the new and baseline samples erases which run each value came
+ from. Drawing `n_a + n_b` values from that pool with replacement and slicing
+ them back into two groups produces a pair of samples that differ **by chance
+ alone** — exactly the "there is no real difference" world. Recomputing `t`
+ 100,000 times over that world gives the distribution of `t` under the null.
+
+ The p-value is then a rank, not a probability computed from a formula: the
+ share of those chance-only `t` values at least as extreme as the observed
+ one — `min(hits, n − hits) / n × 2`, doubled because the test is two-tailed
+ (criterion asks "different?", not "slower?").
+
+
+
+- [ ] You can name criterion's two regression gates, say which one runs first, and map each of the three printed verdicts to the gate that produced it.
+
+ Answer
+
+ Gate 1 is the bootstrapped t-test: `p_value < significance_level` (0.05).
+ Gate 2 is the bootstrapped relative mean-change CI with **both** bounds past
+ ±`noise_threshold` (0.01). Gate 1 runs first and short-circuits.
+
+ | Gate 1 | Gate 2 | Printed |
+ |---|---|---|
+ | fail | never evaluated | `No change in performance detected.` |
+ | pass | fail | `Change within noise threshold.` |
+ | pass | pass | `Performance has improved.` / `regressed.` |
+
+
+
- [ ] You have run `cargo bench` in `experiments/` and can point at the warm-up, sample and outlier lines in its output.
+ Answer
+
+ Three lines to find, from `report.rs:506`, `:538` and `:463` respectively:
+ `Benchmarking : Warming up for …` (Step 2's calibration loop);
+ `Benchmarking : Collecting 100 samples in estimated …` — the iteration
+ count in that line is `n(n+1)/2 × d` from Step 3; and
+ `Found N outliers among 100 measurements (…%)` followed by the
+ low/high × mild/severe breakdown, which is Step 6's Tukey fences reported,
+ never applied.
+
+
+
## References
-**Code**
-- [criterion.rs](https://github.com/bheisler/criterion.rs)
- `src/analysis/mod.rs` (locally:
- `~/.cargo/registry/src/index.crates.io-*/criterion-0.5.1/src/analysis/mod.rs`,
- 370 lines) — `common()` is the spine; follow the suggested reading
- order above through `tukey.rs`, `regression.rs`, `compare.rs`, and
- `routine.rs::warm_up`
+**Code** — [criterion.rs](https://github.com/bheisler/criterion.rs) **v0.5.1**
+(locally `~/.cargo/registry/src/index.crates.io-*/criterion-0.5.1/src/`). Every
+line number in this chapter is from that version:
+
+| File | Lines | What |
+|------|-------|------|
+| `analysis/mod.rs` | 83, 124–140, 141, 152, 188, 200, 269, 300 | `common()` — sampling, the fork, tukey, linear guard, comparison, p-value, `regression()`, `estimates()` |
+| `routine.rs` | 257, 158 | `warm_up`'s doubling loop; `met` |
+| `lib.rs` | 427–433, 1362–1428 | defaults; sampling mode and the `d` formula |
+| `stats/bivariate/regression.rs` | 20 | `Slope::fit` — least squares through the origin |
+| `stats/univariate/outliers/tukey.rs` | 254 | `classify` and the fences |
+| `stats/univariate/sample.rs` | 64, 171, 187 | MAD, the t-statistic, variance |
+| `stats/univariate/mixed.rs` | 11 | the pooled two-sample bootstrap |
+| `stats/mod.rs` | 63 | `p_value` |
+| `analysis/compare.rs` | 72 | `t_test` |
+| `report.rs` | 463, 598, 779 | outlier line; gate 1's test; gate 2 |
diff --git a/topics/00-performance-toolbox/reading-drepper.md b/topics/00-performance-toolbox/reading-drepper.md
index 5efb981..9a0bf74 100644
--- a/topics/00-performance-toolbox/reading-drepper.md
+++ b/topics/00-performance-toolbox/reading-drepper.md
@@ -2,91 +2,342 @@
Every latency table in topic 0 §2 is a compressed version of one 2007 paper —
Drepper's "What Every Programmer Should Know About Memory". Before you open
-its 114 pages, this chapter builds the eight concepts the paper assumes, one
+its 114 pages, this chapter builds the ten concepts the paper assumes, one
at a time — then hands you a section-by-section reading lens, and finally a
table mapping each concept to the counter and the experiment that identify it
in someone else's program. The DDR2 numbers are stale; the cache-organization
math, the prefetching rules, and the measurement methodology behind
`cache_ladder` are forever.
+**Which numbers belong to which era.** Drepper measured on a Pentium 4, a
+Pentium M and an early Core 2 in 2007. This repo measured on an Apple M3 Pro
+in 2026. Both sets of numbers appear below and they are *never* mixed: a
+figure sourced as **(§x.y)** or **(Fig x.y)** is Drepper's, quoted from
+`cpumemory.pdf` version 1.0 (November 21, 2007) with the section, figure or
+table it came from; a figure sourced as **(notes.md)** or **(FINDINGS row N)**
+was measured by a lane in this repo on the M3 Pro on 2026-07-28. Where the repo
+has measured the same quantity Drepper did, the repo's number is the one the
+argument leans on, and his is kept beside it to show what changed.
+
## The problem in one sentence
-A modern core executes an instruction in ~0.3 ns, but fetching data from
-main memory (DRAM) takes ~80–100 ns — roughly **300 instructions of waiting**
-for one load. Everything in this paper is machinery to hide that gap, and
-every database trick in later topics (columnar layouts, B-tree fanout,
-vectorized execution) is a way of cooperating with that machinery.
+The gap between an L1 hit and a DRAM access is a factor of **102×** on the
+machine this repo runs on — `cache_ladder` measures 1.02 ns at a 16–128 KB
+working set and 104 ns at 128 MB ([notes.md](notes.md)) — so a load that misses
+everything costs about as much as a hundred loads that don't. Everything in
+Drepper's paper is machinery to hide that gap, and every database trick in
+later topics (columnar layouts, B-tree fanout, vectorized execution) is a way
+of cooperating with that machinery.
## The concepts, step by step
### Step 1 — the speed gap, and why caches exist
-Memory got bigger much faster than it got faster. The fix: put small,
-fast memories *between* the core and DRAM, and keep recently-used data there.
+> **In:** nothing yet — this step fixes the ladder every later step measures
+> against, and separates Drepper's 2007 constants from this repo's 2026 ones.
+> **Out:** two latency ladders, nineteen years apart, and the one number that
+> did not change: the *ratio* between the top and the bottom.
+
+Memory got bigger much faster than it got faster. The fix: put small, fast
+memories *between* the core and DRAM, and keep recently-used data there. A
+**cache hit** means the line was found at that level; a **cache miss** means go
+down one level and wait. A **working set** is the set of bytes a program is
+actively touching at a given moment — the quantity every graph in the paper
+plots along its X axis.
+
+Drepper's ladder, quoted exactly as §3.2 prints it (page 16, the unnumbered
+table introduced with "These are the numbers Intel lists for a **Pentium M**"):
+
+```
+Drepper §3.2, page 16 — Intel's published Pentium M figures, 2007:
+
+ To Where Cycles
+ Register ≤ 1
+ L1d ~ 3
+ L2 ~ 14
+ Main Memory ~ 240
+```
+
+This repo's ladder, measured by `cache_ladder` on an Apple M3 Pro
+([notes.md](notes.md), "Experiment 1"):
+
+| Working set | ns/access (measured) | Level, and how we know |
+|------------:|---------------------:|------------------------|
+| 16 KB–128 KB | **1.02** | L1 — the plateau ends exactly at 128 KB, the P-core L1d size |
+| 512 KB–1 MB | **5.3–5.8** | L2 |
+| 4–8 MB | **7.6–9.0** | still L2 — Apple's per-cluster L2 is 16 MB-class |
+| 16 MB | **17.1** | falling out of L2 into the SLC |
+| 32 MB | **59.6** | SLC → DRAM transition |
+| 64 MB | **87.4** | DRAM |
+| 128–512 MB | **104–113** | DRAM plus a growing TLB-miss share (Step 8) |
+
+The **SLC** (system level cache) is Apple's shared last-level cache — the
+structural replacement for the inclusive L3 in Drepper's Fig 3.2, sitting behind
+every cluster's L2 rather than inside the CPU complex. Drepper's machines had no
+level between L2 and DRAM at all, which is why his Fig 3.4 shows three plateaus
+and the table above shows five.
+
+The two ladders are not comparable unit-for-unit — one is cycles on 2007
+hardware, the other is nanoseconds on a much wider core with a cache level
+Drepper's machines did not have. What *is* comparable is the ratio across the
+ladder. Drepper's: 240 ÷ 3 = **80×** from L1d to main memory. This repo's:
+104 ÷ 1.02 = **102×**. Nineteen years and an instruction-set change later, the
+shape is the same and the spread got slightly worse.
+
+That is the sentence to carry into the rest of the paper: **the constants aged;
+the ratio did not.** The whole game is what fraction of your loads hit near the
+top.
+
+### Step 2 — the cache line, and how much of it you actually use
+
+> **In:** the ladder from Step 1, which priced *one access*.
+> **Out:** the unit that access actually moves — a fixed-size line — and a
+> utilization fraction that Steps 4 and 6 both consume.
+
+Caches don't store individual bytes. They store **cache lines** — fixed-size
+blocks, the smallest unit that ever moves between levels. Drepper §3.5.2 states
+the sizes of his era plainly: "the cache line size is 64 or 128 bytes". His
+measured machines all use **64 bytes** (he says so explicitly in §6.2.1: "with
+64 bytes for the Core 2 processor"). Apple M-series uses **128 bytes** — a
+number this repo did not take on faith but measured: topic 9 found that padding
+contended counters to 64 B leaves them **1.8× slower** than padding to 128 B
+([topic 9 notes.md](../09-concurrency/notes.md)), which only happens if the
+coherence granule is 128.
+
+Load one byte and the hardware fetches the whole line it lives in. So the
+question that decides a data layout is not "how many bytes do I read?" but
+"how many bytes did the machine move to give me them?"
+
+**The formula.** For a loop that touches `e` bytes of every element, with
+`s` bytes between the starts of consecutive touched elements (the **stride**),
+on a machine with `L`-byte lines:
```
- size latency what it is
- registers ~1 KB 0 cycles inside the core
- L1 cache ~128 KB ~4 cycles per-core, split data/instruction
- L2 cache ~4-16 MB ~14 cycles per-core or per-cluster
- L3 / SLC ~24-48 MB ~40 cycles shared by all cores
- DRAM GBs ~300 cycles the actual memory
+ n = max(1, floor(L / s)) elements whose bytes land inside one line
+ U = (e × n) / L fraction of each transferred line the loop uses
+
+ e = bytes actually read per element
+ s = stride, bytes between consecutive touched elements
+ L = cache-line size (64 on Drepper's machines, 128 on Apple M-series)
+ n = elements per line
+ U = utilization — 1.0 means nothing was wasted
```
-A "cache hit" = found at that level. A "miss" = go down one level and wait.
-The whole game is: what fraction of your loads hit L1?
+Worked on five concrete cases. The first two are Drepper's own matrix
+multiplication from §6.2.1, which is the whole argument of §6.2 in two lines of
+arithmetic:
-### Step 2 — the cache line: memory moves in fixed-size chunks
+```
+1. §6.2.1 naive inner loop, mul2[k][j], N=1000 doubles, L=64:
+ e = 8 (one double)
+ s = 8 × 1000 = 8000 (the inner loop advances the ROW of mul2)
+ n = max(1, floor(64 / 8000)) = max(1, 0) = 1
+ U = (8 × 1) / 64 = 0.125 → 12.5% used, 87.5% of every line wasted
+
+2. §6.2.1 after transposing mul2 into tmp[j][k], L=64:
+ e = 8, s = 8
+ n = floor(64 / 8) = 8
+ U = (8 × 8) / 64 = 64 / 64 = 1.0 → 100% used
+
+3. Fig 3.11 NPAD=7 — a list whose elements are one line wide, L=64:
+ e = 8 (the `n` pointer), s = 64
+ n = floor(64 / 64) = 1
+ U = 8 / 64 = 0.125 → 12.5%
+
+4. Row layout on M-series: one 8-byte column of a 128-byte row, L=128:
+ e = 8, s = 128, n = 1
+ U = 8 / 128 = 0.0625 → 6.25% used, 93.75% wasted
+
+5. Column layout on M-series: the same column stored contiguously, L=128:
+ e = 8, s = 8
+ n = floor(128 / 8) = 16
+ U = (16 × 8) / 128 = 1.0 → 100% used
+```
+
+Cases 4 and 5 are topic 12 (columnar storage) in six lines of division: the
+same filter, the same 8 bytes of answer, **16× fewer bytes moved**. Cases 1 and
+2 are Drepper measuring the same effect on a Core 2 in 2007 — Table 6.2 records
+the naive multiply at 16,765,297,870 cycles and the transposed one at
+3,922,373,010, which is 23.4% of the original (the paper's own figure; the
+division confirms 23.40%). The transpose *added* a full copy of a 1000×1000
+matrix and still won by 4.3×, because it moved case 1's utilization to case 2's.
+
+Two consequences to carry forward:
+
+- **Utilization has a floor, not a slope.** Once `s ≥ L`, `n` is pinned at 1 and
+ `U = e / L` no matter how much bigger the stride gets: 6.25% at stride 128 on
+ M-series, and still 6.25% at stride 4096. Growing the stride past one line
+ stops costing you *line* waste — after that it costs you prefetcher coverage
+ (Step 4) and pages (Step 8) instead. That is the answer to Question 2.
+- **Neighbours are free.** Once the line is in L1, the other 120 bytes cost
+ nothing. Sequential scans exploit this; pointer chasing throws it away.
-Caches don't store individual bytes. They store **lines** — fixed 64-byte
-blocks (128 bytes on Apple M-series). Load 1 byte and the hardware fetches
-the whole line it lives in.
+### Step 3 — where can a line live? Sets, ways, and conflict misses
+
+> **In:** the cache line from Step 2, which now needs somewhere to go.
+> **Out:** the address arithmetic that places it, and the third kind of miss —
+> the one you can cause on purpose, which the profiler table's row 3 exploits.
-Two consequences that shape databases:
+A cache can't compare every line's address on every load — that would be too
+slow. So it is organized like a hash table with fixed-size buckets: some middle
+bits of the address pick a **set** (the bucket), and each set holds `N` lines.
+`N` is the **associativity**, and a cache holding `N` lines per set is called
+**N-way set-associative**. A new line evicts one of the `N` residents *of its
+own set only*.
-- **Touching 8 bytes costs a full line.** Filter on one 8-byte column of a
- wide row and you waste 94% of every transfer:
+Drepper §3.3.1 gives the identity that connects the three:
```
-filter on one 8-byte column, 128 B cache lines (M-series):
+Drepper §3.3.1, page 19:
-row layout: line = [ a │ b c d e f g ... padding ... ] use 8 B / 128 B → 94% wasted
-col layout: line = [ a a a a a a a a a a a a a a a a ] use 128 B → 0% wasted
+ cache size = cache line size × associativity × number of sets
+
+ O = log2(cache line size) bits of the address used as the line offset
+ S = log2(number of sets) bits of the address used as the set index
```
- That's topic 12 (columnar storage) in one diagram — Drepper's Fig 3.11.
+**Worked on his own example** (§3.3.1 states the answers, so this checks both
+the formula and the transcription):
-- **Neighbors are free.** Once the line is in L1, the other 120 bytes cost
- nothing. Sequential scans exploit this; pointer chasing throws it away.
+```
+Drepper's 4 MB, 64-byte-line, 8-way L2:
+ number of sets = 4,194,304 / (64 × 8) = 4,194,304 / 512 = 8,192 sets
+ S = log2(8,192) = 13 bits
+ (§3.3.1, page 19, verbatim: "Given our 4MB/64B cache and 8-way set
+ associativity the cache we are left with has 8,192 sets and only 13 bits
+ of the tag are used in addressing the cache set.")
+ 8 tags compared in parallel per lookup (§3.3.1: "8 tags have to be compared")
+
+The stride that thrashes it — the set-index period:
+ period = number of sets × line size = 8,192 × 64 = 524,288 B = 512 KB
+ ⇒ any two addresses exactly 512 KB apart share a set
+ ⇒ nine addresses at that spacing overflow an 8-way set: 9 > 8, so every
+ touch evicts one that will be needed again. The cache is 4 MB and the
+ working set is nine lines = 576 bytes.
+
+The same arithmetic for this repo's machine (L1d = 128 KB measured, notes.md;
+128-byte lines measured, topic 9 notes.md; 8-way ASSUMED, not measured):
+ number of sets = 131,072 / (128 × 8) = 128 sets
+ period = 128 × 128 = 16,384 B = 16 KB
+ ⇒ a row stride of exactly 16 KB is the pathological one to construct
+```
-### Step 3 — where can a line live? Sets, ways, and conflict misses
+That last block is where the profiler table's "pad the stride by one line
+(4096 → 4096+128)" advice comes from: shifting every row by one line walks the
+set index forward instead of repeating it.
+
+This gives the three miss types their vocabulary:
-A cache can't search all its lines on every load — that would be too slow. So
-it's organized like a **hash table with fixed-size buckets**: some middle
-bits of the address pick a **set** (the bucket), and each set holds N lines
-(**N-way associative**, typically 8–16). A new line evicts one of the N
-residents of *its own set only*.
+- **Cold (compulsory)** — first touch of that line, unavoidable.
+- **Capacity** — the working set is simply bigger than the cache.
+- **Conflict** — the *set* is full even though the *cache* isn't, as in the
+ 576-byte working set above.
-This gives the three miss types a vocabulary:
+Drepper measures how much associativity buys, in Table 3.1 (L2 misses for a
+`gcc` run, 32-byte lines). He asserts the trend; the divisions are ours:
+
+```
+Drepper Table 3.1, 8 MB cache, CL=32 — misses, and the saving from each doubling:
+
+ direct → 2-way: 4,731,904 → 2,690,498 saved 2,041,406 / 4,731,904 = 43.1%
+ 2-way → 4-way: 2,690,498 → 2,207,655 saved 482,843 / 2,690,498 = 17.9%
+ 4-way → 8-way: 2,207,655 → 2,111,075 saved 96,580 / 2,207,655 = 4.4%
+```
-- **cold** — first touch, unavoidable
-- **capacity** — working set simply bigger than the cache
-- **conflict** — the set is full even though the cache isn't (bucket
- collision: many hot addresses hash to the same set, e.g. a stride that
- equals the set-index period)
+§3.3.1 calls the first step "almost 44%" — the division on his own table gives
+43.1%, close enough that the rounding is the only disagreement. The three
+numbers together are the honest version of his prose claim that "the successive
+gains are much smaller": the second doubling is worth 2.4× less than the first,
+and the third is worth 10× less. For the associativity levels of 2007, §3.3.1
+says "Today processors are using associativity levels of up to 24 for L2 caches
+or higher. L1 caches usually get by with 8 sets." — where "8 sets" is a slip for
+*8 ways*, as the surrounding paragraph about comparators makes clear. Quote the
+number, not the word.
### Step 4 — the prefetcher: hardware that bets on your next load
-The memory system watches your access pattern. Sequential or fixed-stride
-loads are detected and the *next* lines are fetched before you ask —
-hiding DRAM latency entirely. The bet fails on random access: the prefetcher
-has nothing to extrapolate, so every miss pays full price.
+> **In:** the access pattern implied by Step 2's stride, laid out over Step 3's
+> sets.
+> **Out:** the measured size of the latency the hardware hides for free, and
+> the exact list of patterns it cannot hide — the premise Step 5 removes.
+
+**Prefetching** is the memory system speculatively fetching lines you have not
+asked for, on the bet that your pattern will continue. Drepper §6.3.1 states
+the rules his era's hardware followed, and they have not fundamentally changed:
+
+- The trigger is "a sequence of **two or more** cache misses in a certain
+ pattern" — one miss never starts a prefetch, because random accesses to
+ globals are common and would waste bandwidth.
+- Fixed strides are recognized, not just adjacent lines, but the recognition
+ range is bounded: §6.3.1 says the range "has been increased over the years,
+ but it probably does not make much sense to go beyond the 512 byte window
+ which is often used today". Treat ~512 B as the outer edge of a stride the
+ hardware will follow.
+- "CPUs today can keep track of **eight to sixteen** separate streams" for the
+ higher-level caches — and that budget is *shared* with every other core and
+ hyper-thread on the same cache.
+- "Prefetching has one big weakness: **it cannot cross page boundaries**",
+ because a speculative fetch must never trigger a page fault the program did
+ not ask for. So you take a miss at every page boundary regardless.
+- "Currently prefetch units do not recognize non-linear access patterns."
+
+**How much is it worth?** Drepper answers this with an accident of Fig 3.10 that
+is easy to miss. On his Pentium 4 (16 kB L1d, 1 MB L2), a sequential walk over
+a linked list costs about **4 cycles** per element inside L1d, and then — past
+the 1 MB L2, where every access is going to main memory — it costs about
+**9 cycles** per element. §3.3.2 spells out the comparison itself: "Before we
+said that a main memory access takes 200+ cycles. Only with effective
+prefetching is it possible for the processor to keep the access times as low as
+9 cycles."
+
+```
+Drepper §3.3.2, Fig 3.10 — what sequential prefetching is worth, 2007:
+ unhidden main-memory access 200+ cycles (§3.3.2's own figure)
+ measured sequential walk 9 cycles (Fig 3.10, working set > L2)
+ hidden 200 / 9 = 22×
+```
+
+A second detail in the same figure is stranger and more instructive: in the L2
+range the walk shows ~9 cycles per element when the L2's own access latency is
+~14 (§3.2's table). The walk is *faster than the cache it is reading from*,
+because the next line is already halfway loaded when the loop reaches it.
+Prefetching does not just avoid the DRAM trip; it removes the L2 trip from the
+critical path.
+
+The bet fails on random access, and Fig 3.15 measures the failure:
+
+```
+Drepper Fig 3.15 (§3.3.2), same list, same machine, order shuffled:
+ sequential, working set ≫ L2 ~9 cycles/element (also Fig 3.10)
+ random, working set ≫ L2 450+ cycles/element
+ gap 450 / 9 = 50×
+
+This repo, Apple M3 Pro, the same comparison in nanoseconds:
+ streaming scan, topic 12 scan_bench: 800 MB / 0.014 s = 57.1 GB/s
+ ⇒ one 128 B line every 128 / 57.1e9 = 2.24 ns
+ dependent random chase, cache_ladder at 128 MB: 104 ns per line
+ gap 104 / 2.24 = 46×
+```
-This is why "sequential vs random" is the single most important distinction
-in the topic 0 latency table — same data, same cache, ~10× difference.
+Two machines nineteen years apart, and the sequential-vs-random gap is 50× on
+one and 46× on the other. This is the correction to a folk figure the earlier
+version of this chapter repeated: the gap is **not** "about 10×". It is about
+**50×**, and it has been about 50× since 2007.
+
+Note what Drepper says about *why* random loses even when both are going to
+DRAM: §3.3.2 attributes it to three stacked causes — no prefetch, a rising L2
+miss ratio (Table 3.2 puts random at 13.4% miss at a 1 MB working set against
+sequential's 0.94%, and 57.8% against 4.67% at 512 MB), and TLB misses (Step 8,
+and Fig 3.17, where limiting the randomization to page-sized blocks recovers
+"up to 38%").
### Step 5 — dependent loads: the one latency you cannot hide
+> **In:** Step 4's finding that the prefetcher covers predictable patterns.
+> **Out:** the cost of a miss when *nothing* can cover it — measured twice on
+> the same machine, 11× apart — plus the instrument that measures it, which
+> Steps 6 and 8 both reuse.
+
A load is **dependent** when the core cannot compute its address until an
earlier load has come back. `chain[idx]` where `idx` was itself loaded from
memory; `node->next->next`; a B-tree descent, where the child pointer lives
@@ -94,12 +345,18 @@ inside the parent node you are still waiting for.
Why that one word decides everything: an out-of-order core does not run one
load at a time. It keeps hundreds of instructions in flight and issues *every*
-load whose address it already knows, so several misses sit in the memory
-system simultaneously. This is **memory-level parallelism (MLP)**, and it
-means the cost of a miss is not a property of the miss — it is a property of
-how many other misses could keep it company.
+load whose address it already knows, so several misses sit in the memory system
+simultaneously. This is **memory-level parallelism (MLP)** — the number of
+outstanding misses the machine is servicing at once — and it means the cost of
+a miss is not a property of the miss. It is a property of how many other misses
+could keep it company. Drepper puts the same point at the head of §6.3: "To
+cover the latency of main memory accesses, the command queue would have to be
+incredibly long."
```
+ILLUSTRATION — round numbers, not measurements. The measured version of this
+diagram is the table immediately below it.
+
independent loads — a[0], a[1], a[2]: all three addresses computable right now
load A ├──────── ~100 ns ────────┤
@@ -115,83 +372,246 @@ dependent loads — B's address IS the value A returned
└─► ~300 ns total ⇒ 100 ns each
```
-Same cache, same DRAM, same number of misses — 3× apart here, and ~10× apart
-in practice once the out-of-order window is full. Note what the diagram
-implies: **latency and bandwidth are different questions.** A chase leaves the
-memory bus nearly idle (one 128-byte line per ~104 ns ≈ 1.2 GB/s, against the
-24–57 GB/s a single core reaches on a streaming scan in topic 12); it is slow
-while doing almost nothing. Nothing you can do about the bus will help it.
-
-This repo measured both sides of the diagram on the same machine
-([`notes.md`](notes.md)):
+This repo measured both sides of that diagram on the same machine
+([notes.md](notes.md)):
| what | working set | ns per access |
|------|------------:|--------------:|
| `lookup_shootout` `hashmap` at n=1e7 — 1024 **independent** probes | ~160 MB | **9.3** |
| `cache_ladder` at 128 MB — a **dependent** chase | 128 MB | **104** |
-Both are random DRAM accesses that "should" cost ~100 ns. The 11× gap is
-overlap and nothing else. That is why the hash table looked suspiciously flat
-at ten million keys, and why a *single* isolated lookup in the capstone would
-not enjoy the same number.
+Both are random DRAM accesses that "should" cost ~100 ns. 104 ÷ 9.3 = **11.2×**,
+and the difference is overlap and nothing else. That is why the hash table
+looked suspiciously flat at ten million keys (7.4 ns at n=100 to 9.3 ns at
+n=1e7, [notes.md](notes.md)), and why a *single* isolated lookup in the capstone
+would not enjoy the same number.
The chase is how you measure the un-hidable case. Three properties of
-[`cache_ladder`](experiments/benches/cache_ladder.rs) do the work:
+[`cache_ladder`](experiments/benches/cache_ladder.rs) do the work, and the file
+is short enough to read whole:
+
+```rust
+// topics/00-performance-toolbox/experiments/benches/cache_ladder.rs — chase, 25-31
+ 25 fn chase(chain: &[usize], start: usize, steps: usize) -> usize {
+ 26 let mut idx = start;
+ 27 for _ in 0..steps {
+ 28 idx = chain[idx];
+ 29 }
+ 30 idx
+ 31 }
+```
+
+Line 28 is the entire experiment. The value loaded *is* the next address, so the
+dependency lives in the data, not in the code: it cannot be reordered, hoisted
+or speculated around by any compiler or any core, because the next address
+genuinely does not exist yet. One miss in flight, always. Line 30 returns `idx`
+so the loop is not dead code.
+
+The second property is in the chain's construction:
+
+```rust
+// topics/00-performance-toolbox/experiments/benches/cache_ladder.rs — make_chain, 14-23
+ 14 fn make_chain(len: usize, rng: &mut StdRng) -> Vec {
+ 15 let mut order: Vec = (0..len).collect();
+ 16 order.shuffle(rng);
+ 17 let mut chain = vec![0usize; len];
+ 18 for w in order.windows(2) {
+ 19 chain[w[0]] = w[1];
+ 20 }
+ 21 chain[order[len - 1]] = order[0];
+ 22 chain
+ 23 }
+```
+
+Line 16 makes it **random**, which kills the prefetcher (Step 4 — no stride to
+extrapolate, and no non-linear pattern is recognized at all). Line 21 makes it
+**cyclic** — it closes the permutation into a single cycle covering every slot,
+so no short sub-cycle can quietly live in L1 and flatter the big sizes. The
+generator is seeded (`StdRng::seed_from_u64(42)`, line 36), so the chain is the
+same on every run.
+
+The third property is the one that was originally wrong:
```rust
-fn chase(chain: &[usize], start: usize, steps: usize) -> usize {
- let mut idx = start;
- for _ in 0..steps {
- idx = chain[idx]; // the value loaded IS the next address
- }
- idx // returned so the loop isn't dead code
-}
-```
-
-1. **The dependency is in the data, not the code.** `idx = chain[idx]` cannot
- be reordered, hoisted, or speculated around by any compiler or any core —
- the next address genuinely does not exist yet. One miss in flight, always.
-2. **`chain` is a random cyclic permutation** (Sattolo's algorithm). Random
- kills the prefetcher (Step 4) so nothing arrives early; *cyclic* — one cycle
- through every slot — stops a short sub-cycle from quietly living in L1 and
- flattering the big sizes.
-3. **`idx` is carried across criterion iterations.** The first version of this
- benchmark restarted at `idx = 0` each iteration, re-walked the same 65,536
- slots, and reported ~25 ns for "DRAM" — it had measured an ~8 MB hot path
- that the benchmark itself created. The fix is in the source comment;
- the confession is in `notes.md`.
+// topics/00-performance-toolbox/experiments/benches/cache_ladder.rs — the bench closure, 50-57
+ 50 // Carry the position across iterations: restarting at 0 every iter
+ 51 // re-walks the same `steps` slots, which stay cached — at 512MB that
+ 52 // silently measures an ~8MB hot path instead of DRAM.
+ 53 let mut idx = 0usize;
+ 54 b.iter(|| {
+ 55 idx = chase(black_box(chain), idx, steps);
+ 56 black_box(idx)
+ 57 })
+```
+
+Line 53 sits *outside* `b.iter`, which is the whole fix. The first version of
+this benchmark declared `idx` inside the closure, so every criterion iteration
+re-walked the same 65,536 slots (`steps`, line 43) and reported **~25 ns** for
+"DRAM" — it had measured an ~8 MB hot path that the benchmark itself created
+([notes.md](notes.md), "First version lied"). The correct answer at 512 MB is
+113 ns. A benchmark that creates the cache residency it then measures is topic
+0's headline failure mode, and this file is where the repo committed it.
The readout is unusually direct: with no arithmetic between the loads,
`elapsed / steps` **is** the latency of one access at that working-set size.
-Sweep the size from 16 KB to 512 MB and the plateaus are the cache levels —
-this is Drepper's Fig 3.4, and it is the only number in this repo you can
-compare to a datasheet without an argument.
+Sweep the size from 16 KB to 512 MB (line 39-42) and the plateaus are the cache
+levels. This is the same experiment as Drepper's Fig 3.4 — cycles per operation
+against working-set size, with the levels readable off the plateaus — and it is
+the only number in this repo you can compare to a datasheet without an argument.
The database consequence is the whole curriculum: pointer-chasing layouts
(linked lists, naive trees, record-per-node graph stores) pay full latency per
hop, while layouts that expose addresses up front (arrays, matrices,
page-sized nodes, batched lookup APIs) convert latency into throughput. When a
later topic says a design "exposes memory-level parallelism", it means: the
-addresses are knowable early enough to overlap the waiting.
+addresses are knowable early enough to overlap the waiting. Topic 3 is this
+step's bill arriving — B-tree lookups climb **862 → 1101 ns** from 1e6 to 4e6
+keys while the tree's height stays at 3 ([FINDINGS row 3](../../FINDINGS.md)).
+Height sets how many pointers you chase; residency sets what each chase costs.
+
+### Step 6 — latency and bandwidth are opposite questions
+
+> **In:** the two measured numbers from Step 5 — 9.3 ns overlapped and 104 ns
+> serialized — plus Step 2's utilization fraction.
+> **Out:** the ratio that says which of the two walls a loop is against, run on
+> four of this repo's lanes. The profiler table at the end of the chapter is
+> this step applied to code you did not write.
+
+Look again at Step 5's diagram and notice what it implies about the *bus*. A
+dependent chase moves one line per 104 ns and then stops to think. That is
+128 ÷ 104e-9 = **1.23 GB/s**, on a machine whose peak memory bandwidth is
+150 GB/s ([topic 12 notes.md](../12-columnar-analytics/notes.md)). It is slow
+while doing almost nothing. Nothing you can do about the bus will help it.
+
+Two definitions, because these words get used interchangeably and must not be.
+**Latency-bound** means the loop is waiting on the *round trip* of an access it
+could not start earlier; the fix is more overlap (more independent addresses,
+batched APIs, prefetch hints), and the bus is idle while it happens.
+**Bandwidth-bound** means the loop has already saturated the *rate* at which
+bytes can arrive; the fix is moving fewer bytes (Step 2's utilization), and more
+overlap does nothing at all.
+
+**Utilization: how close to the wall is this lane?**
+
+```
+ B_peak = 150 GB/s this machine's peak memory bandwidth (topic 12 notes.md)
+ B_ach = Q / t bytes the lane moved, divided by how long it took
+ Util = B_ach / B_peak the fraction of the bus the lane is using
+
+lane Q / t B_ach Util
+topic 12 scan_bench, small-range random 800 MB / 0.014 s 57.1 GB/s 38.1%
+topic 12 scan_bench, sorted low-card 800 MB / 0.033 s 24.2 GB/s 16.2%
+topic 0 lookup_shootout hashmap n=1e7 128 B / 9.3 ns 13.8 GB/s 9.2%
+topic 0 cache_ladder at 128 MB 128 B / 104 ns 1.23 GB/s 0.82%
+
+ FINDINGS row 12 states the top two rows as the headline: "The scan floor is
+ 24–57 GB/s on a 150 GB/s machine."
+```
+
+(The two `lookup_shootout` and `cache_ladder` rows assume one 128-byte line per
+access, which is a floor — a hash probe may touch a second line. All four `Q`
+and `t` values come from [notes.md](notes.md) and
+[topic 12 notes.md](../12-columnar-analytics/notes.md). The `B_ach` column is
+this chapter's own division on the times those files print, so it differs from
+the GB/s those files print by the rounding in `t`: 57.1 here against the lane's
+57.0, and 24.2 against 24.4. Topic 12's notes also warn that repeat runs put
+this lane anywhere from 24 to 76 GB/s depending on machine state, so treat the
+utilization column as an order of magnitude, not a constant — which is all the
+memory-bound-vs-latency-bound question needs.)
+
+Read the column. The columnar scan at 38% of peak on a single core is against
+the bandwidth wall; buying it more overlap is pointless, and topic 12 spends its
+whole chapter moving fewer bytes instead. The pointer chase at 0.82% is against
+the latency wall with 99% of the bus sitting idle; it does not need a better
+layout, it needs more addresses known earlier. The hash probe at 9.2% is the
+interesting one — already overlapping 11× better than the chase (Step 5), and
+still using less than a tenth of the bus, which is why Question 4 asks you to
+prove there is more MLP left in it.
+
+**Arithmetic intensity: at what point does a loop stop being memory-bound?**
+
+```
+ W = useful operations the kernel performs
+ Q = bytes it must move
+ I = W / Q arithmetic intensity, operations per byte
+ P = ops/s the core can retire
+ B = bytes/s the memory system can sustain for this kernel
+ I* = P / B the RIDGE POINT — the machine's balance
+
+ the loop is memory-bound iff I < I*
+```
+
+Run it on topic 12's `scan_bench` lane, which folds 100 M `u64` with
+`wrapping_add`:
+
+```
+ W = 100,000,000 adds
+ Q = 100,000,000 × 8 = 800,000,000 bytes
+ I = 100e6 / 800e6 = 0.125 adds per byte
+
+ B = 57.1 GB/s the best single-core streaming bandwidth this repo has
+ MEASURED (topic 12 notes.md, small-range random lane)
+ P = 16e9 adds/s ASSUMED: 4 u64 adds per cycle at 4.0 GHz. This repo has
+ not measured peak scalar issue rate, so this is a stated
+ assumption, not a figure.
+
+ I* = 16e9 / 57.1e9 = 0.28 adds per byte
+ I = 0.125 < I* = 0.28 ⇒ memory-bound, by a factor of 0.28 / 0.125 = 2.24×
+
+ What would it take to reach the ridge?
+ ops per 8-byte element at the ridge = I* × 8 = 0.28 × 8 = 2.24
+ the lane does 1. It would have to more than double its work per element
+ before bandwidth stopped being the limit.
+
+ Sensitivity, because P was assumed rather than measured:
+ at 8 adds/cycle, I* = 32e9 / 57.1e9 = 0.56 ⇒ still memory-bound, by 4.5×
+ at 2 adds/cycle, I* = 8e9 / 57.1e9 = 0.14 ⇒ still memory-bound, by 1.1×
+ The conclusion does not flip anywhere in that range, which is the point of
+ computing a ridge instead of asserting one.
+```
-### Step 6 — virtual memory: every address you use is fake
+That last block is topic 0 §4's roofline paragraph with the division actually
+performed. It is also why the profiler flowchart below asks "achieved GB/s near
+the machine's peak?" before it asks anything about TLBs: the answer to that one
+question separates two diagnoses whose fixes are opposites.
+
+### Step 7 — virtual memory: every address you use is fake
+
+> **In:** every address Steps 1–6 loaded from, which they all quietly assumed
+> was a real place in DRAM.
+> **Out:** the translation those addresses need, priced in dependent loads —
+> Step 5's chain, running in silicon in front of your access. Step 8 caches it.
+
+Every pointer your program holds is a **virtual address** — a number meaningful
+only inside your process. The physical DRAM location is decided by the OS,
+which gives each process its own address space, maps pages lazily (your `Vec`
+allocation may have no physical memory behind it until first touch), shares
+pages between processes, and backs some of them with files. So *every* load
+needs a translation: virtual page → physical frame. The map is the **page
+table**, and it lives, awkwardly, in memory itself.
+
+**Why a tree and not an array.** Drepper does this arithmetic in §4.2 for his
+era: with 4 kB pages on a 32-bit machine the offset is 12 bits, leaving 20 bits
+of page number, so a flat table is 2²⁰ entries × 4 bytes = **4 MB** per process —
+and "with each process potentially having its own distinct page directory much
+of the physical memory of the system would be tied up". The same arithmetic on
+this machine is far worse:
-Every pointer your program holds is a **virtual address**. The physical DRAM
-location is decided by the OS — which gives each process its own address space,
-maps pages lazily (your `Vec` allocation may have no physical memory behind it
-until first touch), shares pages between processes, and backs some of them with
-files. So *every* load needs a translation: virtual page → physical frame. The
-map is the **page table**, and it lives, awkwardly, in memory itself.
+```
+ 47-bit user address space, 16 KB pages (Apple M-series):
+ pages = 2^47 / 2^14 = 2^33 = 8,589,934,592 pages
+ flat = 2^33 × 8 bytes = 2^36 = 64 GB of page table, per process
+```
-**Why a tree and not an array.** A flat lookup table would be one entry per
-page: a 47-bit address space with 16 KB pages is 2³³ pages × 8 bytes = **64 GB
-of table per process**. Unaffordable. So it is a **radix tree**: the virtual
-address is chopped into fixed-width slices, each slice indexes one level, and
-only the sub-tables that actually have mappings are ever allocated. An idle
-process's page table is a few KB. You pay for sparsity with depth — and depth
-here means *loads*.
+Unaffordable, so it is a **radix tree**: the virtual address is chopped into
+fixed-width slices, each slice indexes one level, and only the sub-tables that
+actually have mappings are ever allocated. An idle process's page table is a
+few KB. You pay for sparsity with depth — and depth here means *loads*.
+Drepper's §4.2 puts the same trade in one sentence, typo and all: "The level
+then form a huge, sparse page directory; address space regions which are not
+actually used do not require allocated memory."
-**How the address is chopped** (x86-64, 4 KB pages — the paper's case):
+**How the address is chopped** (x86-64, 4 kB pages — the paper's case, Fig 4.2):
```
47 39 38 30 29 21 20 12 11 0
@@ -208,10 +628,13 @@ here means *loads*.
│ + offset ──► your data, at last
└── CR3: physical address of this process's top table (swapped on context switch)
-why 9 bits: one table is one 4 KB page = 4096 / 8 = 512 entries = 2⁹
+why 9 bits: one table is one 4 kB page = 4096 / 8 = 512 entries = 2⁹
+ (Drepper §4.2: "on x86-64 with 4kB pages and 512 entries per directory")
why dependent: each entry holds the *physical address of the next table*, so
load N+1's address is unknown until load N returns — Step 5's chain, in
- silicon, running BEFORE your actual access can even issue
+ silicon, running BEFORE your actual access can even issue. §4.3: "These
+ accesses cannot be parallelized since they depend on the previous lookup's
+ result."
```
The same tree, three vocabularies for the same four levels — you will meet all
@@ -224,86 +647,302 @@ three while reading:
| ↓ | PD | PMD | L2 |
| leaf | PT | PTE | L3 |
-**Apple Silicon is shallower, because its pages are bigger.** With a 16 KB
+**Apple Silicon is shallower, because its pages are bigger.** With a 16 kB
granule a table holds 16384 / 8 = 2048 entries = **11 bits** of index, and the
offset takes 14 bits. So `14 + 11 + 11 + 11 = 47` — a 47-bit user address space
is covered in **three** levels, not four. Bigger pages buy a shorter walk *and*
-4× the TLB reach (Step 7) from the same entry count.
+4× the TLB reach (Step 8) from the same entry count. Drepper predicted exactly
+this in §4.3.2: "There is a second effect of using larger page sizes: the number
+of levels of the page table tree is reduced."
+
+**What a walk costs.** §4.3 prices the best case in his own numbers, and the
+paper states the answer, so this checks the transcription:
+
+```
+Drepper §4.3, page 38:
+ "on a machine with four page table levels, require at the very least 12 cycles"
+ ⇒ 4 levels × 3 cycles (§3.2's L1d figure) = 12 cycles, if every level hits L1d
+
+The other end of the range, using §3.2's own Main Memory figure:
+ 4 levels × 240 cycles = 960 cycles, if every level misses to DRAM
+
+The same two bounds on this machine, with three levels and MEASURED latencies
+(notes.md):
+ all-hit: 3 × 1.02 ns = 3.1 ns
+ all-miss: 3 × 104 ns = 312 ns
+```
**Four things keep this from being catastrophic**, and knowing them is the
difference between fearing the diagram and predicting it:
- **Hardware walks it, not the kernel.** The MMU's page-table walker does those
- loads in silicon, costing nanoseconds. The kernel only gets involved when
- there is no valid entry — a **page fault**, which is microseconds, a
- thousand-fold different event.
+ loads in silicon, costing nanoseconds; Drepper §4.2 notes x86 and x86-64 "perform
+ this operation in hardware". The kernel only gets involved when there is no
+ valid entry — a **page fault**, which is microseconds. §6.2.4 makes the
+ relative sizes explicit — and then immediately qualifies it, which is the part
+ usually dropped: "Page faults are orders of magnitude more expensive than TLB
+ misses but, if a program is running long enough and certain parts of the
+ program are executed frequently enough, TLB misses can outweigh even page fault
+ costs."
- **The tables are ordinary cacheable memory.** The upper levels are touched by
- every access in the region, so they normally sit in L1/L2; only the leaf level
- is likely to be cold.
+ every access in the region, so they normally sit in L1/L2 — which is what
+ makes the 12-cycle bound the realistic one and the 960-cycle bound the
+ pathological one.
- **There are dedicated page-walk caches** (x86 paging-structure caches, ARM
walk caches) holding partial translations, so a walk often skips its first
- levels entirely.
+ levels entirely. This is post-2007 hardware; the paper does not describe it.
- **Huge pages truncate the walk.** An entry at the PMD/L2 level can be a
*block* descriptor pointing straight at 2 MB of contiguous physical memory
- (32 MB with a 16 KB granule) instead of at another table — one fewer load,
- and one TLB entry covering 512× more address space.
-
-So: worst case is ~3–4 dependent DRAM loads *added in front of* your access;
-typical case is far less. The measurement is in `notes.md` and it lands where
-this predicts — `cache_ladder`'s tail rises **87 → 113 ns** from 64 MB to
-512 MB, an added ~26 ns per access once 32K pages overflow the TLB. Not the
-+400 ns of a fully cold walk, not zero either. That +26 ns is this diagram,
-priced.
-
-### Step 7 — the TLB: a cache for translations, with tiny reach
-
-Doing that 4-load walk per access would be absurd, so translations are
-cached in the **TLB** (translation lookaside buffer). The catch is
-**reach**: ~2K entries × 4 KB pages ≈ only a few MB of address space covered.
-Working sets beyond that miss in the TLB *as well as* the caches — the two
-penalties stack. This is why databases care about **huge pages** (2 MB/1 GB
-pages multiply reach by 512×; Apple's 16 KB base pages already 4× it).
-
-### Step 8 — multiple cores: coherency and false sharing
-
-Each core has its own L1/L2, so hardware keeps copies **coherent**: writing
-a line invalidates every other core's copy of it. The pathology is **false
-sharing** — two threads writing *different* variables that happen to share
-one line. The line ping-pongs between cores at ~100-cycle cost per bounce,
-and multi-thread scaling collapses with no visible reason in the source.
-Padding each thread's data to its own line fixes it. (This pays off in
-topic 9, concurrency.)
+ (32 MB with a 16 kB granule) instead of at another table — one fewer load, and
+ one TLB entry covering far more address space (Step 8 does that division).
-## How to read the paper (with the concepts in hand)
+So: worst case is three or four dependent DRAM loads *added in front of* your
+access; typical case is far less. The measurement is in
+[notes.md](notes.md) and it lands where this predicts:
+
+```
+ cache_ladder's tail, 64 MB → 512 MB: 87.4 → 113 ns
+ added per access: 113 − 87.4 = 25.6 ns
+ as a fraction of a fully cold 3-level walk: 25.6 / 312 = 8.2%
+```
+
+Not the 312 ns of three cold DRAM loads, not zero either. 8.2% of a cold walk is
+what it looks like when the upper levels are cached and the walk caches are
+doing their job. That +25.6 ns *is* this diagram, priced.
+
+### Step 8 — the TLB: a cache for translations, with tiny reach
+
+> **In:** the walk from Step 7, which is far too expensive to do per access.
+> **Out:** the reach arithmetic, and the second cliff it puts in Step 1's
+> ladder — the one that explains `cache_ladder`'s last two rows.
+
+Doing that three-or-four-load walk per access would be absurd, so completed
+translations are cached in the **TLB** (translation lookaside buffer) — a small,
+very fast cache holding virtual-page → physical-frame results. Drepper §4.3
+describes what is stored precisely, and the detail matters: it is not the
+directory entries that are cached but "the complete computation of the address
+of the physical page", tagged by the virtual address minus its offset bits.
+
+The catch is **reach** — the total amount of address space the TLB's entries can
+cover at once:
+
+```
+ Reach = entries × page size
+
+Drepper's measurement (§3.3.2, Fig 3.12 — one 64-byte list element per page):
+ the spike appears when the working set reaches 2^13 bytes
+ 2^13 / 64 = 128 elements = 128 pages, against 2^12 / 64 = 64 pages just below
+ §3.3.2's conclusion: "we can compute that the TLB cache has 64 entries"
+ Reach = 64 × 4,096 = 262,144 B = 256 KB
+
+This machine (notes.md), where the cliff is measured rather than the entry count:
+ 512 MB / 16 KB pages = 32,768 pages in the working set
+ 64 MB / 16 KB pages = 4,096 pages
+ cache_ladder: 87.4 ns at 64 MB → 113 ns at 512 MB, i.e. +25.6 ns per access
+ ⇒ reach lies somewhere between those two page counts, and the ladder's last
+ two rows are the cost of exceeding it
+```
+
+Working sets beyond reach miss in the TLB *as well as* in the caches, and the
+two penalties stack — Drepper §3.3.2 is explicit that "the address translation
+penalties are additive to the memory access times", which is why Fig 3.11's
+NPAD=31 curve exceeds the machine's own DRAM latency.
+
+This is why databases care about **huge pages** — pages larger than the OS
+default, which multiply reach without needing more TLB entries:
+
+```
+ 4 kB → 2 MB pages: 2,097,152 / 4,096 = 512× the reach
+ 4 kB → 1 GB pages: 1,073,741,824 / 4,096 = 262,144× the reach
+ 4 kB → Apple's 16 kB base page: 16,384 / 4,096 = 4× the reach, for free,
+ on every process, with no configuration at all
+```
+
+Drepper §4.3.2 lists the cost of the 2 MB version honestly and then names the
+one workload it is worth it for: the pages "must be contiguous in physical
+memory", which means "finding a free area with 512 contiguous pages ... can be
+extremely difficult (or impossible) after the system runs for a while"; on Linux
+of that era they had to be reserved at boot via `hugetlbfs`. His conclusion:
+"huge pages are the way to go in situations where performance is a premium,
+resources are plenty, and cumbersome setup is not a big deterrent. **Database
+servers are an example.**"
+
+### Step 9 — coherence: why a write is a bus event
+
+> **In:** Step 1's observation that each core has its own L1/L2 — which Steps
+> 2–8 never had to think about, because they were single-threaded.
+> **Out:** the protocol that keeps those private caches consistent, and the
+> message that makes a write expensive. Step 10 is what happens when you
+> trigger it by accident.
+
+Each core has its own L1 (and often L2), so the same line can exist in several
+places at once. **Cache coherence** is the hardware guarantee that all those
+copies agree: a program cannot observe two different values for one address.
+Drepper §3.3.4 describes the protocol every mainstream machine uses, **MESI**,
+named for the four states a line can be in:
+
+- **Modified** — this core has changed the line; it is the only copy anywhere.
+- **Exclusive** — unmodified, and known to be in no other core's cache.
+- **Shared** — unmodified, and possibly present in other cores' caches.
+- **Invalid** — unused.
+
+The expensive transition has a name. When a core wants to write a line that
+other cores may hold, it must first take exclusive ownership by broadcasting a
+**Request For Ownership (RFO)** — a message that invalidates every other copy.
+§3.3.4 calls it "the infamous ... (RFO) operation" — the elided words are
+*Request For Ownership* in the paper's own quotation marks — and notes
+that "performing this operation in the last level cache ... is comparatively
+expensive". Two situations produce them: a thread migrating between cores, and a
+line genuinely needed by two cores.
+
+The consequence for a write-heavy multithreaded loop: §6.4.1 states it in one
+line — "if multiple threads write to a memory location, the cache line must be
+in 'E' (exclusive) state in the L1d of each respective core. This means that a
+lot of RFO messages are sent, in the worst case one for each write access. So a
+normal write will be suddenly very expensive."
+
+Drepper also measures the aggregate effect on his four-processor box, Table 3.3
+(speed-up at the largest working set, where the theoretical limits are 2 and 4):
+
+```
+Drepper Table 3.3 (§3.3.4) — measured speed-up, largest working set:
+
+ #Threads Seq Read Seq Inc Rand Add
+ 2 1.69 1.69 1.54
+ 4 2.98 2.07 1.65
+
+ Random-access work scales 1.54× on two threads and 1.65× on four —
+ §3.3.4: "it is almost not worth it to scale beyond two threads."
+```
+
+### Step 10 — false sharing: the pathology with no visible cause
-The paper is ~114 pages; §3–§4 are the payload.
+> **In:** Step 9's RFO, plus Step 2's cache line as the unit of everything.
+> **Out:** the one bug in this chapter that is invisible in the source code,
+> and the padding constant this machine actually needs.
-- **§3.1–3.2** — skim; this is Steps 1–3 with 2007 diagrams.
-- **§3.3 — read carefully.** The famous measurements. Fig 3.4 (sequential vs
- random over working-set size) is *exactly* `cache_ladder`; compare his
- plateau shapes with yours before explaining your numbers in `notes.md`.
- You now know why random loses even in DRAM: no prefetch (Step 4) + TLB
- misses (Step 7) + DRAM row activation.
-- **§3.3.2** — critical word first / early restart: the CPU resumes as soon
- as the needed word arrives, before the rest of the line does.
+**False sharing** is the case where two threads write *different* variables that
+happen to live in the same cache line. Nothing is shared at the language level;
+everything is shared at the hardware level, because the line — not the variable
+— is the unit the coherence protocol tracks (Step 2). Every write by one thread
+invalidates the other's copy, so the line ping-pongs between cores and
+multi-thread scaling collapses with no visible reason in the source.
+
+Drepper measured it in §6.4.1 with the simplest possible program: N threads,
+each incrementing its own memory location 500 million times, pinned to
+individual processors on a four-P4 machine.
+
+```
+Drepper Fig 6.10 (§6.4.1) — same program, locations on one cache line vs on
+separate cache lines. The overhead is "computed by dividing the time needed when
+using one single cache line versus a separate cache line for each thread":
+
+ 2 threads: 390%
+ 3 threads: 734%
+ 4 threads: 1,147%
+```
+
+This repo's version of the same experiment, on Apple M-series
+([topic 9 notes.md](../09-concurrency/notes.md), [FINDINGS row 9](../../FINDINGS.md)):
+
+```
+ packed (all counters in one line): 202.7 ms 197.4 M inc/s
+ pad128 (one line each): 11.4 ms 3,502.9 M inc/s
+ ratio: 202.7 / 11.4 = 17.8×, i.e. (17.8 − 1) × 100 = 1,680% overhead
+
+ pad64 is STILL 1.8× slower than pad128 — `#[repr(align(64))]`, the x86
+ default that most `CachePadded` types use, only HALF-fixes false sharing on
+ this machine, because the coherence granule is 128 bytes.
+```
+
+1,680% on four Apple P-cores in 2026 against 1,147% on four Pentium 4s in 2007:
+this is the one pathology in the whole paper that got *worse*, because cores got
+faster relative to the interconnect.
+
+One honest caveat that Drepper supplies himself and that is easy to over-claim
+past. Figure 6.11 runs the identical program on a **single** quad-core package
+(a Core 2 QX 6700) and finds no scaling problem at all — "there is a slight
+overhead when using the same cache line more than once but it does not increase
+with the number of cores." His 1,147% needed four separate *sockets*. So the
+correct general claim is not "false sharing always costs 10×"; it is "false
+sharing costs whatever the path between the two writers costs", which was
+enormous across a 2007 front-side bus, negligible within one 2007 package, and
+17.8× across the P-cluster of an M3 Pro. Measure it on the machine you have —
+the differential in the profiler table takes about ten minutes.
+
+Padding each thread's hot datum to its own line fixes it, at the cost of
+footprint, which §6.4.1 flags as a genuine conflict with the rest of the paper's
+advice. Quoted in full, because the elision people usually make hides *what* is
+unacceptable: "There is a very simple 'fix' for the problem: put every variable
+on its own cache line. This is where the conflict with the previously mentioned
+optimization comes into play, specifically, the footprint of the application
+would increase a lot. This is not acceptable." It is the footprint increase that
+is unacceptable, not the padding itself — the blanket rule costs you everything
+Steps 2 and 3 bought. Pad what is written by multiple threads; pack everything
+else. (This pays off in topic 9, concurrency.)
+
+## How to read the paper (with the concepts in hand)
+
+The paper is 114 pages, version 1.0, dated November 21, 2007; §3–§4 are the
+payload. The section numbers below were checked against the PDF's own headings.
+
+- **§3.1–3.2** — skim; this is Steps 1–3 with 2007 diagrams. Do stop at the
+ unnumbered cycles table at the end of §3.2 (page 16): ≤1 / ~3 / ~14 / ~240 is
+ the ladder every later figure is denominated in. Fig 3.4 lives here too —
+ "Access Times for Random Writes", the first working-set sweep in the paper and
+ the same *shape* `cache_ladder` produces.
+- **§3.3.1 — read carefully.** Associativity, the `size = line × ways × sets`
+ identity, and Table 3.1. This is Step 3, and it is the only place in the paper
+ that gives you the arithmetic to *construct* a conflict miss.
+- **§3.3.2 — read most carefully of all.** The famous measurements, and the
+ source of almost every number in this chapter: Fig 3.10 (sequential, ~4 and
+ ~9 cycles), Fig 3.11 (the same walk with growing element sizes — Step 2's
+ utilization, measured), Fig 3.12 (the TLB spike, and the 64-entry deduction),
+ **Fig 3.15 (sequential vs random — the 50× gap)**, Table 3.2 (the miss ratios
+ behind it) and Fig 3.17 (page-wise randomization, worth "up to 38%").
+ Compare his plateau shapes with yours before explaining your numbers in
+ `notes.md`. You now know why random loses even in DRAM: no prefetch (Step 4)
+ + rising miss ratio + TLB misses (Step 8).
+- **§3.3.4 — read carefully.** MESI, RFO, and the multi-thread scaling
+ measurements (Figs 3.19–3.22, Table 3.3). This is Step 9.
- **§3.4** — instruction cache: skim (matters again at topic 19, JIT).
-- **§3.5 — read carefully.** Coherency + false sharing (Step 8) with the
- multi-thread scaling-collapse measurements.
-- **§4.1–4.3** — Steps 6–7. The key bit is §4.3 on TLB reach.
-- **§4.4+, §5, §7** — virtualization and NUMA: skip until a NUMA box matters.
-- **§6** — skim for the checklist: sequential > random; hot struct fields
- together, sorted by size; padding audits. §6.2's cache-oblivious matrix
- transpose is worth 10 minutes — the intellectual ancestor of
+- **§3.5.1** — cache and memory bandwidth in bytes/cycle (Figs 3.24–3.29). Worth
+ ten minutes for the method: he plots 16 B/cycle inside L1d falling to ~5.3
+ B/cycle streaming from the FSB, with a visible step at 2¹⁸ bytes "due to the
+ exhaustion of the DTLB cache". That is Step 6 and Step 8 in one graph.
+- **§3.5.2** — critical word first / early restart: the CPU resumes as soon as
+ the needed word arrives, before the rest of the line does. Note the measured
+ size of the effect in Fig 3.30 — about **0.7%**. A famous mechanism with a
+ tiny coefficient is a useful thing to have calibrated.
+- **§4.1–4.3** — Steps 7–8. §4.2 is the radix tree, §4.3 prices the walk ("at
+ the very least 12 cycles"), §4.3.1 covers TLB flushes on context switch, and
+ §4.3.2 is huge pages, including the sentence that names database servers.
+- **§4.4, §5** — virtualization and NUMA: skip until a NUMA box matters.
+- **§6.2.1 — read carefully.** The matrix multiplication and Table 6.2. Note
+ what it actually is: matrix *multiplication*, not a transpose benchmark, and
+ the blocked version is **cache-aware, not cache-oblivious** — `SM` is defined
+ as `CLS / sizeof(double)` with `CLS` supplied at compile time by
+ `getconf LEVEL1_DCACHE_LINESIZE`. Table 6.2's four columns
+ (100% → 23.4% → 17.3% → 9.47%) are the intellectual ancestor of
blocked/vectorized execution (topic 11).
-
-What's stale vs. forever: DDR2 timings, front-side bus, and Pentium 4
-details aged; the organization math, miss taxonomy, and measurement method
-didn't. Keep the Apple Silicon deltas in mind while reading: 128-byte lines
-(not 64), no inclusive L3 (shared SLC instead), much larger L1 (128–192 KB).
+- **§6.2.2–6.2.4** — instruction cache, higher-level caches, and TLB usage;
+ skim. §6.3.1 is worth reading in full — it is the only place the paper states
+ the prefetcher's actual rules (Step 4).
+- **§6.4.1 — read carefully.** False sharing, Figs 6.10 and 6.11. This is
+ Step 10, *not* §3.5 — a mistake this chapter used to make.
+- **§7** — memory performance tools: §7.1 (oprofile) and §7.2 (cachegrind) are
+ the 2007 ancestors of the instrument list in the next section. The tool names
+ aged; the two-instrument discipline did not.
+
+What's stale vs. forever: DDR2 timings, front-side bus, and Pentium 4 details
+aged; the organization math, miss taxonomy, and measurement method didn't. Keep
+the Apple Silicon deltas in mind while reading: 128-byte lines (not 64), no
+inclusive L3 (a shared SLC instead), much larger L1 (128 KB measured here), 16 kB
+base pages, and a three-level page table.
## Finding these concepts in a real program
-Steps 1–8 are visible in a microbenchmark you wrote on purpose. The harder
+Steps 1–10 are visible in a microbenchmark you wrote on purpose. The harder
skill is spotting them in a program you did not write, where the pathology is
one loop among thousands. Three instruments, in the order you should reach for
them:
@@ -318,17 +957,18 @@ them:
showed 21% in SipHash and ~79% in one inlined probe loop, and no amount of
staring at it could split "hashing" from "waiting".
2. **Hardware counters** tell you *which wall*. This is the only instrument
- that distinguishes the eight concepts directly. On Linux: `perf stat`. On
+ that distinguishes the ten concepts directly. On Linux: `perf stat`. On
macOS there is no `perf` — Instruments → **CPU Counters** template gives you
the events, and for real counter work run the same crate in a Linux VM or
- container.
+ container. Drepper's §7.1 is the same idea with `oprofile`.
3. **A differential experiment** — change exactly one thing, re-measure —
is the only fully portable instrument, and the one this repo leans on. Each
row of the table below has one, because a counter tells you a number is high
while a differential proves the *causal* link.
-Start with the funnel: two counters (`instructions`, `cycles` → IPC) plus a
-branch-miss rate narrow eight suspects to one or two.
+Start with the funnel: two counters (`instructions`, `cycles` → **IPC**, the
+instructions the core retires per clock cycle) plus a branch-miss rate narrow
+ten suspects to one or two.
```mermaid
flowchart TD
@@ -338,7 +978,7 @@ flowchart TD
C -->|no| E{"achieved GB/s near
the machine's peak?"}
E -->|yes| F["bandwidth-bound
→ Steps 2, 4: line waste, layout"]
E -->|"no — bus mostly idle"| G{"dTLB misses
significant?"}
- G -->|yes| H["translation-bound
→ Steps 6, 7: huge pages, smaller reach"]
+ G -->|yes| H["translation-bound
→ Steps 7, 8: huge pages, smaller reach"]
G -->|no| I["latency-bound
→ Step 5: dependent loads, no MLP"]
style B fill:#1f6feb,color:#fff
style D fill:#8957e5,color:#fff
@@ -347,73 +987,391 @@ flowchart TD
style I fill:#d29922,color:#000
```
-The last branch is the one people get wrong: **latency-bound and
-bandwidth-bound are opposites.** Both look "memory-bound" in a flamegraph, and
-they have opposite fixes — more bandwidth-efficient layouts do nothing for a
-pointer chase, and more overlap does nothing for a saturated bus.
+The last branch is Step 6, and it is the one people get wrong: **latency-bound
+and bandwidth-bound are opposites.** Both look "memory-bound" in a flamegraph,
+and they have opposite fixes — more bandwidth-efficient layouts do nothing for a
+pointer chase at 0.82% bus utilization, and more overlap does nothing for a scan
+already at 38%.
| Concept | Signature in a profile | Counters (Linux `perf`) | Differential test that proves it |
|---|---|---|---|
| **1** Hierarchy at all | Low IPC, flat profile, time on loads | `cycles,instructions` | Shrink the dataset with the algorithm unchanged. Time/op drops sharply ⇒ you were paying the hierarchy, not the code. |
-| **2** Cache-line waste | Hot loop touches one field of a wide struct | `cache-references,cache-misses`, plus achieved GB/s vs *useful* bytes | Split hot fields out (AoS→SoA) or shrink the struct. Faster with identical instruction count ⇒ you were paying for bytes you never read. |
+| **2** Cache-line waste | Hot loop touches one field of a wide struct | `cache-references,cache-misses`, plus achieved GB/s vs *useful* bytes | Split hot fields out (AoS→SoA) or shrink the struct. Faster with identical instruction count ⇒ you were paying for bytes you never read. Compute `U` first — Step 2's five cases take two minutes. |
| **3** Conflict misses | A cliff at a power-of-two size or stride, while the working set still "fits" | `L1-dcache-load-misses` high with a small working set | Pad the stride by one line (row stride 4096 → 4096+128). Faster ⇒ conflict, not capacity. Nothing else moves that. |
-| **4** Prefetching | Sequential and random over the *same* data differ ~10× | (vendor-specific prefetch events; weak) | Feed the same loop a sorted vs shuffled index array. The gap *is* the prefetcher's contribution. |
+| **4** Prefetching | Sequential and random over the *same* data differ ~50× (Fig 3.15: 50×; this repo: 46×) | (vendor-specific prefetch events; weak) | Feed the same loop a sorted vs shuffled index array. The gap *is* the prefetcher's contribution. |
| **5** Dependent loads | One load instruction owns the samples, IPC ≪ 1, **and achieved bandwidth is low** — slow while the bus idles | `cycles,instructions`; on x86 the stall-on-memory events | Run k independent chases interleaved with k cursors. Per-step time falls ~k× until it saturates ⇒ you were latency-bound with spare MLP. Batched/vectorized lookup APIs exist to collect that k×. |
-| **6–7** TLB / page walks | A *second*, later cliff after the DRAM plateau has flattened | `dTLB-loads,dTLB-load-misses` (x86: `dtlb_load_misses.walk_completed`) | Enable huge pages (`MADV_HUGEPAGE` / THP) or drop the working set under TLB reach. On macOS: compare above vs below reach — that is `cache_ladder`'s last two rows, 87 → 113 ns. |
-| **8** False sharing | Multi-thread scaling collapses; time sits in a *store*; per-thread work is unchanged | `perf c2c` — the purpose-built tool | Pad each thread's datum to its own line (128 B on M-series) and re-plot the scaling curve. Curve straightens ⇒ false sharing. |
+| **6** Which wall | Everything looks "memory-bound" | achieved GB/s ÷ peak GB/s | Compute the utilization table of Step 6 for your loop. Under ~10% of peak ⇒ latency; over ~30% ⇒ bandwidth. The two fixes are opposites, so guessing costs you a sprint. |
+| **7–8** TLB / page walks | A *second*, later cliff after the DRAM plateau has flattened | `dTLB-loads,dTLB-load-misses` (x86: `dtlb_load_misses.walk_completed`) | Enable huge pages (`MADV_HUGEPAGE` / THP) or drop the working set under TLB reach. On macOS: compare above vs below reach — that is `cache_ladder`'s last two rows, 87.4 → 113 ns. |
+| **9** Coherence / RFO | Time sits in *stores*; scaling flattens as soon as two threads write the same structure | `perf c2c`; on x86 the RFO / `mem_inst_retired` events | Make the shared structure per-thread and merge at the end. Scaling straightens ⇒ you were paying RFOs. |
+| **10** False sharing | Multi-thread scaling collapses; time sits in a *store*; per-thread work is unchanged | `perf c2c` — the purpose-built tool | Pad each thread's datum to its own line (**128 B** on M-series — 64 B only half-fixes it here) and re-plot the scaling curve. Curve straightens ⇒ false sharing. |
Two habits that make this reliable:
- **Always pair a counter with a differential.** "Cache misses are high" is not
a diagnosis; databases miss cache constantly and are fine. The differential
answers the only question that matters — *would fixing it help?*
-- **Compute the useful-bytes ratio by hand.** Bytes your algorithm needs ÷
- bytes the machine moved. It needs no profiler, catches Step 2 instantly, and
- is the number that decides row-vs-column layouts in topic 12.
+- **Compute the useful-bytes ratio by hand.** That is Step 2's `U`: bytes your
+ algorithm needs ÷ bytes the machine moved. It needs no profiler, catches the
+ waste instantly, and is the number that decides row-vs-column layouts in
+ topic 12.
## Questions to answer in notes.md when done
-1. Why does `cache_ladder` show *gradual* transitions between plateaus rather than
- steps? (Hint: set associativity + random chain touching multiple sets.)
-2. Predict: on M-series with 128 B lines, at what stride does a strided-read benchmark
- stop getting faster per element? Verify with a quick experiment.
-3. How many memory accesses can a single TLB miss add on a 4-level page table, and why
- don't we see it in `cache_ladder`? (Hint: 16 KB pages, working set vs TLB reach.)
-4. Take `lookup_shootout` at n=1e7 — 9.3 ns per probe over a ~160 MB table — and prove
- with the Step 5 differential (not with reasoning) that it is latency-bound with spare
- MLP rather than bandwidth-bound: make the probes *dependent* (each key derived from
- the previous lookup's result) and report the new ns/probe. Which row of the
+1. Why does `cache_ladder` show *gradual* transitions between plateaus rather
+ than steps? Drepper hit the same thing and diagnosed it in §3.3.2 — Fig 3.15's
+ random curve "keeps on rising" instead of flattening, and Table 3.2 shows why.
+ Which of his two causes apply to a random *cyclic* chain, and which does not?
+2. Using Step 2's formula with `L = 128`, tabulate `U` for strides
+ 8, 16, 32, 64, 128, 256 and 4096 bytes over 8-byte elements. At what stride
+ does `U` stop falling, and why does per-element time keep rising past that
+ point anyway? Verify with a quick strided-read experiment.
+3. How many memory accesses can a single TLB miss add on this machine's page
+ table, and why is `cache_ladder`'s measured addition only 25.6 ns rather than
+ the 312 ns of three cold DRAM loads? (Step 7's four mitigations; name the two
+ that are doing most of the work.)
+4. Take `lookup_shootout` at n=1e7 — 9.3 ns per probe over a ~160 MB table, 9.2%
+ of the bus by Step 6's table — and prove with the Step 5 differential (not
+ with reasoning) that it is latency-bound with spare MLP rather than
+ bandwidth-bound: make the probes *dependent* (each key derived from the
+ previous lookup's result) and report the new ns/probe. Which row of the
profiler table did you just walk down?
+5. Drepper's §6.2.1 blocking uses `SM = CLS / sizeof(double)` with `CLS` from
+ `getconf LEVEL1_DCACHE_LINESIZE` — a cache-*aware* parameter, baked in at
+ compile time. What breaks when that binary runs on a machine with 128-byte
+ lines, and what would a cache-*oblivious* version do instead? (Table 6.2's
+ 17.3% column is what is at stake.)
## Takeaway
-Every table in topic 0 §2 is a compressed version of this paper. Drepper's method —
-plot access cost against working-set size and *explain every inflection* — is the
-habit; the numbers you regenerate yourself on your own machine.
+Every table in topic 0 §2 is a compressed version of this paper. Drepper's
+method — plot access cost against working-set size and *explain every
+inflection* — is the habit; the numbers you regenerate yourself on your own
+machine. His constants are from 2007 and you should never quote them as
+present-day figures; his ratios (80× across the ladder, 50× sequential-to-random,
+22× of prefetch coverage) survived nineteen years and an instruction-set change
+nearly unchanged, and that is the more surprising result.
## Done when
-- [ ] You can recite the latency ladder — L1, L2, L3, DRAM — within 2x, and say which numbers from 2007 have aged and which have not.
+Answer each before unfolding it.
+
+- [ ] You can recite the latency ladder — L1, L2, L3, DRAM — within 2×, and say which numbers from 2007 have aged and which have not.
+
+ Answer
+
+ This machine, measured by `cache_ladder` ([notes.md](notes.md)): **~1 ns** L1
+ (1.02 at 16–128 KB), **~5 ns** L2 (5.3–5.8 at 512 KB–1 MB, and still 7.6–9.0
+ out to 8 MB because Apple's per-cluster L2 is 16 MB-class), **~17 ns** at
+ 16 MB as it falls into the SLC, and **~104 ns** DRAM at 128 MB rising to
+ 113 ns at 512 MB.
+
+ Drepper's, from the unnumbered table at the end of §3.2 (Intel's published
+ Pentium M figures): Register ≤1, L1d ~3, L2 ~14, Main Memory ~240 — in
+ *cycles*, on 2007 hardware, and never to be restated in nanoseconds
+ or as a modern figure.
+
+ What aged: every absolute constant, plus the entire structure around them —
+ DDR2, the front-side bus, inclusive L3, 64-byte lines, 4 kB base pages. What
+ did not: the ratio. His L1d-to-DRAM spread is 240 ÷ 3 = 80×; this machine's is
+ 104 ÷ 1.02 = 102×. Nineteen years of hardware progress made the gap slightly
+ worse, not better, which is the reason the paper is still assigned.
+
+
+
+- [ ] You can compute what fraction of a cache line a strided loop actually uses, and run it on both a row layout and a column layout.
+
+ Answer
+
+ `U = (e × max(1, floor(L / s))) / L`, where `e` is the bytes read per element,
+ `s` the stride between touched elements, and `L` the line size — 64 bytes on
+ Drepper's machines (§6.2.1: "with 64 bytes for the Core 2 processor"), 128 on
+ Apple M-series, which topic 9 established by measuring that 64-byte padding
+ leaves contended counters 1.8× slower than 128-byte padding.
+
+ Row layout, filtering on one 8-byte column of a 128-byte row: `s = 128 ≥ L`,
+ so `n = 1` and `U = 8 / 128 = 6.25%` — 93.75% of every transfer discarded.
+ Column layout, same filter: `s = 8`, `n = floor(128 / 8) = 16`,
+ `U = 128 / 128 = 100%`. Same 8 bytes of answer, 16× fewer bytes moved.
+
+ Drepper's version is the same arithmetic at `L = 64`: the naive
+ `mul2[k][j]` inner loop of §6.2.1 has `s = 8 × 1000 = 8000`, so `U = 12.5%`;
+ transposing to `tmp[j][k]` gives `s = 8` and `U = 100%`. Table 6.2 prices the
+ difference at 16,765,297,870 → 3,922,373,010 cycles, or 23.4% of the original,
+ *including* the cost of copying the whole matrix.
+
+
+
- [ ] You can explain a conflict miss in terms of sets and ways, and construct a stride that causes one on purpose.
-- [ ] You can say why a dependent-load chain is the one latency the prefetcher cannot hide, and why `cache_ladder` is built as a pointer chase for exactly that reason — including all three of its construction choices (data dependency, random *cyclic* permutation, cursor carried across iterations).
+
+ Answer
+
+ A cache holds `sets` buckets of `ways` lines each, and
+ `size = line × ways × sets` (§3.3.1). Middle address bits pick the set, so a
+ line can only be placed in *its* set; a **conflict miss** is an eviction from
+ a full set while the cache as a whole is nearly empty.
+
+ To construct one, find the set-index period, `sets × line size`, and stride by
+ it. Drepper's own 4 MB, 8-way, 64-byte-line L2 has
+ 4,194,304 / (64 × 8) = 8,192 sets — he states this, along with the 13 bits of
+ set index — so the period is 8,192 × 64 = 524,288 B = **512 KB**. Nine
+ addresses spaced 512 KB apart use nine lines, 576 bytes total, and thrash a
+ 4 MB cache, because 9 > 8 ways.
+
+ On this machine, using the measured 128 KB L1d and 128-byte line and *assuming*
+ 8-way: 131,072 / (128 × 8) = 128 sets, period 128 × 128 = 16 KB. That is why
+ the profiler table's differential is "pad the row stride by one line" — 4096 →
+ 4096+128 walks the set index forward instead of landing on it repeatedly.
+ Associativity is what buys this back: Table 3.1's 8 MB row goes 4,731,904 →
+ 2,690,498 misses from direct-mapped to 2-way, a 43.1% saving, and then only
+ 17.9% and 4.4% for the next two doublings.
+
+
+
+- [ ] You can say what the prefetcher can and cannot cover, and how much latency it hides when it works.
+
+ Answer
+
+ §6.3.1's rules: it needs **two or more** misses before it starts, it recognizes
+ strides only within roughly a 512-byte window, it tracks eight to sixteen
+ streams *shared across all cores on that cache*, it does not recognize
+ non-linear patterns at all, and — the hard limit — "it cannot cross page
+ boundaries", because a speculative fetch must never cause a page fault the
+ program did not ask for. So even a perfect sequential scan takes a miss at
+ every page boundary.
+
+ How much it is worth: §3.3.2 says main memory costs "200+ cycles" on that
+ machine, and Fig 3.10's sequential list walk past the 1 MB L2 costs about 9
+ cycles per element. 200 ÷ 9 ≈ **22×** hidden. The same figure shows the walk at
+ ~9 cycles inside the L2 range too, which is faster than the L2's own ~14-cycle
+ latency (§3.2) — prefetching removes the L2 trip from the critical path, not
+ just the DRAM trip.
+
+ When it fails, Fig 3.15 measures the bill: the identical list, shuffled, costs
+ 450+ cycles per element against sequential's ~9 — a **50×** gap. This repo's
+ equivalent pair is 2.24 ns per 128-byte line streaming (topic 12's 57.1 GB/s)
+ against 104 ns per line chased (`cache_ladder` at 128 MB), a **46×** gap.
+ Not the "~10×" this chapter used to claim.
+
+
+
+- [ ] You can say why a dependent-load chain is the one latency the prefetcher cannot hide, and why `cache_ladder` is built as a pointer chase for exactly that reason — including all three of its construction choices.
+
+ Answer
+
+ Because there is nothing to extrapolate *and* nothing to overlap. The
+ prefetcher needs a pattern (Step 4) and the out-of-order core needs a
+ computable address; in `idx = chain[idx]` the next address is the value the
+ previous load is still fetching, so exactly one miss is ever in flight. Every
+ other case in this chapter has some escape: a stride the prefetcher can follow,
+ or several independent addresses the core can issue together.
+
+ The measurement of the difference is on one machine, in one file: the
+ `lookup_shootout` hashmap lane does 1024 *independent* random probes over a
+ ~160 MB table at **9.3 ns** each, while `cache_ladder` does a *dependent* chase
+ over 128 MB at **104 ns** each — 11.2× apart on the same DRAM
+ ([notes.md](notes.md)).
+
+ `cache_ladder`'s three choices, at
+ [`experiments/benches/cache_ladder.rs`](experiments/benches/cache_ladder.rs):
+ (1) line 28, `idx = chain[idx]` — the dependency is in the *data*, so no
+ compiler and no core can reorder around it; (2) lines 16 and 21 — the chain is
+ a random permutation closed into a *single* cycle (Sattolo), where random kills
+ the prefetcher and cyclic stops a short sub-cycle from living in L1 and
+ flattering the large sizes; (3) line 53 — `idx` is declared *outside*
+ `b.iter`, carried across criterion iterations. Without (3) the benchmark
+ re-walked the same 65,536 slots every iteration and reported ~25 ns for
+ "DRAM", having measured an ~8 MB hot path it created itself.
+
+
+
- [ ] You can state the difference between latency-bound and bandwidth-bound, name the one measurement that separates them, and say why a flamegraph never can.
-- [ ] You can compute a TLB's reach from entry count and page size, and explain why exceeding it looks like a second, later cliff.
-- [ ] Given a strange profile, you can name the counter *and* the differential experiment for each of the eight concepts, without re-reading the table.
-- [ ] You wrote answers to all four questions in notes.md.
+
+ Answer
+
+ Latency-bound: waiting on the round trip of an access that could not be started
+ earlier; the bus is idle and the fix is more overlap. Bandwidth-bound: the
+ bytes cannot arrive any faster; the bus is saturated and the fix is moving
+ fewer bytes. The fixes are opposites, so the distinction is not academic.
+
+ The measurement is achieved bandwidth as a fraction of peak. On this machine
+ (peak 150 GB/s, [topic 12 notes.md](../12-columnar-analytics/notes.md)):
+ `cache_ladder` at 128 MB moves one 128-byte line per 104 ns = 1.23 GB/s =
+ **0.82%** of peak — latency-bound with 99% of the bus idle. Topic 12's
+ `scan_bench` moves 800 MB in 0.014 s = 57.1 GB/s = **38%** of peak on a single
+ core — bandwidth-bound. `lookup_shootout` at n=1e7 sits between them at
+ 13.8 GB/s = **9.2%**, which is Question 4.
+
+ A flamegraph cannot separate them because a sampling profiler attributes a
+ stall to the instruction that is waiting, and both cases produce the same
+ picture: one hot load instruction with most of the samples. This repo has the
+ worked example — the `lookup_shootout` flamegraph shows 21% in SipHash and
+ ~79% in one inlined probe loop, and no amount of staring at it splits
+ "hashing" from "waiting on DRAM".
+
+
+
+- [ ] You can compute a TLB's reach from entry count and page size, explain why exceeding it looks like a second, later cliff, and say what huge pages buy.
+
+ Answer
+
+ `Reach = entries × page size`. Drepper deduces both factors experimentally in
+ §3.3.2: Fig 3.12 places one 64-byte list element per page, the cost spikes when
+ the working set reaches 2¹³ bytes (128 pages, against 64 just below), and he
+ concludes "the TLB cache has 64 entries". 64 × 4,096 = **256 KB** of reach.
+
+ It is a *second* cliff because translation is a separate cache from the data
+ caches and the penalties add rather than overlap — §3.3.2: "the address
+ translation penalties are additive to the memory access times". So once the
+ DRAM plateau has already flattened, a further rise can only be translation.
+ That is exactly `cache_ladder`'s tail: 87.4 ns at 64 MB (4,096 pages of 16 KB)
+ to 113 ns at 512 MB (32,768 pages), **+25.6 ns** per access with the data
+ latency unchanged.
+
+ Huge pages buy reach multiplicatively without more entries: 2 MB pages are
+ 2,097,152 / 4,096 = **512×** the reach of 4 kB pages, 1 GB pages are
+ **262,144×**, and Apple's 16 kB base page is **4×** for free on every process.
+ They also shorten the walk (§4.3.2). The price, per §4.3.2, is physical
+ contiguity — "finding a free area with 512 contiguous pages ... can be
+ extremely difficult (or impossible) after the system runs for a while" — which
+ is why Drepper's list of workloads worth the setup cost is short, and
+ "database servers" is on it.
+
+
+
+- [ ] You can explain false sharing in terms of the coherence protocol, and say what padding constant this machine actually needs.
+
+ Answer
+
+ MESI (§3.3.4) tracks state per *cache line*, not per variable. To write, a core
+ must hold the line Exclusive, which means broadcasting a Request For Ownership
+ that invalidates every other copy. Two threads writing two different variables
+ that share one line therefore trade RFOs on every write, even though the
+ program shares nothing — §6.4.1: "This means that a lot of RFO messages are
+ sent, in the worst case one for each write access. So a normal write will be
+ suddenly very expensive."
+
+ Drepper's measurement (Fig 6.10, four P4 sockets, 500 M increments per thread,
+ one line vs separate lines) is 390% / 734% / 1,147% overhead at 2 / 3 / 4
+ threads. This repo's (topic 9) is packed 202.7 ms against pad128 11.4 ms —
+ **17.8×**, i.e. 1,680% — so the pathology got worse, not better, as cores
+ outran interconnects.
+
+ The constant is **128 bytes** on M-series. `pad64` — which is what
+ `#[repr(align(64))]` and most x86-derived `CachePadded` types give you — is
+ still 1.8× slower than `pad128` here, because the coherence granule is 128.
+ And the honest caveat is Drepper's own Fig 6.11: the identical program on a
+ *single* quad-core Core 2 package showed no scaling penalty at all, so the
+ right claim is "false sharing costs whatever the path between the writers
+ costs", and the differential in the profiler table is how you find out what
+ yours is.
+
+
+
+- [ ] Given a strange profile, you can name the counter *and* the differential experiment for each of the ten concepts, without re-reading the table.
+
+ Answer
+
+ The funnel first: IPC (`instructions` ÷ `cycles`) at ≥2 and scaling with cores
+ is compute-bound; under ~1, check branch-miss rate; if branches are clean,
+ check achieved GB/s against peak; if the bus is idle, check dTLB misses; if
+ those are clean too, it is dependent loads.
+
+ Counters, per concept: `cycles,instructions` for the hierarchy and for
+ dependent loads; `cache-references,cache-misses` plus achieved-vs-useful bytes
+ for line waste; `L1-dcache-load-misses` at a small working set for conflict
+ misses; achieved GB/s ÷ peak for the which-wall question;
+ `dTLB-loads,dTLB-load-misses` (x86: `dtlb_load_misses.walk_completed`) for
+ translation; `perf c2c` for coherence and false sharing.
+
+ Differentials, per concept: shrink the dataset (hierarchy); AoS→SoA or shrink
+ the struct (line waste); pad the stride by one line, 4096 → 4096+128 (conflict);
+ sorted vs shuffled index array over identical data (prefetching); run k
+ interleaved cursors and watch per-step time fall ~k× (MLP); compute the
+ utilization table (which wall); enable huge pages or drop under reach
+ (translation); make the shared structure per-thread (RFO); pad each thread's
+ datum to 128 B and re-plot the scaling curve (false sharing). Always pair a
+ counter with a differential — "cache misses are high" is not a diagnosis,
+ because databases miss cache constantly and are fine.
+
+
+
+- [ ] You wrote answers to all five questions in notes.md.
+
+ Answer
+
+ There is no shortcut here, but three of the five have most of their answer in
+ this chapter and are really asking you to run the arithmetic yourself: Q2 is
+ Step 2's `U` formula tabulated over seven strides, Q3 is Step 7's 25.6 ÷ 312 =
+ 8.2% and naming which mitigations explain it, and Q5 is Table 6.2's 17.3%
+ column against a compile-time `CLS`.
+
+ Q1 and Q4 need work at the keyboard. Q1 wants you to hold Drepper's §3.3.2
+ explanation of the non-flattening random curve — rising L2 miss ratio
+ (Table 3.2) plus TLB misses (Fig 3.17) — against a chain that is a *single
+ cycle* over every slot, and decide which of his causes survives that
+ construction. Q4 is a real edit to `lookup_shootout`: derive each key from the
+ previous lookup's result, re-run, and report the new ns/probe against the
+ 9.3 ns baseline. If it lands near `cache_ladder`'s 104 ns you have proved the
+ 9.3 was overlap, and you have walked down row 5 of the profiler table.
+
+
## References
-**Papers**
-- Drepper — "What Every Programmer Should Know About Memory" (Red Hat,
- 2007) — [PDF](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)
- (~114 pages — read §3–§4 properly, skim §6, skip the rest; the study
- guide's advice stands)
+**The paper**
+- Ulrich Drepper — "What Every Programmer Should Know About Memory" (Red Hat,
+ **version 1.0, November 21, 2007**, 114 pages) —
+ [PDF](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf).
+ Every section, figure and table number in this chapter was checked against
+ that PDF. Read §3.3.1–§3.3.4 and §4.1–§4.3 properly, §6.2.1, §6.3.1 and §6.4.1
+ carefully, skim the rest.
+
+| Section / Figure | What this chapter uses it for |
+|---|---|
+| §3.2, page 16 (unnumbered table) | Intel's Pentium M cycles: Register ≤1, L1d ~3, L2 ~14, Main Memory ~240 — Step 1's 2007 ladder |
+| §3.2, Fig 3.4 | "Access Times for Random Writes" — the first working-set sweep; L1d at 2¹³ B, L2 at 2²⁰ B, <10 → ~28 → 480+ cycles. The *shape* `cache_ladder` reproduces |
+| §3.3.1 | `size = line × ways × sets`, and the worked 4 MB/8-way/64 B → 8,192 sets, 13 index bits — Step 3 |
+| §3.3.1, Table 3.1 | L2 misses vs size, associativity and line size; the 43.1% / 17.9% / 4.4% ladder of diminishing returns |
+| §3.3.2, Fig 3.10 | Sequential walk: ~4 cycles in L1d, ~9 past L2 against "200+ cycles" unhidden — Step 4's 22× |
+| §3.3.2, Fig 3.11 | The same walk at NPAD 0/7/15/31 (strides 8/64/128/256 B) — Step 2's utilization, measured |
+| §3.3.2, Fig 3.12 | One element per page; the spike at 2¹³ B and the deduction "the TLB cache has 64 entries" — Step 8's reach |
+| §3.3.2, Fig 3.15 | Sequential vs random, ~9 against 450+ cycles — the 50× gap that corrects this chapter's old "~10×" |
+| §3.3.2, Table 3.2 | L2 miss ratios behind Fig 3.15: 0.94% vs 13.42% at 2²⁰, 4.67% vs 57.84% at 2²⁹ |
+| §3.3.2, Fig 3.17 | Page-wise randomization; limiting TLB working set is worth "up to 38%" |
+| §3.3.4, Fig 3.18 | MESI states and transitions, and the RFO — Step 9 |
+| §3.3.4, Table 3.3 | Multi-thread efficiency: 1.69/1.69/1.54 at two threads, 2.98/2.07/1.65 at four |
+| §3.5.1, Figs 3.24–3.29 | Bandwidth in bytes/cycle: 16 B/cycle in L1d, ~5.3 streaming, with a DTLB step at 2¹⁸ B |
+| §3.5.2, Fig 3.30 | Critical word first / early restart — and the measured effect, about 0.7% |
+| §4.2, Fig 4.2 | The four-level radix tree, 512 entries per 4 kB directory, and the 4 MB flat-table counterexample — Step 7 |
+| §4.3 | "up to four memory accesses", "at the very least 12 cycles", and why the four loads cannot be parallelized |
+| §4.3.2 | Huge pages: 2 MB/4 MB on x86-64, 512 contiguous pages, shorter walks, and "Database servers are an example" |
+| §6.2.1, Table 6.2 | Matrix multiplication: 100% → 23.4% → 17.3% → 9.47%, with cache-*aware* blocking `SM = CLS / sizeof(double)` |
+| §6.2.1, Fig 6.2 | Spreading one element over two cache lines: ~17% penalty in L2, ~27% in DRAM, 25–35% random |
+| §6.3, §6.3.1 | The prefetcher's actual rules: two-miss trigger, ~512 B stride window, 8–16 shared streams, no page crossing, no non-linear patterns — Step 4 |
+| §6.4.1, Figs 6.10, 6.11 | False sharing: 390% / 734% / 1,147% across four sockets — and *no* penalty within one quad-core package — Step 10 |
+| §7.1, §7.2 | oprofile and cachegrind: the 2007 ancestors of the instrument list above |
+
+**Measured in this repo** (Apple M3 Pro, 2026-07-28 — see
+[FINDINGS.md](../../FINDINGS.md))
+
+| Source | Number this chapter uses |
+|---|---|
+| [`notes.md`](notes.md), `cache_ladder` | The whole measured ladder, 1.02 ns at 16 KB to 113 ns at 512 MB; the 87.4 → 113 ns TLB tail; the ~25 ns lying first version |
+| [`notes.md`](notes.md), `lookup_shootout` | 9.3 ns per independent probe at n=1e7 over ~160 MB — the 11.2× MLP fork against the chase |
+| [`notes.md`](notes.md), flamegraph | 21% SipHash / ~79% inlined probe loop — why a flamegraph cannot answer Step 6 |
+| [`experiments/benches/cache_ladder.rs`](experiments/benches/cache_ladder.rs) | Lines 14-23, 25-31 and 50-57 — the three construction choices |
+| [topic 9 `notes.md`](../09-concurrency/notes.md) | packed 202.7 ms vs pad128 11.4 ms = 17.8×; pad64 still 1.8× slower — the 128-byte coherence granule |
+| [topic 12 `notes.md`](../12-columnar-analytics/notes.md) | 800 MB folded in 0.014–0.033 s = 24.2–57.1 GB/s single core, against 150 GB/s peak |
+| [FINDINGS row 3](../../FINDINGS.md) | B-tree lookups 862 → 1101 ns at constant height — residency, not height |
+| [FINDINGS row 9](../../FINDINGS.md) | "Padding 'independent' counters to 128 B is worth **17.8×**; 64 B only half-fixes it on M-series" — Step 10 |
+| [FINDINGS row 12](../../FINDINGS.md) | "The scan floor is **24–57 GB/s** on a 150 GB/s machine" — Step 6's utilization column |
+| [FINDINGS row 0](../../FINDINGS.md) | "The DRAM latency ladder verified at ~1 / 5 / 100 ns … **21%** of a HashMap lookup is SipHash" — Steps 1 and 5 |
**Tools referenced above**
- Brendan Gregg — [perf examples](https://www.brendangregg.com/perf.html) —
the counter-event cookbook behind the profiler table's middle column.
- [`perf c2c(1)`](https://man7.org/linux/man-pages/man1/perf-c2c.1.html) —
- purpose-built false-sharing detection (Step 8); no macOS equivalent, so this
- is one of the cases worth a Linux VM.
+ purpose-built false-sharing detection (Steps 9–10); no macOS equivalent, so
+ this is one of the cases worth a Linux VM.
- Instruments → **CPU Counters** template — the macOS substitute for
`perf stat`; see topic 0 §4 for the full tool table on this machine.
diff --git a/topics/00-performance-toolbox/reading-fair-benchmarking.md b/topics/00-performance-toolbox/reading-fair-benchmarking.md
index de9215f..f607576 100644
--- a/topics/00-performance-toolbox/reading-fair-benchmarking.md
+++ b/topics/00-performance-toolbox/reading-fair-benchmarking.md
@@ -3,29 +3,38 @@
Criterion and Tene cover how a *single measurement* lies; this chapter — built
on a 6-page DBTest '18 paper from the future DuckDB authors — covers how a
*comparison between systems* lies. Before pointing you at the paper, it builds
-the idea of a fair comparison from zero and then walks the eight pitfalls one
-at a time: what each one is, a concrete example of how it lies, and how to
-avoid it. It is the database-specific companion to topic 0 §1, and the paper's
-Appendix A checklist is an artifact you will reuse against every capstone
-comparison in this curriculum.
+the idea of a fair comparison from zero, pins down the one experimental setup
+every demonstration in the paper reuses, and then walks the eight pitfalls one
+at a time: what each is, the paper's own measured example of it lying, and how
+to avoid it. It is the database-specific companion to topic 0 §1, and the
+paper's Appendix A checklist is an artifact you will reuse against every
+capstone comparison in this curriculum.
+
+Every figure below is quoted from the paper with the section, figure or table
+it appears in. Where this repo has measured the same failure mode itself, the
+repo's number is cited instead of a borrowed one.
## The problem in one sentence
Van der Kouwe's survey found benchmarking crimes in **96%** of 50 top-tier
-systems papers, and Purohith et al. showed SQLite throughput varies **28×**
-on one configuration parameter that 0 of 16 surveyed papers reported — so
-"system A is 3× faster than system B" is, by default, a statement about the
-experimenters, not the systems.
+systems papers (§2.1), and Purohith et al. showed SQLite transaction
+throughput varies by a factor of **28** on a single parameter setting that
+**none of 16** surveyed papers reported (§2.2) — so "system A is 3× faster
+than system B" is, by default, a statement about the experimenters, not the
+systems.
## The concepts, step by step
### Step 1 — what a fair comparison even requires
+> **In:** nothing yet — this step builds the frame the eight pitfalls hang on.
+> **Out:** four requirements, each of which one or more pitfalls violates.
+> Step 2 supplies the experimental setup that demonstrates them.
+
A benchmark comparison is fair only when the *systems* are the only variable —
-everything else (data, tuning effort, machine state, what gets timed, and
-correctness of the answers) is held equal. That decomposes into four
-requirements, and each of the eight pitfalls below is a failure of one of
-them:
+everything else (the data, the tuning effort, the machine state, what gets
+timed, and the correctness of the answers) is held equal. That decomposes into
+four requirements:
```mermaid
flowchart TD
@@ -34,193 +43,514 @@ flowchart TD
Q --> CMP["Comparison"]
Q --> MEA["Measurement"]
Q --> RES["Results"]
- SU --> P1["3.1 non-reproducible
(the Escher result)"]
- SU --> P2["3.2 untuned baseline
(debug build, default config)"]
+ SU --> P1["3.1 non-reproducible
(the Escher result, Fig. 2)"]
+ SU --> P2["3.2 failure to optimize
(debug build, default config)"]
CMP --> P3["3.3 apples vs oranges
(kernel vs full system)"]
- CMP --> P4["3.4 tuned to the benchmark
(known selectivities)"]
- MEA --> P5["3.5 cold/hot conflated"]
- MEA --> P6["3.6 restart ≠ cold
(OS page cache warm)"]
+ CMP --> P4["3.4 overly-specific tuning
(known selectivities)"]
+ MEA --> P5["3.5 cold and hot conflated"]
+ MEA --> P6["3.6 restart is not cold
(OS page cache stays warm)"]
MEA --> P7["3.7 preprocessing ignored
(index build, auto-imprints)"]
- RES --> P8["3.8 fast but wrong
(diff against a trusted engine)"]
+ RES --> P8["3.8 incorrect code wins
(diff against a trusted engine)"]
```
-- **Setup** must be reproducible and *both* systems must be tuned with equal
- effort (pitfalls 3.1, 3.2).
-- **Comparison** must pit like against like — same functionality, workloads
- neither system was specifically fitted to (3.3, 3.4).
-- **Measurement** must control machine state — hot vs cold caches — and time
- *all* the work, including preparation (3.5, 3.6, 3.7).
-- **Results** must be verified correct, because a wrong answer is free (3.8).
+- **Setup** must be reproducible, and *both* systems must be tuned with equal
+ effort (pitfalls 3.1, 3.2 — Steps 3 and 4).
+- **Comparison** must pit like against like: the same functionality, on
+ workloads neither system was specifically fitted to (3.3, 3.4 — Steps 5, 6).
+- **Measurement** must control machine state — hot against cold caches — and
+ time *all* the work, including preparation (3.5, 3.6, 3.7 — Steps 7, 8, 9).
+- **Results** must be verified correct, because a wrong answer is free (3.8 —
+ Step 10).
+
+Two definitions the paper leans on from the start. **TPC-H** is the standard
+decision-support benchmark: a fixed schema, 22 queries, and generated data at
+a chosen **scale factor** — SF1 means roughly 1 GB of data. And Jain's
+distinction, quoted at §2.1, frames the whole list: **mistakes** are
+"ill-advised but inadvertent choices", while **games** are "deliberate and
+purposeful manipulation of the experiment to elicit a specific outcome". The
+checklist catches both, which matters because you cannot tell them apart from
+the outside — and you are far more likely to commit the first.
+
+Why it matters: every pitfall below is a failure of exactly one of those four
+requirements, which is what makes eight separate mistakes a single checklist.
+
+### Step 2 — the one setup that produced every number in the paper
+
+> **In:** the four requirements from Step 1.
+> **Out:** the machine, the system versions and the reporting standard behind
+> every figure quoted in Steps 3–10 — without which none of them mean
+> anything, which is itself pitfall 3.1.
+
+The paper practises what Step 3 preaches, and states its setup once (§3
+preamble):
+
+| Component | What |
+|---|---|
+| CPU | Intel i7-2600K at 3.40 GHz, **one** of its eight hardware threads used |
+| Memory | 16 GB |
+| OS | Fedora 26, Linux kernel 4.14 |
+| Compiler | GCC 7.3.1 |
+| Systems | MariaDB 10.2.13, MonetDB 11.27.13, SQLite 3.20.1, PostgreSQL 9.6.1 |
+| Workload | mock TPC-H at SF1, single-threaded |
+| Reporting | median with non-parametric, quantile-based 95% confidence intervals |
+| Artifacts | scripts, results, configs and plotting code, all published |
+
+Single-threaded is a deliberate fairness choice, stated in the preamble: not
+every system supports intra-query parallelism, so giving all of them one
+thread removes a variable rather than testing one.
+
+Two terms in that last row. A **confidence interval** is a range that would
+contain the true value in a stated fraction of repeated experiments — a 95% CI
+means 95 of 100 repetitions. **Non-parametric** means it is computed without
+assuming the measurements follow any particular distribution, here by taking
+quantiles of the observed runs directly. That is the same philosophy as
+criterion's bootstrap intervals, applied to whole-system runs instead of
+function calls: do not assume normality of something you can resample.
+
+The paper also states, in the same preamble, that including a system in these
+experiments implies nothing about the papers that used it. The systems are
+props.
+
+Why it matters: Steps 3–10 quote a dozen timings. Every one of them is *this*
+machine, *these* versions, SF1, one thread. A number from this paper carried
+into an argument about your hardware is a fresh instance of pitfall 3.1.
+
+### Step 3 — pitfall 3.1: non-reproducibility
+
+> **In:** the setup from Step 2.
+> **Out:** the Escher result — a ranking that cycles — and the size of the
+> single hidden decision that produced it.
-The paper's demonstrations all use one setup: mock TPC-H experiments
-(TPC-H is the standard decision-support benchmark — fixed schema, 22 queries,
-generated data at a scale factor; SF1 ≈ 1 GB) at SF1 against MariaDB,
-PostgreSQL, SQLite, and MonetDB, single-threaded. Jain's classic distinction
-frames the whole list: *mistakes* (accidental) vs *games* (deliberate) — the
-checklist catches both.
+A result is reproducible only if someone else can rerun it from the published
+hardware description, configuration, code and data. Most papers publish none
+of these, and §3.1 notes the aggravating habit of anonymising systems as
+"DBMS-X" to avoid a vendor's legal department: even a reader who owns every
+system cannot tell which one was measured.
-### Step 2 — pitfall 3.1: non-reproducibility
+The demonstration is the **Escher result** (Fig. 2, TPC-H Q1 at SF1) — three
+pairwise comparisons, every measurement individually true, that together form
+a cycle:
-A result is reproducible only if someone else can rerun it from the published
-hardware description, configuration, code, and data — and most papers publish
-none of these. The paper's demonstration is the **Escher result** (Fig. 2):
-MariaDB < PostgreSQL < SQLite < MariaDB\* — a cycle where every measurement
-is individually "true". The trick: MariaDB\* stored columns as DOUBLE instead
-of DECIMAL — *both* allowed by the TPC-H spec, invisible unless the full
-setup is published, and worth enough to reorder the ranking.
+```
+Fig. 2, median seconds: MariaDB 12.18
+ Postgres 9.73
+ SQLite 8.19
+ MariaDB* 4.70
+
+panel 1: Postgres 9.73 < MariaDB 12.18 → P beats M (12.18/9.73 = 1.25×)
+panel 2: SQLite 8.19 < Postgres 9.73 → S beats P ( 9.73/8.19 = 1.19×)
+panel 3: MariaDB* 4.70 < SQLite 8.19 → M beats S ( 8.19/4.70 = 1.74×)
+```
-**How to avoid it:** publish hardware, all configuration parameters, scripts,
-and data-generation steps. If a reader can't rebuild the experiment, the
-number is an anecdote.
+So M < P, P < S, S < M: MariaDB is both the slowest system in the paper and
+the fastest, "a contradiction similar to the famous paintings by M.C. Escher".
-### Step 3 — pitfall 3.2: failure to optimize the baseline
+The hidden decision is one schema choice: MariaDB\* stored the `lineitem`
+money columns as `DOUBLE` instead of `DECIMAL`, and MariaDB's decimal
+implementation is inefficient. **Both spellings are allowed by the TPC-H
+specification** (§3.1 cites [2, sec. 1.3]), so neither run is cheating. Do the
+division the paper does not print:
+
+```
+one schema decision, same system: 12.18 / 4.70 = 2.59×
+the largest gap it manufactures: 8.19 / 4.70 = 1.74×
+```
+
+The undisclosed choice is worth **more than any of the three system gaps it
+was used to create**. That is the general shape of this pitfall: the
+unpublished variable does not add noise to the comparison, it dominates it.
+
+**How to avoid it:** publish the hardware, every configuration parameter, the
+source or binaries, and the data-generation steps — §3.1's list also names the
+OS, how the server was installed, and its version. If a reader cannot rebuild
+the experiment, the number is an anecdote.
+
+### Step 4 — pitfall 3.2: failure to optimize the baseline
+
+> **In:** the Step 2 setup, now run twice per system — once as an author would
+> configure their own system, once as they would configure a competitor's.
+> **Out:** two measured gaps that are entirely artifacts of build and config.
The baseline system is *the author's competitor*, so nobody spends a week
-tuning it — and defaults are terrible. The paper's numbers: MonetDB compiled
-in debug mode runs TPC-H Q1 in 1.58 s vs 0.87 s for a release build (1.8×
-from a compiler flag); PostgreSQL with default configuration runs Q9 in
-0.47 s vs 0.27 s once its memory settings are configured (1.7×). "DBMS A
-beats DBMS B" can literally be the same system measured twice.
+tuning it. §3.2 states the incentive plainly: "the worse the state of the art
+system does, the better the authors' system looks." Two measurements:
+
+```
+Fig. 3a (Q1) MonetDB debug build 1.58 s
+ MonetDB* release build 0.87 s 1.58 / 0.87 = 1.82×
+Fig. 3b (Q9) Postgres default config 0.47 s
+ Postgres* configured 0.27 s 0.47 / 0.27 = 1.74×
+```
+
+Both gaps are between a system and *itself*. The debug build is not merely
+"unoptimized": §3.2 explains that MonetDB's debug mode enables sanity-checking
+code that **scans entire columns** to verify invariants — work that has
+nothing to do with answering the query. Postgres's default configuration
+predates the machine it is running on and does not use the available memory.
+
+An author who published either as "DBMS A beats DBMS B" would have published a
+compiler flag.
**How to avoid it:** tune both systems with documented, comparable effort —
-release builds, memory settings sized to the machine — and publish the
-configs (Step 2).
+release builds, memory settings sized to the machine — and publish the configs,
+which is Step 3's rule again. The Appendix A checklist splits this into exactly
+two boxes, *compilation flags* and *system parameters*, because they fail
+independently.
+
+### Step 5 — pitfall 3.3: apples against oranges
-### Step 4 — pitfall 3.3: apples vs oranges
+> **In:** the Step 4 numbers, now compared against a program that is not a
+> database at all.
+> **Out:** the largest single gap in the paper, and the reason it is meaningless.
+
+A comparison is fair only if both systems perform the same functionality.
+§3.3 lists what a real DBMS carries that a standalone program does not:
+arbitrary queries, transaction isolation, updates, and multiple concurrent
+clients. The paper hand-writes TPC-H Q1 as a standalone program, names it
+**TimDB**, and measures it against the fairly feature-complete MonetDB:
+
+```
+Fig. 3c (Q1) MonetDB (release) 0.87 s
+ 'TimDB' 0.03 s 0.87 / 0.03 = 29×
+```
-Comparing a stripped-down kernel against a full system credits the kernel for
-all the work it simply doesn't do. The paper hand-writes TPC-H Q1 as a
-standalone C++ program ("TimDB"): 0.03 s vs MonetDB's 0.87 s — 29× "faster",
-because it skips parsing, query optimization, transactions, overflow
-checking, and concurrency control. Any research prototype missing features is
-structurally TimDB.
+Note which MonetDB that is: the *tuned* 0.87 s from Fig. 3a, not the 1.58 s
+debug build. The paper is being scrupulous — and the pitfalls still compound
+if you are not:
-**How to avoid it:** compare full system vs full system; where feature gaps
-exist, state them explicitly next to the numbers; verify both produce
+```
+debug MonetDB against a hand-written kernel: 1.58 / 0.03 = 52.7×
+```
+
+29× of that is pitfall 3.3 and a further 1.8× is pitfall 3.2, and a paper
+committing both would report 53× without either being a lie about arithmetic.
+
+§3.3 also names the subtle version: **overflow handling**. Guaranteeing correct
+results regardless of the stored data requires either *overflow checking*
+(test each arithmetic result) or *overflow prevention* (prove from the data's
+range that none can occur). An implementation with neither is faster and is
+not comparable. Any research prototype missing features is structurally TimDB.
+
+**How to avoid it:** compare full system against full system; ideally integrate
+the new algorithm into a complete system before measuring it. Where feature
+gaps remain, state them next to the numbers, and verify both systems produce
identical results.
-### Step 5 — pitfall 3.4: overly-specific tuning
+### Step 6 — pitfall 3.4: overly-specific tuning
+
+> **In:** a standardized benchmark, whose every property is published.
+> **Out:** a system whose advantage exists only on that benchmark.
Tuning to the benchmark means fitting the *system* to the test's known
-properties, so the number stops generalizing. TPC-H's selectivities (the
-fraction of rows each filter keeps) and cardinalities (result sizes) are
-published constants — so a join-order heuristic can be quietly tuned until
-exactly those 22 queries win, while everything else regresses.
+properties, so the number stops generalizing. §3.4 lists what TPC-H and TPC-C
+publish up front: the workload, the **cardinalities** of intermediate results
+(how many rows each step produces), the data distributions, the
+**selectivities** of predicates (the fraction of rows a filter keeps), and the
+number of groups an aggregation creates. With all of that known, join-order
+heuristics can be tuned until exactly those 22 queries win, and data can be
+sharded so the work splits evenly *for this benchmark*.
+
+The failure is invisible from inside the benchmark: the system is genuinely
+faster on it, and genuinely slower on the similar queries the benchmark does
+not contain.
+
+**How to avoid it:** run more experiments than the standardized suite — §3.4's
+advice is that the standard benchmark is a good *baseline* comparison, with a
+set of different queries measured alongside it. Be suspicious of any advantage
+that evaporates off-benchmark.
+
+### Step 7 — pitfall 3.5: conflating cold and hot runs
+
+> **In:** repeated runs of one query on one system.
+> **Out:** two distinct populations of measurement that must not be pooled.
+
+A **cold run** is the first execution, with nothing cached; a **hot run** has
+the data already resident. §3.5 names all four reasons the first is slower:
+data must be read from persistent storage, the query must be parsed and
+compiled, the buffer pool is empty, and any plan cache is cold. Averaging the
+two produces a number describing neither — first-query-of-the-morning and
+query-in-a-loop are different user experiences, and both are real.
+
+**How to avoid it:** report cold and hot *separately*. The checklist's box for
+hot runs is "ignore initial runs", which is criterion's warm-up (Step 2 of the
+criterion chapter) formalized at the system level: the same idea, one layer up.
+
+### Step 8 — pitfall 3.6: restarting the server is not a cold run
+
+> **In:** the cold-run protocol from Step 7.
+> **Out:** what stays warm across a restart, and the only protocol that
+> actually clears it.
+
+Subtler than Step 7, and §3.6 gives it its own section because the usual
+protocol is wrong. Restarting the database server does **not** produce a cold
+run: the operating system uses spare main memory as a cache of disk blocks —
+the **page cache** — and that cache belongs to the kernel, not to the process,
+so it survives the restart entirely. A restarted server reads its "disk" data
+out of RAM.
+
+The paper's correct protocol, from §3.6 and its footnote 2:
-**How to avoid it:** also run queries *outside* the benchmark suite, and be
-suspicious of any system whose advantage evaporates off-benchmark.
+```
+per cold measurement:
+ stop the database server
+ echo 3 > /proc/sys/vm/drop_caches # root, recent Linux
+ start the server
+ run and time exactly ONE query
+ repeat
+```
-### Step 6 — pitfall 3.5: conflating cold and hot runs
+One query per cycle, because the second query is by definition hot again. §3.6
+also notes the cloud problem: caching also happens on the virtualization host,
+where you cannot drop it, so the only option may be to start a fresh virtual
+machine — which makes honest cold numbers "very time-consuming and
+inconvenient".
-A **cold run** starts with empty caches (first query after boot); a **hot
-run** has data already cached from previous queries. They can differ by an
-order of magnitude, and averaging them produces a number that describes
-neither — first-query-of-the-morning and query-in-a-loop are different
-user experiences.
+**How to avoid it:** flush the OS cache explicitly, per measurement, and treat
+any cloud "cold" number as warm until proven otherwise.
-**How to avoid it:** report cold and hot *separately*; for hot numbers,
-discard the initial iterations — this is criterion's warm-up, formalized at
-the system level.
+### Step 9 — pitfall 3.7: ignoring preprocessing time
-### Step 7 — pitfall 3.6: restart ≠ cold
+> **In:** the timed window from Steps 7 and 8.
+> **Out:** the work that happens outside it, and the two ways a system gets it
+> for free.
-Subtler than Step 6: restarting the database server does **not** produce a
-cold run, because the OS keeps its own file cache (the page cache) that
-survives the process — the restarted server reads "disk" data straight from
-the OS's RAM. True cold on Linux = stop server, `echo 3 >
-/proc/sys/vm/drop_caches`, start server, run *one* query, repeat the whole
-cycle per measurement. (Nearly impossible in the cloud — the hypervisor
-caches too, and you can't flush it.)
+Excluding preparation — loading, format conversion, index construction — from
+the timed window rewards whichever system shifts the most cost into it. §3.7's
+statement of the bias: spending more time on index creation generally produces
+a faster index, so discarding creation time gives expensive-to-build,
+efficient indices an unfair advantage over cheap-to-build, less efficient ones.
-**How to avoid it:** flush the OS cache explicitly per cold measurement, and
-treat any cloud "cold" number as warm until proven otherwise.
+The trap doubles when the preprocessing is *automatic*, and §3.7 gives two
+MonetDB examples:
-### Step 8 — pitfall 3.7: ignoring preprocessing time
+- **Imprints** — a lightweight per-column min/max index that MonetDB builds
+ automatically the first time a range filter touches a column. Subsequent
+ range queries on that column are significantly faster.
+- **Dictionary encoding** — string columns are stored at load time as integer
+ offsets into a heap with duplicates eliminated, so string equality in a query
+ becomes integer comparison.
-Excluding preparation work (index builds, data loading, format conversion)
-from the timed window rewards systems that shift the most cost into it — an
-expensive-to-build index looks free. The trap doubles with *automatic*
-preprocessing: MonetDB builds imprints (a min/max index) on the first range
-filter and dictionary-encodes strings at load time — so a "cold" first-query
-timing silently *includes* that work for MonetDB while a competitor's
-equivalent work happened invisibly at load.
+Both mean the *first* query pays for an index the later queries enjoy. Discard
+the first query as a "cold run" (Step 7) and the index becomes free; keep it
+and you charge MonetDB for work a competitor did invisibly at load time. The
+two pitfalls interact, which is why they are adjacent in the paper.
-**How to avoid it:** either time preprocessing and report it, or verify both
-systems did equivalent preparation before the timed window opens.
+**How to avoid it:** §3.7's rule is symmetry — either create indexes for both
+systems or for neither — plus explicit wariness of automatic index creation.
+Where preprocessing is timed, report it.
-### Step 9 — pitfall 3.8: incorrect code wins
+### Step 10 — pitfall 3.8: incorrect code wins
-A fast wrong answer beats every correct system, and nothing in a timing
-harness notices. Skipped overflow handling, hardcoded group counts, missed
-edge cases — each buys speed and produces plausible-looking output.
+> **In:** every number produced by Steps 3–9.
+> **Out:** the check that has to happen before any of them count.
-**How to avoid it:** diff every result set against a trusted engine, every
-run. Correctness checking is part of the benchmark, not a separate activity.
+A fast wrong answer beats every correct system, and nothing in a timing
+harness notices. §3.8 separates two flavours. An outright bug produces wrong
+results and is often *faster* because the bug means less data is touched — and
+if the experiment is not reproducible (Step 3), nobody will ever find it. The
+subtler flavour is a program correct only for the data it was tested on:
+neglected overflow handling (Step 5's term), or hardcoding the number of
+groups an aggregation produces.
+
+**How to avoid it:** §3.8 is specific — compare output against reference
+answers, taken either from the benchmark specification or from running the
+same query on a well-tested RDBMS such as SQLite or PostgreSQL, and check that
+the results stay correct **when the data changes**. Correctness checking is
+part of the benchmark, not a separate activity.
## How to read the paper (with the concepts in hand)
Six pages, one evening:
-- **§1–2** Intro + related work — skim, but note the gems: Jain's *mistakes
- vs games* distinction (Step 1); Hoefler & Belli's 12 HPC benchmarking
- rules; van der Kouwe's 96%-of-50-papers survey; Purohith et al.'s 28×
- SQLite parameter that 0 of 16 papers reported.
-- **§3** The eight pitfalls (Steps 2–9), each with its mock TPC-H SF1
- experiment — read carefully; the numbers quoted above all live here.
-- **§4 + Appendix A** Conclusions + **the checklist** — the artifact you'll
- reuse against every comparison in this repo.
-
-Methodology to steal from the §3 preamble: their own reporting standard is
-**median + non-parametric quantile-based 95% confidence intervals**, all
-scripts/configs/plots public — the same philosophy as criterion's bootstrap
-CIs, applied to system-level runs.
+- **§1–2** Intro and related work — skim, but note the gems: Jain's *mistakes
+ against games* distinction (§2.1, Step 1); Hoefler and Belli's 12 HPC
+ benchmarking rules, derived from issues in 120 HPC papers, including their
+ point that averages are only valid when there is no variance — "which is
+ almost never the case in benchmarking" (§2.1); van der Kouwe's 96%-of-50
+ survey (§2.1); Purohith et al.'s factor-of-28 SQLite parameter that none of
+ 16 papers reported (§2.2).
+- **§3 preamble** The setup of Step 2 — read it before any figure.
+- **§3.1–3.8** The eight pitfalls (Steps 3–10), with the mock TPC-H SF1
+ experiments in Figures 2 and 3. Every number quoted above lives here.
+- **§4 and Appendix A** Conclusions and **the checklist** — the artifact you
+ will reuse against every comparison in this repo.
+
+Appendix A's eight groups, condensed:
+
+| Group | Boxes |
+|---|---|
+| Choosing your benchmarks | covers the evaluation space; subset justified; stresses the relevant functionality |
+| Reproducible | hardware config; DBMS parameters and version; source or binaries; data, schema and queries |
+| Optimization | compilation flags; system parameters |
+| Apples vs apples | similar functionality; equivalent workload |
+| Comparable tuning | different data; various workloads |
+| Cold/warm/hot runs | cold and hot differentiated; cold runs flush OS and CPU caches; hot runs ignore initial runs |
+| Preprocessing | preprocessing equal between systems; aware of automatic index creation |
+| Ensure correctness | verify results; test different data sets; corner cases work |
+| Collecting results | several runs; check standard deviation; report robust metrics (median and CIs) |
## Connections to this repo
- The capstone's M4 backend shootout and M22 LDBC 3-way FalkorDB comparison
- must pass Appendix A — especially 3.2 (tune the *reference* FalkorDB
- properly, Step 3) and 3.3 (a young engine missing features is structurally
- "TimDB", Step 4 — say so explicitly next to numbers).
-- FalkorDB/benchmark audit overlaps: no warmup (3.5 / Step 6), timeout
+ must pass Appendix A — especially *optimization* (tune the *reference*
+ FalkorDB properly, Step 4) and *apples vs apples* (a young engine missing
+ features is structurally TimDB, Step 5 — say so explicitly next to the
+ numbers).
+- FalkorDB/benchmark audit overlaps: no warmup (3.5, Step 7), timeout
asymmetry (3.3-ish), uniform keys (3.4's cousin — tuning the *workload* to
flatter caches).
-- 3.7 (Step 8) is why M0's `workload` crate measures generation throughput
+- 3.7 (Step 9) is why M0's `workload` crate measures generation throughput
separately from engine time.
+- This repo has caught two of these on itself, and both are in
+ [FINDINGS.md](../../FINDINGS.md): topic 12's scan lane once printed
+ **19,047,619 GB/s** from a hoisted timing loop — pitfall 3.8, a wrong answer
+ that was very fast — and now reports **24–57 GB/s** on a 150 GB/s machine.
+ Topic 6's mmap lane reports p50 **42 ns** against a max of **182 µs**, a
+ 4300× spread that a mean would have hidden — Hoefler and Belli's point about
+ averages, measured locally.
## Questions to answer in notes.md
-1. Which Appendix A checklist items does FalkorDB/benchmark currently fail? (I count at
- least four — list them.)
-2. The paper reports medians + CIs; Tene demands full percentile curves + max. When is
- each right? (Hint: throughput-style repeated identical runs vs latency under load.)
-3. Which "automatic preprocessing" (3.7) exists in FalkorDB that a fair Neo4j
- comparison must account for?
+1. Which Appendix A checklist items does FalkorDB/benchmark currently fail? (I
+ count at least four — list them, with the box each one misses.)
+2. The paper reports medians with quantile-based CIs; Tene demands full
+ percentile curves and the max. When is each right? (Hint: repeated identical
+ runs of one query, against latency under sustained load.)
+3. Which "automatic preprocessing" (3.7, Step 9) exists in FalkorDB that a fair
+ Neo4j comparison must account for?
+4. Step 3's arithmetic showed one undisclosed schema choice was worth 2.59×,
+ more than any system gap it produced. Name an undisclosed variable in this
+ repo's own lanes that could be worth more than the effect being measured,
+ and say how you would publish it.
+5. `verify.sh` publishes every lane's command and every generator is seeded.
+ Which Appendix A boxes does that tick, and which does it leave open?
## Takeaway
-Appendix A is a reusable review checklist: benchmarks chosen + justified; reproducible
-(hardware, params, code, data); both systems optimized; same functionality; cold/hot
-separated and correctly collected; preprocessing equalized; results verified; medians +
-CIs over several runs. Pin it next to every capstone `notes.md` comparison.
+Appendix A is a reusable review checklist: benchmarks chosen and justified;
+reproducible (hardware, params, code, data); both systems optimized; same
+functionality; cold/hot separated and correctly collected; preprocessing
+equalized; results verified; medians and CIs over several runs. Pin it next to
+every capstone `notes.md` comparison.
## Done when
+Answer each before unfolding it.
+
- [ ] You can name all eight pitfalls without the paper open.
+
+ Answer
+
+ Grouped by what they break (Step 1): **setup** — 3.1 non-reproducibility,
+ 3.2 failure to optimize the baseline; **comparison** — 3.3 apples against
+ oranges, 3.4 overly-specific tuning; **measurement** — 3.5 conflating cold
+ and hot runs, 3.6 a restart is not a cold run, 3.7 ignoring preprocessing
+ time; **results** — 3.8 incorrect code wins.
+
+ The grouping is the recall aid: four requirements for a fair comparison, and
+ eight ways to fail them.
+
+
+
- [ ] You can explain why "failure to optimize the baseline" (3.2) is the one that invalidates a result rather than merely weakening it.
+
+ Answer
+
+ Because both sides of the comparison can be the *same system*. Fig. 3a
+ measures MonetDB against MonetDB — 1.58 s debug against 0.87 s release, 1.82×
+ — and Fig. 3b measures Postgres against Postgres, 0.47 s against 0.27 s,
+ 1.74×. Neither gap contains any information about a system's design. A paper
+ reporting a 1.8× win over a baseline it built in debug mode has reported a
+ compiler flag.
+
+ It invalidates rather than weakens because the incentive runs one way. §3.2
+ says it: the author has "very little incentive to properly optimize the
+ current system", so the error is not noise around the truth, it is a bias
+ that always points at the author's conclusion. A weakness makes a result less
+ certain; a systematic bias makes it uninformative.
+
+
+
- [ ] You can state the difference between a restarted process and a cold system (3.6), and name what stays warm across a restart.
+
+ Answer
+
+ Restarting drops everything the *process* owned — buffer pool, plan cache,
+ any in-process state. It drops nothing the *kernel* owns, and the kernel owns
+ the page cache: spare RAM holding recently read disk blocks. The restarted
+ server therefore reads its data out of memory while believing it read from
+ disk, and reports a "cold" number that is warm.
+
+ The protocol that actually works (§3.6, footnote 2) is: stop the server,
+ `echo 3 > /proc/sys/vm/drop_caches` as root, start the server, run and time
+ exactly one query, repeat — one query per cycle, because the second is hot
+ again. In a cloud VM even that fails, because the virtualization host caches
+ too and you cannot reach it; the paper's only suggestion there is to start a
+ fresh virtual machine.
+
+
+
- [ ] You can identify which pitfall each of this repo's own lanes is most exposed to — start with topic 6's mmap handicap and topic 12's bandwidth variance.
-- [ ] You wrote answers to all three questions in notes.md, including the FalkorDB checklist audit.
+
+ Answer
+
+ Topic 6's mmap lane reports p50 **42 ns** and a max of **182 µs**
+ ([FINDINGS.md](../../FINDINGS.md) row 6) — a 4300× spread that is almost
+ entirely minor page faults. Its exposure is 3.5/3.6: whether the pages were
+ already resident decides the whole distribution, so any figure from it that
+ does not say which is a cold/hot conflation. It is also the case Hoefler and
+ Belli warn about at §2.1 — a mean over that spread describes nothing.
+
+ Topic 12's scan lane reports **24–57 GB/s** on a 150 GB/s machine, and the
+ same lane once printed **19,047,619 GB/s** from a hoisted timing loop. That
+ is pitfall 3.8 exactly: incorrect code was very fast, and only an
+ implausibility check caught it. Its standing exposure now is 3.3 — a
+ hand-written scan kernel measured against anything that also parses a query
+ is TimDB.
+
+ More generally: every lane that measures this repo's own code against a
+ published number inherits 3.1, because the published number's setup (Step 2)
+ is rarely stated in as much detail as `verify.sh` states ours.
+
+
+
+- [ ] You wrote answers to all five questions in notes.md, including the FalkorDB checklist audit.
+
+ Answer
+
+ There is no answer to unfold here — the checklist audit is the exercise. The
+ bar: for each of the four requirements in Step 1, name the specific
+ FalkorDB/benchmark behaviour that fails it and the Appendix A box it misses.
+ An audit that finds nothing has usually confused "I could not reproduce their
+ setup" (pitfall 3.1, a finding) with "their setup is fine".
+
+
## References
**Papers**
- Raasveldt, Holanda, Gubner, Mühleisen — "Fair Benchmarking Considered
- Difficult: Common Pitfalls in Database Performance Testing" (DBTest
- 2018) —
+ Difficult: Common Pitfalls in Database Performance Testing" (DBTest 2018) —
[PDF](https://hannes.muehleisen.org/publications/DBTEST2018-performance-testing.pdf)
— 6 pages, one evening; read §3 carefully, Appendix A is the reusable
- artifact. (CWI — Raasveldt & Mühleisen later created DuckDB.)
+ artifact. (CWI — Raasveldt and Mühleisen later created DuckDB.)
+
+| Section | What this chapter took from it |
+|---|---|
+| §2.1 | Jain's mistakes/games distinction; Hoefler & Belli's 12 rules over 120 HPC papers; van der Kouwe's 96% of 50 papers |
+| §2.2 | Purohith et al.: SQLite throughput varies by 28×, none of 16 papers reported the parameter |
+| §3 preamble | the hardware, versions, single-thread choice and median-with-95%-CI reporting standard (Step 2) |
+| §3.1, Fig. 2 | the Escher result: 12.18 / 9.73 / 8.19 / 4.70 s, and the DOUBLE-instead-of-DECIMAL schema choice, both TPC-H-legal |
+| §3.2, Fig. 3a-b | MonetDB 1.58 → 0.87 s (debug scans whole columns); Postgres 0.47 → 0.27 s |
+| §3.3, Fig. 3c | MonetDB 0.87 s against hand-written 'TimDB' 0.03 s; overflow checking vs prevention |
+| §3.4 | what a standardized benchmark publishes: selectivities, cardinalities, group counts |
+| §3.5 | why the first run is slower: storage, parse/compile, buffer pool, plan cache |
+| §3.6 + fn. 2 | the page cache survives a restart; `drop_caches`; the cloud has no equivalent |
+| §3.7 | MonetDB's automatic imprints and load-time dictionary encoding |
+| §3.8 | verify against a reference engine, and re-verify when the data changes |
+| Appendix A | the checklist, condensed in the table above |
**Code**
- [pholanda/FairBenchmarking](https://github.com/pholanda/FairBenchmarking)
- — the paper's experiment scripts and configs
+ — the paper's experiment scripts and configs, the artifact §3 preamble
+ promises.
diff --git a/topics/00-performance-toolbox/reading-redis-benchmark.md b/topics/00-performance-toolbox/reading-redis-benchmark.md
index 145d6b2..a0673e0 100644
--- a/topics/00-performance-toolbox/reading-redis-benchmark.md
+++ b/topics/00-performance-toolbox/reading-redis-benchmark.md
@@ -2,72 +2,132 @@
The load generator you'll imitate — and the mistake you'll avoid. In one
dependency-free file, redis-benchmark shows a masterclass in cheap pipelining
-(one pre-built buffer, patched in place) and, in the same 2000 lines, the
+(one pre-built buffer, patched in place) and, in the same 2028 lines, the
canonical case of coordinated omission: a closed loop that measures service
time and calls it latency. This chapter builds the load-generation concepts
-step by step — throughput vs latency, closed loops, pipelining, where the
-histogram comes from, and exactly how the numbers go wrong — then hands you
-the line-by-line map of the C file. Two questions drive the read: *how does
-it implement pipelining, and what does it get wrong about coordinated
-omission?*
+from zero — throughput against latency, closed loops, pipelining, where the
+histogram's samples come from, and exactly how the numbers go wrong — then
+hands you the line-by-line map of the C file. Two questions drive the read:
+*how does it implement pipelining, and what does it get wrong about
+coordinated omission?*
+
+Every anchor below is Redis **8.6.2** (`src/version.h:1`), the commit
+`a176d1225` this repo pins, quoted with the line numbers the code occupies in
+that version.
## The problem in one sentence
When Redis stalls for 100 ms (a fork for a background save, say), a
closed-loop benchmark records *one* bad sample per client instead of the
thousands of delayed requests a real workload would have suffered — so the
-reported p99 can be 100× better than what users would experience.
+reported p99.9 can be hundreds of times better than what users experience.
## The concepts, step by step
### Step 1 — throughput and latency answer different questions
-**Throughput** is how many requests per second the server can complete
-(capacity: "can Redis do 1M SET/s?"). **Latency** is how long *one* request
-takes from the moment a client wants it done to the moment the answer
-arrives (experience: "how slow is the p99?" — the p99 being the 99th
-percentile, the time that 99% of requests beat and 1% exceed). They are not
-two views of one number: a server can post 1M ops/s while some requests take
-500 ms, and a load generator built to maximize the first is, as we'll see,
-structurally unable to measure the second honestly.
+> **In:** nothing yet — this step fixes the vocabulary every later step uses.
+> **Out:** two words that are not two views of one number, and the reason the
+> tool's design goal decides which of them it can measure.
+
+**Throughput** is how many requests per second the server completes — a
+capacity question, "can Redis do 1M SET/s?". **Latency** is how long *one*
+request takes, from the moment a client wanted it done to the moment the
+answer arrived — an experience question, "how slow is the p99?".
+
+A **percentile** is the value a given fraction of samples falls below: the
+**p99** is the time 99% of requests beat and 1% exceed; the **p99.9** is the
+time 999 requests in 1000 beat. Percentiles are what latency is reported in
+because the mean hides exactly the tail users complain about.
-Why it matters: redis-benchmark's design goal is throughput; every latency
+These are not two views of one number. A server can post 1M ops/s while some
+requests take 500 ms — the 1M is an average over a second, the 500 ms is one
+request's experience inside it. And a load generator built to maximize the
+first is, as Steps 2 to 6 show, structurally unable to measure the second
+honestly.
+
+Why it matters: redis-benchmark's design goal is throughput. Every latency
figure it prints has to be read with that in mind.
-### Step 2 — closed-loop load generation
+### Step 2 — the closed loop: the server sets the send rate
+
+> **In:** the vocabulary from Step 1.
+> **Out:** the send cycle every later step is built on — and the fact that no
+> target rate exists anywhere in it. Step 6 turns that absence into the bug.
A **closed loop** is the simplest possible client: send a request, wait for
-the reply, send the next — the client and server take turns, and at most one
-request (per client connection) is ever outstanding. The whole of
-redis-benchmark is this cycle:
+the reply, send the next. Client and server take turns, so at most one request
+per connection is ever outstanding (or one *batch* of them, once Step 3 adds
+pipelining). An **event loop** — Redis's own `ae` library, reused here — is
+the thing that drives it: a single thread that waits for file descriptors to
+become readable or writable and calls a handler for each.
+
+The whole of redis-benchmark is this cycle:
```mermaid
flowchart LR
- W["writeHandler (555)
c->start = ustime()"] --> R["readHandler (442)
latency = now − start
on FIRST reply only (452)"]
- R --> D["clientDone (420)"]
- D --> RC["resetClient (368)"]
- RC -->|"next batch starts only after
the previous one finished"| W
+ W["writeHandler 555
c->start = ustime() at 574"] --> R["readHandler 442
c->latency = ustime()-c->start
at 452, first read event only"]
+ R --> D["clientDone 420"]
+ D --> RC["resetClient 368
c->pending = config.pipeline at 374"]
+ RC -->|"the next batch starts only
after the previous one finished"| W
+```
+
+The closed loop itself is eight lines, and worth reading in full because the
+absence in it is the point:
+
+```c
+// src/redis-benchmark.c — resetClient, the whole closed loop, 368-375
+ 368 static void resetClient(client c) {
+ 369 aeEventLoop *el = CLIENT_GET_EVENTLOOP(c);
+ 370 aeDeleteFileEvent(el,c->context->fd,AE_WRITABLE);
+ 371 aeDeleteFileEvent(el,c->context->fd,AE_READABLE);
+ 372 aeCreateFileEvent(el,c->context->fd,AE_WRITABLE,writeHandler,c);
+ 373 c->written = 0;
+ 374 c->pending = config.pipeline;
+ 375 }
```
-In the code: `clientDone` (line 420) → `resetClient` (line 368 — the closed
-loop, in 8 lines) → re-arm the write handler → the next batch starts *after*
-the previous one finished. Notice what does **not** exist anywhere in the
-cycle: a target request rate or an intended send schedule. The client sends
-exactly as fast as the server answers — no faster, and crucially, no matter
-what, never *during* a server stall.
+Line 372 is the one that closes the loop: it re-arms the *write* handler, so
+the next batch is sent the instant this one finished being read. `clientDone`
+(420) calls it when `config.keepalive` is set (428-429), and reconnects
+instead when it is not (430-437).
+
+Now look for what is *not* there: no target request rate, no intended send
+schedule, no clock the loop is trying to keep up with. The client sends
+exactly as fast as the server answers — no faster, and, crucially, not at all
+*during* a server stall.
-Why it matters: closed loops are trivial to write and great for finding peak
-throughput, but the send rate is controlled by the *server* — hold that
-thought for Step 5.
+Why it matters: closed loops are trivial to write and are the right tool for
+finding peak throughput, but the send rate is controlled by the **server**.
+Hold that thought for Step 6.
-### Step 3 — pipelining: k requests in flight amortize the round trip
+### Step 3 — pipelining: one pre-built buffer, k commands deep
+
+> **In:** the send cycle from Step 2.
+> **Out:** a batch of `config.pipeline` commands as the unit that gets sent —
+> which Step 4 then treats as a single timing unit.
**Pipelining** means sending k requests back-to-back without waiting for
-replies, then collecting all k answers — so one network round trip (the
-~50–500 µs of wire and kernel time per exchange) is paid once per *batch*
-instead of once per request. With a 100 µs round trip and a 1 µs command,
-unpipelined throughput caps at ~10K ops/s per connection; `-P 100` lifts it
-near 1M.
+replies, then collecting all k answers. One **round trip** — the wire, kernel
+and syscall time of a single send-and-receive exchange, ~50-500 µs on real
+networks and ~10-50 µs on loopback — is then paid once per *batch* instead of
+once per request.
+
+The arithmetic, on a 100 µs round trip and a 1 µs command:
+
+```
+unpipelined: 1 command / (100 µs RTT + 1 × 1 µs) = 1/101 µs = 9,901 ops/s
+-P 100: 100 commands / (100 µs RTT + 100 × 1 µs) = 100/200 µs = 500,000 ops/s
+-P 1000: 1000 commands / (100 µs RTT + 1000 × 1 µs) = 1000/1100 µs = 909,091 ops/s
+ceiling as k grows: 1 / 1 µs = 1,000,000 ops/s
+```
+
+So `-P 100` is a 50× lift and gets you *half* way to the 1M/s ceiling, not to
+it: the round trip is still half the batch's cost. The ceiling is the command
+time alone, and you only approach it once `k × service ≫ RTT`. Topic 7
+measures the real version of this curve on loopback with zero-work requests:
+**44k ops/s at P=1 against 12.3M at P=256** ([FINDINGS.md](../../FINDINGS.md)
+row 7).
redis-benchmark's implementation is the elegant part — there is no request
queue at all, just one pre-built buffer:
@@ -81,145 +141,433 @@ c->obuf — the whole benchmark is one pre-built buffer, written over and over:
trimmed after 1st reply └── randptr[] patch digits in place — no re-serialization
```
-`createClient` (line 625) copies the *same command bytes* `config.pipeline`
-times into one output buffer `c->obuf`, sets `c->pending = config.pipeline`,
-and the event loop just writes the whole buffer and counts replies back down
-(`readHandler`, line 458: `while(c->pending)`). Randomized keys are patched
-*in place* through saved pointers into the buffer (`randptr`, lines 377–393 —
-writes digits directly into the command bytes, no re-serialization).
-Auth/SELECT prefix commands ride in the same buffer once and are trimmed
-after the first reply (lines 506–523).
-
-Cost of the trick: within one batch every pipelined command has the *same*
-key randomization per slot of the buffer, and — see Step 4 — the whole batch
-becomes one timing unit.
-
-Why it matters: this is the minimum possible work per event-loop tick, and
-it's the part worth stealing for your own load generator.
-
-### Step 4 — where the latency histogram comes from
-
-A **latency histogram** counts how many requests fell into each time bucket,
-so percentiles (p50, p99, p99.9) can be read off it afterward;
-redis-benchmark uses HdrHistogram (a histogram with buckets sized to keep a
-fixed relative error at every magnitude — two of them live in `struct
-config`, lines 99–100). But a histogram is only as honest as the samples fed
-into it, and here's exactly what gets fed:
-
-- `writeHandler` line 574: `c->start = ustime()` when a batch begins writing.
-- `readHandler` line 452: `if (c->latency < 0) c->latency = ustime() - c->start`
- — **on the first read event only**. So "latency" = batch send → first
- bytes of first reply. Deliberate (the comment says parsing overhead
- shouldn't count), but it means the last reply's extra wait is invisible.
-- Lines 528–541: that *single* value is recorded into the HdrHistogram
- **once per reply** — all `pipeline` requests inherit the first reply's
- latency. With `-P 100`, one measurement pretends to be 100.
-
-`showLatencyReport` (line 830+) then prints beautiful full percentiles — of
-that sample.
-
-Why it matters: the display machinery is state of the art; the *sampling* is
-one clock read per batch, duplicated. Good percentiles of a biased sample
-are still biased.
-
-### Step 5 — coordinated omission: the closed loop under-samples the worst moments
+`createClient` (625) builds it. The replication is two lines, a hundred lines
+into the function — this is the trick worth stealing:
+
+```c
+// src/redis-benchmark.c — inside createClient, 719-731
+ 719 c->prefixlen = sdslen(c->obuf);
+ 720 /* Append the request itself. */
+ 721 if (from) {
+ 722 c->obuf = sdscatlen(c->obuf,
+ 723 from->obuf+from->prefixlen,
+ 724 sdslen(from->obuf)-from->prefixlen);
+ 725 } else {
+ 726 for (j = 0; j < config.pipeline; j++)
+ 727 c->obuf = sdscatlen(c->obuf,cmd,len);
+ 728 }
+ 729
+ 730 c->written = 0;
+ 731 c->pending = config.pipeline+c->prefix_pending;
+```
+
+Line 727 is the whole of it: the *same command bytes* appended
+`config.pipeline` times into one output buffer. Line 731 sets the reply
+counter to match, and the read side just counts back down —
+`while(c->pending)` at 458. Note that the counter is `config.pipeline` **plus**
+`c->prefix_pending`, because AUTH, SELECT and HELLO 3 ride in the same buffer
+(705-717) and are trimmed after their replies arrive (510-521).
+
+Randomized keys are patched *in place* through saved pointers into that
+buffer, so a new key costs twelve stores and no re-serialization:
+
+```c
+// src/redis-benchmark.c — randomizeClientKey, 377-393
+ 377 static void randomizeClientKey(client c) {
+ 378 size_t i;
+ 379
+ 380 for (i = 0; i < c->randlen; i++) {
+ 381 char *p = c->randptr[i]+11;
+ 382 size_t r = 0;
+ 383 if (config.randomkeys_keyspacelen != 0)
+ 384 r = random() % config.randomkeys_keyspacelen;
+ 385 size_t j;
+ 386
+ 387 for (j = 0; j < 12; j++) {
+ 388 *p = '0'+r%10;
+ 389 r/=10;
+ 390 p--;
+ 391 }
+ 392 }
+ 393 }
+```
+
+The line to look at is 388: it writes one decimal digit directly into the
+command bytes. `c->randptr[i]` points at the `:rand:` placeholder inside
+`c->obuf` itself, so there is no format string, no allocation, and no copy on
+the hot path.
+
+Cost of the trick: the buffer is rewritten once per batch (571, via
+`writeHandler`), so every slot gets a fresh key, but all `config.pipeline`
+commands in a batch are randomized together and sent together — and, as
+Step 4 shows, timed together.
+
+Why it matters: this is close to the minimum possible work per event-loop
+tick, and it is the part worth stealing for your own load generator.
+
+### Step 4 — one clock read per batch
+
+> **In:** the batch of `config.pipeline` commands from Step 3.
+> **Out:** exactly one number, `c->latency`, per batch — the sample Step 5
+> feeds to the histograms.
+
+The clock starts when a batch begins writing:
+
+```c
+// src/redis-benchmark.c — inside writeHandler, 561-576
+ 561 /* Initialize request when nothing was written. */
+ 562 if (c->written == 0) {
+ // ... 563-568: stop if config.requests has already been issued ...
+ 570 /* Really initialize: randomize keys and set start time. */
+ 571 if (config.randomkeys) randomizeClientKey(c);
+ // ... 572-573: cluster-mode hash tags and slot epoch ...
+ 574 c->start = ustime();
+ 575 c->latency = -1;
+ 576 }
+```
+
+Line 574 is the one that matters, and note the guard on 562: the clock is set
+when the *first* byte of a batch is written, not on every partial write. Line
+575 arms the sentinel that the read side tests.
+
+It stops on the first byte of the first reply:
+
+```c
+// src/redis-benchmark.c — the top of readHandler, 449-452
+ 449 /* Calculate latency only for the first read event. This means that the
+ 450 * server already sent the reply and we need to parse it. Parsing overhead
+ 451 * is not part of the latency, so calculate it only once, here. */
+ 452 if (c->latency < 0) c->latency = ustime()-(c->start);
+```
+
+Line 452 is the whole measurement, and the `< 0` test is what makes it happen
+once. So what redis-benchmark calls latency is precisely: **the interval from
+"we started writing k commands" to "the first bytes of the first reply came
+back"**. The comment on 449-451 says why parsing is excluded, and that
+reasoning is sound. What it does not say is the consequence: the k-th reply's
+extra wait is outside the interval entirely, and is never measured.
+
+Why it matters: one `ustime()` pair per batch is the entire sampling
+apparatus. Everything printed later is a rendering of these numbers.
+
+### Step 5 — the fork: one sample, two histograms, k recordings
+
+> **In:** the single `c->latency` value from Step 4.
+> **Out:** two datasets — a cumulative HdrHistogram that
+> `showLatencyReport` prints at the end, and a per-second one that drives the
+> live line. Both are filled once per *reply*, not once per measurement.
+
+A **latency histogram** counts how many samples fell into each time bucket, so
+percentiles can be read off it afterwards without keeping every sample.
+**HdrHistogram** is the standard implementation: its buckets are sized to hold
+a fixed *relative* error at every magnitude, so 1 µs and 1 s are both recorded
+to the same number of significant digits, in constant time and constant space.
+
+Two of them live in `struct config` (99-100), and this is where the run's data
+forks in two:
+
+```c
+// src/redis-benchmark.c — inside struct config, 99-100
+ 99 struct hdr_histogram* latency_histogram;
+ 100 struct hdr_histogram* current_sec_latency_histogram;
+```
+
+`latency_histogram` accumulates the whole run and is what `showLatencyReport`
+(830) reads for the final percentiles (833-838). `current_sec_latency_histogram`
+is reset every second and drives the live progress line. Same samples, two
+consumers — so a claim about "the p99 redis-benchmark printed" is a claim
+about the first one.
+
+The recording is inside the reply loop:
+
+```c
+// src/redis-benchmark.c — inside readHandler's while(c->pending), 524-543
+ 524 int requests_finished = 0;
+ 525 atomicGetIncr(config.requests_finished, requests_finished, 1);
+ 526 if (requests_finished < config.requests){
+ 527 if (config.num_threads == 0) {
+ 528 hdr_record_value(
+ 529 config.latency_histogram, // Histogram to record to
+ 530 (long)c->latency<=CONFIG_LATENCY_HISTOGRAM_MAX_VALUE ? (long)c->latency : CONFIG_LATENCY_HISTOGRAM_MAX_VALUE); // Value to record
+ 531 hdr_record_value(
+ 532 config.current_sec_latency_histogram, // Histogram to record to
+ 533 (long)c->latency<=CONFIG_LATENCY_HISTOGRAM_INSTANT_MAX_VALUE ? (long)c->latency : CONFIG_LATENCY_HISTOGRAM_INSTANT_MAX_VALUE); // Value to record
+ // ... 534-541: the same two calls again, hdr_record_value_atomic, when threads are on ...
+ 542 }
+ 543 c->pending--;
+```
+
+The load-bearing detail is *where* this sits: inside `while(c->pending)` (458),
+which spins once per reply. `c->latency` was computed once, at 452, and is not
+recomputed — so the same value is recorded `config.pipeline` times. Run with
+`-P 100` and a million requests, and the histogram holds 1,000,000 entries
+drawn from **10,000** clock readings. One measurement pretending to be a
+hundred.
+Line 530 adds a second distortion: `CONFIG_LATENCY_HISTOGRAM_MAX_VALUE` is
+`3000000L` µs (line 50), so any sample above **3 s** is recorded *as* 3 s. The
+worst outliers are truncated, not lost — which flatters the maximum.
+
+`showLatencyReport` (830) then computes p50, p95, p99 and the max off this
+histogram (834-837) and prints them to two decimal places. The display is
+state of the art. The samples are one clock read per batch, duplicated.
+
+Why it matters: good percentiles of a biased sample are still biased, and
+nothing downstream of line 452 can recover what line 452 did not measure.
+
+### Step 6 — coordinated omission: the closed loop under-samples the worst moments
+
+> **In:** the rate-free cycle (Step 2), the batch as timing unit (Steps 3-4),
+> and the histograms (Step 5).
+> **Out:** the named defect, its size in requests, and the one structural
+> change that fixes it.
+
+**Service time** is how long the server took once it picked a request up.
+**Queueing delay** is how long the request waited before that — behind other
+work, or behind a stall. **Latency**, as a user experiences it, is the sum.
**Coordinated omission** (Gil Tene's term) is the measurement error where the
-load generator, by waiting for the server, silently *coordinates* with it —
+load generator, by waiting for the server, silently *coordinates* with it: the
requests that would have arrived during a stall are never sent, so the worst
-moments are systematically under-sampled and the p99 lies. Steps 2–4 combine
-into exactly this. In Tene's terms:
-
-1. **No target rate exists.** The benchmark always sends as fast as the
- server answers, so a stall (fork for RDB save, AOF fsync, slow command)
- simply pauses the generator — requests that *would* have arrived during
- the stall are never sent, never measured. You get exactly one bad sample
- per client per stall instead of thousands.
-2. **It measures service time and calls it latency.** Service time = how long
- the server took once it picked the request up; latency = service time
- *plus the queueing delay* a real open-world client would experience
- waiting behind the stall. The queueing delay never appears.
-3. **HdrHistogram doesn't save it.** Redis added HdrHistogram (config lines
- 99–100) and full percentile output (830+) — good display of a *biased*
- sample (Step 4). Correction would require an intended-arrival schedule,
- which doesn't exist here. (Compare wrk2, which was written to fix exactly
- this; memtier_benchmark has `--rate-limiting`.)
-4. Small extra: `hdr_record_value` clamps at
- `CONFIG_LATENCY_HISTOGRAM_MAX_VALUE` (line 530) — the worst outliers are
- also truncated.
+moments are systematically under-sampled and the reported percentiles lie.
+
+Steps 2 to 5 combine into exactly this, in four parts:
+
+1. **No target rate exists.** Step 2 found no intended send schedule anywhere
+ in the cycle. A stall — a fork for an RDB save, an AOF fsync, one slow
+ `KEYS` — simply pauses the generator. Requests that *would* have arrived
+ during the stall are never sent, so they are never measured.
+2. **It measures service time and calls it latency.** Step 4's interval starts
+ when the client writes, which for a closed loop is always *after* the
+ previous reply. The queueing delay of Step 6's definition never appears,
+ because the client was never queued.
+3. **HdrHistogram does not save it.** Redis added two HdrHistograms (99-100)
+ and full percentile output (830) — excellent display of the biased sample
+ from Step 5. Correction needs an intended-arrival schedule, which does not
+ exist here. (Compare wrk2, written specifically to fix this;
+ memtier_benchmark has `--rate-limiting`.)
+4. **The clamp truncates what survives.** Step 5's 3 s ceiling (line 50, used
+ at 530) caps the few honest outliers a stall does produce.
Both loops, distilled to their timing skeletons — the entire bug and the
entire fix is *where the clock starts*:
```rust
-// closed loop (redis-benchmark): clock starts at SEND — a server stall
-// pauses the generator, so the requests that would have queued up behind
-// the stall are never sent, never measured.
+// ILLUSTRATION — not quoted from Redis. The closed loop is the real cycle of
+// src/redis-benchmark.c:368-375 with its clock reads at 574 and 452; the open
+// loop is what the tool would need and does not have.
+
+// closed loop (redis-benchmark): the clock starts at SEND, so a server stall
+// pauses the generator and the requests that would have queued behind the
+// stall are never sent, never measured.
loop {
let start = now();
send_batch_and_wait_all_replies();
- record(now() - start); // one bad sample per stall
+ record(now() - start); // one bad sample per stall
}
-// open loop (the fix): clock starts at the INTENDED send time — the
+// open loop (the fix): the clock starts at the INTENDED send time, and the
// schedule advances whether or not the server keeps up.
let mut intended = now();
loop {
- intended += period; // target rate exists
+ intended += period; // a target rate exists
wait_until(intended);
- send_one(); // reply handled async
+ send_one(); // reply handled asynchronously
on_reply(move |t| record(t - intended)); // queueing delay is visible
}
```
-Why it matters: worked example of the 100 ms fork stall from the problem
-statement — a closed loop with 50 clients records 50 samples of ~100 ms; an
-open loop at 100K req/s records ~10,000 samples spanning 0–100 ms of
-queueing delay. Same server, same stall; only the second histogram tells the
-truth about it.
+Worked example — the 100 ms fork stall from the problem statement, on a run of
+1,000,000 requests with 50 clients, against an open-loop generator running the
+same server at a target 100,000 req/s:
+
+```
+closed loop: 50 clients × 1 stalled batch = 50 samples of ~100 ms
+ 50 / 1,000,000 = 0.005% of the histogram
+ → first visible at the p99.995; p50, p99 and p99.9 are untouched
+
+open loop: 100,000 req/s × 0.100 s = 10,000 requests delayed
+ 10,000 / 1,000,000 = 1% of the histogram
+ delays spread ~uniformly over 0-100 ms, so:
+ p99.5 = the 5,000th of them = ~50 ms
+ p99.9 = the 9,000th of them = ~90 ms
+```
+
+Same server, same 100 ms stall, one run each. The closed loop's p99.9 is a
+normal healthy figure — a few hundred microseconds; the open loop's is ~90 ms,
+two and a half orders of magnitude worse and correct. Only the second
+histogram tells the truth about the stall.
+
+Why it matters: this is not a bug you can fix downstream. No histogram, no
+percentile estimator and no amount of sample count repairs a sample that was
+never taken.
## Where each step lives in the code
-One file, `src/redis-benchmark.c`, readable top to bottom in an evening:
+One file, `src/redis-benchmark.c` (2028 lines at `a176d1225`), readable top to
+bottom in an evening:
| Lines | What | Step |
|-------|------|------|
-| 61–108 | `struct config` — all global state, incl. `pipeline`, two HdrHistograms (99–100) | 3, 4 |
-| 110–130 | `struct _client` — note `start`, `latency`, `pending` | 2, 4 |
-| 368–375 | `resetClient` — the closed loop, in 8 lines | 2 |
-| 420–439 | `clientDone` — finished batch → `resetClient` (keepalive) or reconnect | 2 |
-| 442–553 | `readHandler` — latency capture + histogram recording | 4, 5 |
-| 555–602 | `writeHandler` — batch start, `c->start = ustime()` | 4 |
-| 625+ | `createClient` — pipelining via buffer replication | 3 |
-| 830+ | `showLatencyReport` — percentiles off HdrHistogram | 4 |
-| 946 | `benchmark()` — sets up clients, runs the event loop | 2 |
-| 1696 | `main` — test loop over SET/GET/INCR/... | 1 |
+| 49-51 | `CONFIG_LATENCY_HISTOGRAM_*` — the 10 µs floor and the 3 s clamp | 5 |
+| 61-108 | `struct config` — all global state, incl. `pipeline`, two HdrHistograms (99-100) | 3, 5 |
+| 110-130 | `struct _client` — note `start` (120), `latency` (121), `pending` (122) | 2, 4 |
+| 368-375 | `resetClient` — the closed loop, in 8 lines | 2 |
+| 377-393 | `randomizeClientKey` — digits patched into the buffer in place | 3 |
+| 420-439 | `clientDone` — finished batch → `resetClient` (429) or reconnect (430-437) | 2 |
+| 442-553 | `readHandler` — latency capture (452), prefix trim (510-521), histogram recording (528-541) | 4, 5 |
+| 555-602 | `writeHandler` — batch start, `c->start = ustime()` at 574 | 4 |
+| 625-812 | `createClient` — pipelining by buffer replication (726-727) | 3 |
+| 830-921 | `showLatencyReport` — percentiles off the cumulative histogram (833-838) | 5 |
+| 946-982 | `benchmark()` — allocates both histograms (954-963), runs the event loop, calls the report (976) | 2, 5 |
+| 1696 | `main` — the test loop over SET/GET/INCR/… | 1 |
Suggested route: `main` (1696) → `benchmark()` (946) → `createClient` (625,
-Step 3's buffer trick) → the Step 2 cycle (`writeHandler` 555 →
-`readHandler` 442 → `clientDone` 420 → `resetClient` 368) — and as you trace
-it, confirm for yourself that no intended-arrival schedule exists anywhere
-(Step 5).
+for Step 3's buffer trick at 726-727) → then the Step 2 cycle in order,
+`writeHandler` (555) → `readHandler` (442) → `clientDone` (420) →
+`resetClient` (368). As you trace it, confirm for yourself that no
+intended-arrival schedule exists anywhere — that absence is Step 6.
+
+## Questions to answer in notes.md
+
+1. `readHandler` computes `c->latency` at line 452 but records it inside the
+ loop at 528. How many histogram entries does one `-P 100` batch produce,
+ and how many `ustime()` calls paid for them?
+2. The comment at 449-451 justifies measuring only the first read event
+ ("parsing overhead is not part of the latency"). Is that reasoning right?
+ What does it cost, for a batch of 100?
+3. `c->pending` is set to `config.pipeline` in `resetClient` (374) but to
+ `config.pipeline + c->prefix_pending` in `createClient` (731). Why the
+ difference, and what happens to the prefix replies' latencies (506-523)?
+4. If you added `--rate` to this tool, which of the two clock reads (574, 452)
+ would have to move, and what new state would `struct _client` need?
+5. Sketch what the histogram from Step 6's worked example looks like in each
+ loop. Which percentile is the first to differ?
## Takeaway
-redis-benchmark is a *throughput* tool with percentile decoration: buffer-replication
-pipelining is a masterclass in doing the minimum work per event-loop tick, but the
-closed loop means its latency numbers systematically flatter the server under stress.
-For the capstone (M7+): keep the obuf trick, add an intended-send schedule.
+redis-benchmark is a *throughput* tool with percentile decoration:
+buffer-replication pipelining is a masterclass in doing the minimum work per
+event-loop tick, but the closed loop means its latency numbers systematically
+flatter the server under stress. For the capstone (M7+): keep the `obuf`
+trick, add an intended-send schedule.
## Done when
+Answer each before unfolding it.
+
- [ ] You can explain the difference between service time and latency, and say which one `redis-benchmark` reports.
+
+ Answer
+
+ Service time is how long the server took once it picked the request up;
+ latency as a user experiences it is service time *plus* the queueing delay
+ spent waiting to be picked up. redis-benchmark reports service time. Its
+ clock starts at line 574, when the client begins writing a batch — and in a
+ closed loop the client only begins writing after the previous reply landed,
+ so by construction it was never queued. There is no moment in the cycle at
+ which a request exists but has not been sent, which is exactly the interval
+ queueing delay would occupy.
+
+
+
- [ ] You can define coordinated omission and explain the mechanism by which a closed loop under-samples exactly the worst moments.
+
+ Answer
+
+ Coordinated omission is the error where the load generator waits for the
+ server and thereby *coordinates* with it: the requests that would have
+ arrived during a stall are never issued, so the stall is under-represented
+ in the sample rather than over-represented as it is in production.
+
+ The mechanism is `resetClient` (368-375). Line 372 re-arms the write handler
+ only after the batch's last reply was consumed, so the send rate is a
+ function of the server's completion rate. During a 100 ms stall each client
+ contributes exactly one in-flight batch — 50 clients, 50 bad samples — where
+ an open loop at 100,000 req/s would have issued 10,000 requests into it.
+ The stall is 0.005% of the closed loop's histogram and 1% of the open loop's.
+
+
+
- [ ] You can say why adding HdrHistogram to a closed-loop generator does not fix it.
+
+ Answer
+
+ Because HdrHistogram is a recording and rendering structure, and the defect
+ is in the sampling. It buys constant-space storage at fixed relative error
+ and lets `showLatencyReport` (830) compute exact percentiles at 833-838 —
+ of whatever it was given. What it was given is one clock read per batch
+ (452), replicated `config.pipeline` times (528-541), with the 10,000
+ requests a stall would have delayed simply absent. Correcting it needs an
+ intended-arrival schedule to subtract from, and no such timestamp exists in
+ `struct _client` (110-130). The 3 s clamp at line 530 makes it slightly
+ worse, capping the honest outliers that do survive.
+
+
+
- [ ] You can predict what pipelining does to the reported figure, then check your prediction against topic 7's `loopback_bench` — 44k ops/s at P=1 against 12.3M at P=256.
+
+ Answer
+
+ Throughput rises steeply and then saturates, because the round trip is
+ amortised over k commands while the per-command service time is not: at a
+ 100 µs RTT and a 1 µs command, P=1 gives 9,901 ops/s, P=100 gives 500,000,
+ P=1000 gives 909,091, and the ceiling is 1,000,000. Topic 7's measured
+ 44k → 12.3M over P=1 → P=256 is the same curve with the per-request cost
+ being syscalls rather than a network hop.
+
+ The reported *latency* moves the other way and stops meaning what it says:
+ the interval measured at 452 is now "send 256 commands, get the first reply
+ back", and it is written into the histogram 256 times. A larger `-P` makes
+ throughput look better, latency look worse, and the sample count look 256×
+ more trustworthy than it is.
+
+
+
- [ ] You can name what you would have to change in the tool to make its latency numbers trustworthy (a target rate, and the arithmetic that goes with it).
+ Answer
+
+ Three changes, in dependency order. First, `struct _client` (110-130) needs
+ an `intended` timestamp alongside `start` (120), advanced by a fixed
+ `period = 1/rate` regardless of when the previous reply arrived. Second, the
+ measurement at 452 has to subtract `c->intended`, not `c->start`, so
+ queueing delay is inside the interval. Third — and this is the part that
+ makes it a rewrite rather than a patch — `resetClient` (368-375) must stop
+ gating the next send on the previous reply, which means replies can no
+ longer be counted down with `while(c->pending)` on a single in-flight batch;
+ the client needs several batches outstanding and a way to match replies to
+ their intended times.
+
+ The pipelining machinery survives all of this: `c->obuf` and the
+ `randptr` in-place patching (377-393) are orthogonal to when you decide to
+ write the buffer.
+
+
+
## References
**Code**
-- [redis](https://github.com/redis/redis) `src/redis-benchmark.c` (2028
- lines, pinned at Redis 8.6.2 / `a176d1225`) — one file, no dependencies
- beyond hiredis + the `ae` event loop; readable top to bottom in an
- evening
+- [redis](https://github.com/redis/redis) `src/redis-benchmark.c` (2028 lines,
+ pinned at Redis 8.6.2 / `a176d1225` — version confirmed in
+ `src/version.h:1`) — one file, no dependencies beyond hiredis and the `ae`
+ event loop; readable top to bottom in an evening.
+
+| File | Lines | What |
+|------|-------|------|
+| `src/redis-benchmark.c` | 49-51 | histogram floor (10 µs) and clamp (3,000,000 µs) |
+| `src/redis-benchmark.c` | 99-100 | the two HdrHistograms — the data fork of Step 5 |
+| `src/redis-benchmark.c` | 368-375 | `resetClient`, the closed loop |
+| `src/redis-benchmark.c` | 377-393 | `randomizeClientKey`, in-place digit patching |
+| `src/redis-benchmark.c` | 449-452 | the only latency measurement in the tool |
+| `src/redis-benchmark.c` | 528-541 | one sample recorded once per reply |
+| `src/redis-benchmark.c` | 574 | `c->start = ustime()` — where the clock starts |
+| `src/redis-benchmark.c` | 726-727 | pipelining, by appending the same bytes k times |
+| `src/redis-benchmark.c` | 833-838 | the percentiles that get printed |
+
+**Background**
+- Gil Tene, *How NOT to Measure Latency* — the talk that named coordinated
+ omission, and the source of the open-loop correction sketched in Step 6.
+- [wrk2](https://github.com/giltene/wrk2) and
+ [memtier_benchmark](https://github.com/RedisLabs/memtier_benchmark)
+ (`--rate-limiting`) — load generators that carry the intended-arrival
+ schedule redis-benchmark lacks.
diff --git a/topics/00-performance-toolbox/reading-rocksdb-db-bench.md b/topics/00-performance-toolbox/reading-rocksdb-db-bench.md
index 0bde610..ee4ef94 100644
--- a/topics/00-performance-toolbox/reading-rocksdb-db-bench.md
+++ b/topics/00-performance-toolbox/reading-rocksdb-db-bench.md
@@ -2,7 +2,7 @@
`fillseq`, `readrandom`, `readwhilewriting` — these workload names started in
LevelDB, were extended by RocksDB, and now appear in every LSM paper since.
-This chapter is a skim route through the 10,000-line tool that defines them —
+This chapter is a skim route through the 10,367-line tool that defines them —
but first it builds the concepts step by step: why a shared workload
vocabulary exists at all, how every workload reduces to picking an integer,
what each name actually stresses, and what to distrust in the numbers the
@@ -10,239 +10,929 @@ tool prints. The goal is the *vocabulary* and the measurement shape, not the
harness code. Name your own benchmarks in this language and your numbers
become comparable to two decades of published results.
+Every anchor below is RocksDB at commit **`7c80a5a`**, the revision this repo
+pins (`resources/codebases.md`, pin table), quoted with the line numbers the
+code occupies in that revision. `tools/db_bench_tool.cc` is **10,367 lines**
+there; it churns fast, so on any other commit re-grep before trusting a
+number. `tools/pinned-source.py show rocksdb tools/db_bench_tool.cc -r
+6107:6119` opens exactly what is quoted here.
+
## The problem in one sentence
"Our engine does 500K writes/s" is uninterpretable — sequential or random
-keys? new inserts or overwrites? uniform or skewed? measured during
-compaction or before it? — and each of those choices swings the number by
-5–50×, so without a shared workload vocabulary no two papers' numbers can be
-compared.
+keys? new inserts or overwrites? uniform or skewed? durable or buffered?
+measured during compaction or before it? — and this repo has already measured
+one of those axes on its own: the durability choice alone moves the ceiling
+from **856,898/s** (buffered `write()`) to **44,109/s** (`fsync`) to
+**337/s** (`F_FULLFSYNC`), a 2,542× spread
+([FINDINGS.md](../../FINDINGS.md) row 5). Without a shared workload
+vocabulary, no two papers' numbers name the same experiment.
## The concepts, step by step
### Step 1 — why storage engines need a standard workload vocabulary
-A storage engine's performance is not one number but a surface: it depends
-on the operation mix (reads vs writes vs scans), the key order (sequential
-vs random), whether keys are new or overwrite old ones, and what background
-work is running. This matters most for **LSM engines** (log-structured
-merge-trees: writes go to sorted files that are later merged — "compacted" —
-in the background; **compaction** is that deferred merging work, and it
-competes with foreground traffic for IO and CPU). The same LSM can absorb
-sequential loads at disk bandwidth and collapse to a tenth of that under
-random overwrites, purely because of compaction debt.
-
-db_bench's fix: give each meaningful point on that surface a *name*, so
-"we ran `fillrandom` then `readwhilewriting`" pins down the experiment as
-precisely as a chess opening's name pins down twelve moves.
+> **In:** nothing yet — this step fixes the vocabulary and the units every
+> later step uses.
+> **Out:** the axes a benchmark name has to pin down, and the measured size of
+> two of them. Step 2 then shows that all of the *key-order* axis reduces to
+> one function.
+
+A storage engine's performance is not one number but a surface: it depends on
+the operation mix (reads vs writes vs scans), the key order (sequential vs
+random), whether keys are new or overwrite old ones, whether each write is
+made durable, and what background work is running.
+
+Four terms, defined before anything leans on them:
+
+- An **LSM engine** (log-structured merge-tree) buffers writes in memory, then
+ writes them out as immutable sorted files that are later merged in the
+ background.
+- **Compaction** is that deferred merging: reading several sorted files and
+ writing one merged file, discarding shadowed versions. It is real IO and CPU
+ that competes with foreground traffic, and it happens *after* the write that
+ caused it was already reported as fast.
+- **Write amplification** is bytes actually written to storage ÷ bytes the
+ application asked to write. It is the price of compaction. Topic 4's notes
+ give the arithmetic: leveled compaction with size ratio `T` over `L` levels
+ rewrites each byte about `T/2` times per level, so `T/2 × L` — at `T=10`,
+ `L=4` that is **~20×** ([topic 4 notes](../04-lsm-deep-dive/notes.md)).
+- **Space amplification** is bytes on disk ÷ logical bytes. Topic 1 measured
+ it end to end on the same 108 MB of records: **0.45× for fjall** (an LSM,
+ which compresses its sorted runs) against **63.28× for redb** (a
+ copy-on-write B-tree under random-order inserts) — a 140× spread
+ ([FINDINGS.md](../../FINDINGS.md) row 1).
+
+So the same engine can absorb a sequential load near disk bandwidth and
+collapse under random overwrites, purely because of compaction debt — and the
+same *workload* can produce wildly different verdicts on two engine families.
+
+db_bench's fix: give each meaningful point on that surface a *name*, so "we
+ran `fillrandom` then `readwhilewriting`" pins down the experiment as
+precisely as a chess opening's name pins down twelve moves. The menu of names
+is one flag, `DEFINE_string(benchmarks, …)` at **115-170**, with its own help
+text at **172-273** — that help text is the best documentation the tool has.
Why it matters: LevelDB shipped these names in 2011, RocksDB extended them,
and every LSM paper since reports in them — the vocabulary *is* the
comparability.
-### Step 2 — every workload is "pick an integer, format it as a key"
-
-Under every workload name sits the same skeleton: choose an integer, then
-format it as a zero-padded fixed-width key. All the drama — sequential vs
-random, insert vs overwrite — lives entirely in *how the next integer is
-chosen* (`GenerateKeyFromInt` :3802, `WriteMode` :5869, `KeyGenerator`
-:6088):
-
-```rust
-// every fill* workload reduces to how the next integer is chosen
-fn next_key(mode: WriteMode, i: u64, n: u64, rng: &mut Rng, perm: &[u64]) -> Key {
- let int = match mode {
- WriteMode::Sequential => i, // fillseq: in-order, no
- // compaction debt
- WriteMode::Random => rng.next() % n, // fillrandom/overwrite:
- // duplicates → garbage
- // → compaction pressure
- WriteMode::UniqueRandom => perm[i as usize], // pre-shuffled permutation:
- // random order, no dups
- };
- generate_key_from_int(int) // zero-padded fixed width
-}
-```
-
-The three modes differ in one property each: `Sequential` arrives in order;
-`Random` draws with replacement, so ~37% of a full pass are duplicates —
-each duplicate is an overwrite that turns an old value into garbage the
-engine must compact away; `UniqueRandom` pre-shuffles `0..n` so arrival
-order is random but every key appears exactly once — random placement
-without garbage.
+### Step 2 — every workload is "pick an integer, then lay it out as a key"
+
+> **In:** the axes from Step 1, in particular key order.
+> **Out:** the single function that decides key order for every `fill*`
+> workload, and the duplicate rate it produces — the input to Step 3's four
+> names.
+
+Under every workload name sits the same skeleton: choose an integer, then lay
+that integer out as a fixed-width key. All the drama — sequential vs random,
+insert vs overwrite — lives entirely in *how the next integer is chosen*.
+There are exactly three ways, and they are an enum:
+
+```cpp
+// tools/db_bench_tool.cc — the whole key-order axis, 5869
+ 5869 enum WriteMode { RANDOM, SEQUENTIAL, UNIQUE_RANDOM };
+```
+
+`KeyGenerator` (**6088-6134**) turns that enum into integers, and its `Next()`
+is thirteen lines that contain everything Step 3 will name:
+
+```cpp
+// tools/db_bench_tool.cc — KeyGenerator::Next, 6107-6119
+ 6107 uint64_t Next() {
+ 6108 switch (mode_) {
+ 6109 case SEQUENTIAL:
+ 6110 return next_++;
+ 6111 case RANDOM:
+ 6112 return rand_->Next() % num_;
+ 6113 case UNIQUE_RANDOM:
+ 6114 assert(next_ < num_);
+ 6115 return values_[next_++];
+ 6116 }
+ 6117 assert(false);
+ 6118 return std::numeric_limits::max();
+ 6119 }
+```
+
+The line that carries the argument is **6112**: `% num_` is a draw *with
+replacement*, so the same key can come up twice. `values_` on 6115 is a
+pre-shuffled permutation of `0..num_`, built once in the constructor:
+
+```cpp
+// tools/db_bench_tool.cc — inside the KeyGenerator constructor, 6093-6104
+ 6093 if (mode_ == UNIQUE_RANDOM) {
+ // ... 6094-6097: a comment on the memory cost of materialising the vector ...
+ 6098 values_.resize(num_);
+ 6099 for (uint64_t i = 0; i < num_; ++i) {
+ 6100 values_[i] = i;
+ 6101 }
+ 6102 RandomShuffle(values_.begin(), values_.end(),
+ 6103 static_cast(*seed_base));
+ 6104 }
+```
+
+Line **6098** is the cost of the mode: `UNIQUE_RANDOM` materialises an 8-byte
+slot per key before the first write, so `--num=1000000000` wants 8 GB of RAM
+just for the permutation. Line 6103 is why the order reproduces: the shuffle
+is seeded from `seed_base`.
+
+**The integer then becomes a key** through `GenerateKeyFromInt`
+(**3802-3842**) — and here the tidy story ("zero-padded fixed-width key") is
+wrong. The code's own comment at 3797-3801 says what it does:
+
+```cpp
+// tools/db_bench_tool.cc — GenerateKeyFromInt's comment and its tail, 3797-3801 and 3830-3841
+ 3797 // - If keys_per_prefix_ is 0, the key is simply a binary representation of
+ 3798 // random number followed by trailing '0's
+ 3799 // ----------------------------
+ 3800 // | key 00000 |
+ 3801 // ----------------------------
+ // ... 3802-3829: the signature, the --use_existing_keys shortcut, and the
+ // ... optional prefix block written when keys_per_prefix_ > 0 ...
+ 3830 int bytes_to_fill = std::min(key_size_ - static_cast(pos - start), 8);
+ 3831 if (port::kLittleEndian) {
+ 3832 for (int i = 0; i < bytes_to_fill; ++i) {
+ 3833 pos[i] = (v >> ((bytes_to_fill - i - 1) << 3)) & 0xFF;
+ 3834 }
+ 3835 } else {
+ 3836 memcpy(pos, static_cast(&v), bytes_to_fill);
+ 3837 }
+ 3838 pos += bytes_to_fill;
+ 3839 if (key_size_ > pos - start) {
+ 3840 memset(pos, '0', key_size_ - (pos - start));
+ 3841 }
+ 3842 }
+```
+
+Line **3833** is the one to look at. The shift `(bytes_to_fill - i - 1) << 3`
+emits the *most significant* byte first: the integer is written **big-endian
+binary**, not decimal digits. The padding on 3840 is ASCII `'0'` (0x30) filling
+the remaining `--key_size` bytes (default 16, line 388). Big-endian is the
+load-bearing choice: RocksDB's default comparator orders keys byte by byte, so
+big-endian layout makes byte order agree with numeric order, and that is
+*why* `SEQUENTIAL` integers produce keys that arrive in sorted order.
+
+**How many duplicates does `RANDOM` produce?** The guide's claim is "~37% of a
+full pass", and it is worth deriving rather than asserting, because it is the
+whole difference between `fillrandom` and `filluniquerandom`. Line 6112 draws
+uniformly from `0..n-1` (up to negligible modulo bias, `n ≪ 2^64`), and
+`DoWrite` runs `num_ops = num_` draws per thread (**6160**), each thread with
+its own generator over the full key space (**6177-6181**). So, with
+`--threads=1`:
+
+```
+symbols
+ n = FLAGS_num = the key space size AND the number of draws (6160, 6177)
+ k = a particular key in 0..n-1
+
+ P(one draw misses k) = 1 - 1/n each draw is uniform (6112)
+ P(all n draws miss k) = (1 - 1/n)^n draws are independent
+ E[keys never written] = n * (1 - 1/n)^n
+ E[distinct keys written] = n * (1 - (1 - 1/n)^n)
+ E[duplicate writes] = n - E[distinct] = n * (1 - 1/n)^n
+
+worked on the guide's own --num=10000000, and on four smaller n to show the limit
+
+ n = 10 (1-1/n)^n = 0.348678 -> 3.49 of 10 keys never written
+ n = 100 (1-1/n)^n = 0.366032 -> 36.6 of 100
+ n = 1000 (1-1/n)^n = 0.367695 -> 368 of 1000
+ n = 1000000 (1-1/n)^n = 0.3678793 -> 367,879 of 1,000,000
+ n = 10000000 (1-1/n)^n = 0.36787942 -> 3,678,794 of 10,000,000
+
+ at n = 10,000,000:
+ expected keys never written = 3,678,794 (36.788%)
+ expected distinct keys = 6,321,206 (63.212%)
+ expected duplicate writes = 10,000,000 - 6,321,206 = 3,678,794 (36.788%)
+
+ the limit: (1 - 1/n)^n -> 1/e = 0.36787944... (already matched to 7 digits at n = 1e7)
+```
+
+Two counts fall out, and they are equal by conservation — every write is
+either a key's first appearance or a repeat, so `n − distinct` repeats and
+`n × (1−1/n)^n` never-written keys are the same number. So a 10 M-key
+`fillrandom` writes **10 M records into 6.32 M distinct keys**, leaving 3.68 M
+keys never touched and 3.68 M writes that are overwrites. Each of those
+overwrites creates a dead version that compaction must later rewrite and drop
+— write amplification (Step 1) with nothing to show for it.
+
+`UNIQUE_RANDOM` exists precisely to remove that term: 6115 hands out each
+integer exactly once, so arrival order is random but the duplicate count is
+**zero**. That is the isolation experiment — random *placement* without random
+*garbage*.
Why it matters: once you see this skeleton, the entire workload menu in
-Steps 3–4 is just this function plus an operation type.
+Steps 3-4 is this one function plus an operation type, and "random writes"
+splits into two genuinely different experiments.
### Step 3 — the fill family: four ways to write
-The `fill*` names are write workloads; each stresses a different part of the
-write path:
-
-- **`fillseq`** — sequential-order load (`Sequential` mode). The LSM fast
- path: sorted input means files never overlap, so there is no compaction
- debt. Papers use it to *build* the database before the real test — it's a
- setup step wearing a benchmark's name.
-- **`fillrandom`** — random-order inserts. Files overlap, compaction runs
- continuously; this is the honest write-throughput number.
-- **`overwrite`** — random writes to *existing* keys. Every write creates
- garbage (the old version) that compaction must reclaim — maximum
- compaction pressure, a different beast from `fillrandom` even though both
- are "random writes".
-- **`fillsync`** — one fsync (the syscall that forces data to durable media,
- ~0.1–5 ms each) per write, run over N/1000 ops. Measures durability cost,
- not throughput — expect 3–4 orders of magnitude below `fillseq`.
+> **In:** the three key orders from Step 2, and the duplicate arithmetic.
+> **Out:** four named write workloads, each pinning one more variable — the
+> write-side half of the menu Step 6 composes into a methodology.
+
+The `fill*` names are write workloads. `Benchmark::Run`'s dispatch chain turns
+each name into a method pointer and a couple of flag mutations, and the
+mutations are where the meaning lives:
+
+```cpp
+// tools/db_bench_tool.cc — the fill family's arms of the dispatch chain, 4030-4056
+ 4030 } else if (name == "fillseq") {
+ 4031 fresh_db = true;
+ 4032 method = &Benchmark::WriteSeq;
+ // ... 4033-4036: fillbatch, the same but with entries_per_batch_ = 1000 ...
+ 4037 } else if (name == "fillrandom") {
+ 4038 fresh_db = true;
+ 4039 method = &Benchmark::WriteRandom;
+ // ... 4040-4049: filluniquerandom, which forces num_threads to 1 ...
+ 4050 } else if (name == "overwrite") {
+ 4051 method = &Benchmark::WriteRandom;
+ 4052 } else if (name == "fillsync") {
+ 4053 fresh_db = true;
+ 4054 num_ /= 1000;
+ 4055 write_options_.sync = true;
+ 4056 method = &Benchmark::WriteRandom;
+```
+
+The two lines that carry the argument are **4050-4051**: `overwrite` and
+`fillrandom` call the *same method*, `WriteRandom`. The entire difference is
+that `fillrandom` sets `fresh_db = true` on 4038 and `overwrite` does not —
+Step 6 shows what `fresh_db` does. So:
+
+- **`fillseq`** — `SEQUENTIAL` mode (4032 → 5880 → `DoWrite(thread,
+ SEQUENTIAL)`). The LSM fast path: keys arrive in the comparator's own order
+ (Step 2's big-endian layout), so successive memtable flushes produce files
+ with disjoint key ranges and there is nothing for compaction to merge.
+ Papers use it to *build* the database before the real test — it is a setup
+ step wearing a benchmark's name.
+- **`fillrandom`** — random-order inserts into a fresh DB (4037-4039). Files
+ overlap, compaction runs continuously, and Step 2's 3.68 M duplicate writes
+ per 10 M ops add garbage on top. This is the honest write-throughput number.
+- **`overwrite`** — the same random writes with **no** fresh DB (4050-4051),
+ so it runs against whatever the previous entry in the comma list left
+ behind. Against a DB that already holds the full key space, nearly every
+ write shadows a live version, which is maximum compaction pressure — a
+ different beast from `fillrandom` even though both are "random writes", and
+ the one that matches a steady-state production database.
+- **`fillsync`** — random writes with `write_options_.sync = true` (4055) over
+ `num_ / 1000` ops (4054). The `/1000` is the tell: the author already knew
+ this workload is far slower and shortened it so the run would finish.
+
+**How much slower is `fillsync`?** Not a number to guess, and the tidy
+folklore answer ("three to four orders of magnitude") is wrong. Two sources
+settle it. First, what `sync = true` actually promises — RocksDB's own header
+is unusually precise:
+
+```cpp
+// include/rocksdb/options.h — the WriteOptions::sync contract, 2502-2515
+ 2502 // If true, the write will be flushed from the operating system
+ 2503 // buffer cache (by calling WritableFile::Sync()) before the write
+ 2504 // is considered complete. If this flag is true, writes will be
+ 2505 // slower.
+ // ... 2506-2511: what is and is not lost when the process or machine dies ...
+ 2512 // In other words, a DB write with sync==false has similar
+ 2513 // crash semantics as the "write()" system call. A DB write
+ 2514 // with sync==true has similar crash semantics to a "write()"
+ 2515 // system call followed by "fdatasync()".
+```
+
+Line **2515** is the one that decides the number: the promise is
+`fdatasync`-grade, *not* a drive cache flush. Second, topic 5 measured that
+exact ladder ([FINDINGS.md](../../FINDINGS.md) row 5, Apple M3 Pro / APFS):
+
+```
+per-call p50 and the implied single-threaded commit ceiling (topic 5, notes.md)
+
+ write() only 1.17 µs -> 856,898 commits/s the fillseq/fillrandom rung
+ fsync 22.67 µs -> 44,109 commits/s 856,898 / 44,109 = 19.4x slower
+ F_FULLFSYNC 2.97 ms -> 337 commits/s 856,898 / 337 = 2,542x slower
+
+ fillsync's rung, per options.h:2515 = the middle one
+ so the expected gap is ~19x (1.3 orders of magnitude), not 3-4 orders
+```
+
+The "3-4 orders" figure belongs to the *bottom* rung — a `Sync()` that really
+flushes the drive's volatile cache, which on macOS is `F_FULLFSYNC` and costs
+2,542× (3.4 orders). Both rungs get called "fsync" in conversation and they
+differ by 131×, so a `fillsync` number is uninterpretable until you know which
+one your platform's `WritableFile::Sync()` reached. That is Step 1's point
+applied to db_bench itself.
Why it matters: a paper quoting "write throughput" without saying which of
-these four it ran has told you almost nothing (a 5–50× spread, per Step 1).
+these four it ran has told you almost nothing — and the durability axis alone,
+measured in this repo, spans 19× to 2,542× depending on a distinction the
+benchmark name does not make.
### Step 4 — the read family: point, scan, and interference
+> **In:** a database in whatever state the write workloads of Step 3 left it.
+> **Out:** the read-side half of the menu, plus the one name whose reported
+> number comes from a subset of its threads — which Step 7 revisits.
+
The read-side names split along two axes — access shape, and whether writes
run concurrently:
-- **`readrandom` / `readseq` / `readreverse`** — point lookups vs. iterator
- scans (forward/backward). Point lookups may probe several LSM levels;
- scans stream.
-- **`seekrandom`** — the cost of positioning an iterator (Seek touches every
- level to set up merge order — a very different profile from a point Get).
-- **`multireadrandom`** — MultiGet batching: many keys per call, amortizing
- per-call overhead.
-- **`readwhilewriting`** — 1 writer + N readers: the "does compaction wreck
- my read tail?" test, and the closest thing in the menu to production. The
- `*whilemerging`/`*whilescanning` variants isolate other interference
- sources.
-
-Why it matters: read-only numbers (`readrandom` on a freshly-compacted DB)
-are the engine's best case; `readwhilewriting` is where LSM read/write
-interference — the thing users actually hit — shows up.
+- **`readrandom` / `readseq` / `readreverse`** (dispatch at 4078, 4062, 4076)
+ — point lookups versus iterator scans, forward and backward. A **point
+ lookup** must consult the memtable and then every level that could hold the
+ key, so it may touch several files; a **scan** positions once and then
+ streams.
+- **`seekrandom`** (4143) — the cost of positioning an iterator. `Seek` has to
+ touch every level to build the merging iterator's min-heap before it can
+ yield the first key (`table/merging_iterator.cc:23-39` states the invariant
+ every `Seek*()` must restore), so its profile is unlike a point `Get`.
+- **`multireadrandom`** (4086) — `MultiGet` batching: `entries_per_batch_`
+ keys per call, amortising per-call overhead across the batch.
+- **`readwhilewriting`** (4158-4160) — the "does compaction wreck my read
+ tail?" test, and the closest thing in the menu to production. The
+ `*whilemerging` / `*whilescanning` variants (4161-4166) isolate other
+ interference sources.
+
+`readwhilewriting` is worth one more level of detail, because its thread
+arithmetic is not what the flag says:
+
+```cpp
+// tools/db_bench_tool.cc — the dispatch arm, 4158-4160, and the method, 8337-8343
+ 4158 } else if (name == "readwhilewriting") {
+ 4159 num_threads++; // Add extra thread for writing
+ 4160 method = &Benchmark::ReadWhileWriting;
+
+ 8337 void ReadWhileWriting(ThreadState* thread) {
+ 8338 if (thread->tid > 0) {
+ 8339 ReadRandom(thread);
+ 8340 } else {
+ 8341 BGWriter(thread, kWrite);
+ 8342 }
+ 8343 }
+```
+
+Line **4159** adds one thread beyond `--threads`, and **8338** makes thread 0
+the writer and threads 1..N readers. So `--threads=8 readwhilewriting` runs
+nine threads: eight readers and one writer. And the writer removes itself from
+the reported figure:
+
+```cpp
+// tools/db_bench_tool.cc — inside BGWriter, 8372-8373
+ 8372 // Don't merge stats from this thread with the readers.
+ 8373 thread->stats.SetExcludeFromMerge();
+```
+
+Line **8373** sets the flag that `Stats::Merge` honours at 2484-2486 (Step 7),
+so the ops/s `readwhilewriting` prints is a *readers-only* number measured
+while a writer ran. That is the right choice — mixing a writer's ops into a
+read throughput figure would be meaningless — but it means the line tells you
+nothing about what the writer achieved, and the writer's rate is a free
+variable unless you also set `--benchmark_write_rate_limit` (1702-1705).
+
+Why it matters: read-only numbers (`readrandom` on a freshly-compacted DB) are
+the engine's best case; `readwhilewriting` is where LSM read/write
+interference — the thing users actually hit — shows up, and it is reported
+from only part of the run.
### Step 5 — distribution knobs: uniform lies, skew is reality
-By default the random modes draw keys **uniformly** (every key equally
-likely), but production traffic is **skewed** — a few hot keys dominate,
-classically modeled as a **Zipfian distribution** (popularity of the k-th
-hottest key falls off as 1/k^s, so a small fraction of keys absorbs most of
-the traffic). The difference is not cosmetic: uniform access defeats every
-cache (no key is hot enough to stay resident), while skewed access lets
-caches shine — the two can disagree on read throughput by an order of
-magnitude.
-
-db_bench's knobs: `read_random_exp_range` (:452) skews reads
-exponentially, and **`mixgraph`** (:4133) is the industrial-strength
-answer — it models Facebook's *measured* production distributions (the
-"Characterizing, Modeling..." FAST'20 paper) with two-term-exponential key
-ranges (`keyrange_dist_a..d`, :1708–1717) and Pareto-distributed value
-sizes. Same motivation as the capstone's Zipfian `workload` crate: uniform
-random keys are the wrong distribution.
+> **In:** the read workloads of Step 4, which so far draw keys uniformly.
+> **Out:** the two skew models db_bench offers, and the property that separates
+> them — a property Step 7's caveats depend on.
+
+By default the random modes draw keys **uniformly** — every key equally
+likely, which is exactly what line 6112's `% num_` gives. Production traffic is
+**skewed**: a few hot keys absorb most of the requests. The classic model is a
+**Zipfian distribution**, where the popularity of the k-th hottest key falls
+off as `1/k^s` for some exponent `s`, so a small fraction of keys takes a large
+fraction of the traffic. (The capstone's `workload` crate uses `s = 0.99`, the
+YCSB default, for the same reason.)
+
+The difference is not cosmetic. Uniform access over a key space larger than
+memory defeats every cache — no key is hot enough to stay resident — while
+skewed access lets the block cache serve most reads. The two can disagree on
+read throughput by an order of magnitude on identical hardware.
+
+db_bench offers two skew knobs, and they differ in a way that matters more
+than the shape of the curve.
+
+**`--read_random_exp_range`** (**452-456**) bends `readrandom`'s draw
+exponentially:
+
+```cpp
+// tools/db_bench_tool.cc — GetRandomKey, 7103-7120
+ 7103 int64_t GetRandomKey(Random64* rand) {
+ 7104 uint64_t rand_int = rand->Next();
+ 7105 int64_t key_rand;
+ 7106 if (read_random_exp_range_ == 0) {
+ 7107 key_rand = rand_int % FLAGS_num;
+ 7108 } else {
+ // ... 7109-7115: order = -(uniform in [0,1)) * read_random_exp_range_, then
+ // ... rand_num = exp(order) * FLAGS_num, which concentrates draws
+ // ... near 0 — larger flag value, sharper skew ...
+ 7116 // Map to a different number to avoid locality.
+ 7117 const uint64_t kBigPrime = 0x5bd1e995;
+ 7118 // Overflow is like %(2^64). Will have little impact of results.
+ 7119 key_rand = static_cast((rand_num * kBigPrime) % FLAGS_num);
+ 7120 }
+```
+
+The line to focus on is **7119**, and the comment above it on 7116 states the
+intent: the hot key *IDs* are deliberately scattered across the key space by
+multiplying by a large prime. So this flag gives you hotness **without**
+key-space locality — the hot keys are hot, but they live in different SST
+blocks. Hold that; it is the exact defect the FAST'20 paper measures.
+
+**`mixgraph`** (dispatch at **4133**) is the industrial-strength answer. It
+models Facebook's *measured* production workloads, from Cao et al.,
+"Characterizing, Modeling, and Benchmarking RocksDB Key-Value Workloads at
+Facebook" (FAST '20). §7.1 of that paper is the indictment: YCSB reproduces
+the overall hotness distribution, but "the hot KV-pairs are actually randomly
+distributed in the whole key-space", which makes a large number of data blocks
+hot and triggers "an extremely large number of block reads" — and the paper
+adds explicitly, "db_bench has a similar situation". §7.2 is the fix: partition
+the key space into key-ranges sized at the average number of KV-pairs per SST
+file, and model the *hotness of the ranges*, so hot keys sit near each other.
+
+The paper's fitted models (§7.4, on UDB's Assoc workload) map onto db_bench's
+flags almost one to one — with one discrepancy the paper wins:
+
+| db_bench flag (lines) | form in the flag's help text | FAST '20 §7.4's fit |
+|---|---|---|
+| `keyrange_dist_a..d` (**1708-1719**) | `f(x)=a*exp(b*x)+c*exp(d*x)` — two-term **exponential** | "The average KV-pair access count of key-ranges can be better fit in a two-term **power** model" |
+| `key_dist_a`, `key_dist_b` (**1723-1726**) | `f(x)=a*x^b` — simple power | "the distribution of KV-pair access counts follows a power-law that can be fit to the simple power model" ✓ |
+| `value_theta/k/sigma` (**1727-1737**) | Generalized Pareto | "Generalized Pareto Distribution best fits the value sizes" ✓ |
+| `iter_theta/k/sigma` (**1738-1748**) | Generalized Pareto | "…and Iterator scan length" ✓ |
+| `sine_a..d` (**1691-1697**) | `f(x) = A sin(bx + c) + d` | "the QPS variation has a strong diurnal pattern… better fit to the Sine model with a 24-hour period" ✓ |
+
+The Pareto value sizes are the paper's, confirmed at §7.4 (and §7.2 for
+ZippyDB). The key-range model is **not** the paper's two-term power model:
+db_bench implements a two-term *exponential*, and the code says so twice — the
+help text on 1709-1710 and the call to `gen_exp.InitiateExpDistribution` at
+**7944-7946**, guarded by "is any `keyrange_dist_*` non-zero" on 7941-7942.
+Both are two-parameter-pair mixtures fitted to the same empirical curve, so
+they are close in practice, but if you cite mixgraph as "the paper's model",
+this is the term that is yours and not theirs. The `value_k = 0.2615` and
+`value_sigma = 25.45` defaults, by contrast, are flagged in the source as
+"reasonable defaults based on the mixgraph paper" (1730-1737).
+
+The property that separates the two knobs: `read_random_exp_range` destroys
+locality on purpose (7116-7119); `mixgraph` preserves it by construction
+(7963-7965 routes the draw through the key-range distribution first). Same
+hotness curve, opposite block-cache behaviour.
Why it matters: a benchmark's key distribution silently decides whether the
-cache hierarchy participates in the result.
+cache hierarchy participates in the result — and *where* the hot keys sit
+decides it a second time, independently of how hot they are.
### Step 6 — the comma list is the methodology
-db_bench runs its benchmarks *in order against the same database*, so
-earlier entries create the state later ones measure:
+> **In:** the named workloads of Steps 3-5, as strings.
+> **Out:** the state each one runs against — the missing half of every
+> published db_bench figure, and the thing Step 7 tells you to ask for.
+
+`--benchmarks` is split on commas and run in order, against one database that
+is opened once:
+
+```cpp
+// tools/db_bench_tool.cc — the top of Benchmark::Run, 3924-3935
+ 3924 void Run(ToolHooks& hooks) {
+ 3925 if (!SanityCheck()) {
+ 3926 ErrorExit();
+ 3927 }
+ 3928 Open(&open_options_, hooks);
+ 3929 PrintHeader(open_options_);
+ 3930 std::stringstream benchmark_stream(FLAGS_benchmarks);
+ 3931 std::string name;
+ 3932 std::unique_ptr filter;
+ 3933 while (std::getline(benchmark_stream, name, ',')) {
+ 3934 // Sanitize parameters
+ 3935 num_ = FLAGS_num;
+```
+
+Line **3928** opens the DB *before* the loop on 3933, so by default every
+entry inherits the previous entry's database. But — and this is the correction
+that changes how you read a comma list — an entry that set `fresh_db = true`
+in Step 3's dispatch destroys it first:
+
+```cpp
+// tools/db_bench_tool.cc — after the dispatch chain, 4295-4317
+ 4295 if (fresh_db) {
+ 4296 DbStateMutationGuard mutation(this);
+ 4297 if (FLAGS_use_existing_db) {
+ 4298 fprintf(stdout, "%-12s : skipped (--use_existing_db is true)\n",
+ 4299 name.c_str());
+ 4300 method = nullptr;
+ 4301 } else {
+ 4302 if (db_.db != nullptr) {
+ 4303 db_.DeleteDBs();
+ 4304 DestroyDB(FLAGS_db, open_options_);
+ 4305 }
+ // ... 4306-4315: the same destroy loop for the --num_multi_db case ...
+ 4316 Open(&open_options_, hooks); // use open_options for the last accessed
+ 4317 }
+```
+
+Line **4304** is the one to look at: `DestroyDB`. Every `fill*` name except
+`overwrite` sets `fresh_db` (4031, 4038, 4042, 4053, 4058), so it *wipes*
+whatever came before it. So:
```
db_bench --benchmarks=fillseq,readrandom --num=10000000 --value_size=100 --histogram
│ │
│ └── measured against the DB fillseq just built
- └── builds a compaction-debt-free DB (Step 3)
+ └── fresh_db = true (4031) → DestroyDB (4304) → build 10M keys in order
+
+fillseq,fillrandom,readrandom fillrandom wipes fillseq's work (4038) — the
+ first name contributed nothing at all
+fillrandom,overwrite,readrandom overwrite does NOT wipe (4050-4051), so it
+ shadows fillrandom's 6.32M live keys and
+ readrandom sees a DB thick with dead versions
```
`fillseq,readrandom` measures reads on a clean, fully-sorted DB;
`fillrandom,readrandom` measures reads on a fragmented one — same second
-benchmark, very different numbers. That ordering **is** the methodology, and
-it's the first thing to check when reproducing a published result.
+benchmark, very different numbers. And `--use_existing_db` (4297-4300) turns
+the destroy into a *skip*, so the same command line means something different
+again. That ordering **is** the methodology, and it is the first thing to
+check when reproducing a published result.
Why it matters: two papers can both say "readrandom, 10M keys" and still be
-measuring different databases.
+measuring different databases — and a comma list you have not traced through
+4295 may contain a name that did nothing.
### Step 7 — what to distrust in the reported numbers
-Knowing how the numbers are produced tells you which claims they can and
-cannot support:
+> **In:** everything above — the workload, the state it ran against, and the
+> threads that ran it.
+> **Out:** a list of claims a db_bench figure can and cannot support, and the
+> line of code behind each one.
+
+Knowing how the numbers are produced tells you which claims they can support:
```mermaid
flowchart TD
- F["--benchmarks=fillseq,readrandom,--histogram
comma list runs IN ORDER against the same DB"]
- F --> RUN["Benchmark::Run() (4030)
workload name → method pointer"]
- RUN --> RB["RunBenchmark(n, name, method) (4583)
spawn N threads"]
- RB --> T1["thread 1
Stats + HistogramImpl (2436)"]
+ F["--benchmarks=fillseq,readrandom --histogram
split on commas at 3933, run IN ORDER against one DB opened at 3928"]
+ F --> RUN["Benchmark::Run 3924
dispatch chain 4030-4291: name → method pointer
fresh_db → DestroyDB at 4304"]
+ RUN --> RB["RunBenchmark 4583
spawn N threads 4608-4634"]
+ RB --> T1["thread 1
closed loop, e.g. ReadRandom 7147-7229
Stats + HistogramImpl 2436-2452"]
RB --> T2["thread 2 ..."]
RB --> TN["thread N"]
- T1 --> M["merge per-thread histograms (2488)
never average percentiles — Tene's rule"]
+ T1 --> M["merge_stats.Merge per thread 4649-4652
Stats::Merge 2483-2495 adds histograms at 2491
never averages percentiles — Tene's rule"]
T2 --> M
TN --> M
- M --> OUT["default output: throughput
latency histogram only with --histogram"]
-```
-
-- **Default output is throughput** (ops/s, MB/s). Per-op latency goes
- through `FinishedOps` (:2564) into a plain `HistogramImpl` — reported only
- with `--histogram`. A quoted latency without that flag didn't come from
- here.
-- **It's a closed loop** (each thread issues the next op only after the
- previous completes — the same structure as redis-benchmark), so it suffers
- coordinated omission (the measurement error where the generator pauses
- during server stalls, under-sampling the worst moments): a compaction
- stall yields a handful of bad samples instead of the thousands a paced
- workload would record. There's a
- `--benchmark_write_rate_limit`/read-rate variant for paced writes, but no
- coordinated-omission correction — the redis-benchmark critique applies
- verbatim.
-- **"Latency" here is service time by construction** — db_bench measures
- the *embedded* engine, no network, no queueing. Legitimate for engine
- work; misleading if quoted as user-facing latency.
-- One thing it gets *right*: per-thread histograms are **merged** (:2488),
- never averaged — you cannot average percentiles (Tene's rule), and
- db_bench doesn't try.
+ M --> OUT["Stats::Report 2692-2732
always: micros/op mean + ops/sec + MB/s
percentiles only under FLAGS_histogram at 2717"]
+```
+
+- **Default output is throughput, plus a mean.** `Stats::Report` prints
+ `micros/op`, `ops/sec`, elapsed seconds and MB/s on 2712-2716 — and the
+ `micros/op` on 2715 is `seconds_ * 1e6 / done_`, an arithmetic mean.
+ Percentiles appear only inside `if (FLAGS_histogram)` on **2717-2723**. A
+ quoted p99 without that flag did not come from here.
+- **The per-op clock is an inter-arrival time, not a service time.**
+ `FinishedOps` (**2564-2584**) computes `micros = now - last_op_finish_`
+ (2571) and then sets `last_op_finish_ = now` (2583). The interval it records
+ is "since the previous op finished", so it includes everything the loop did
+ between the two ops — key generation, the `GetRandomKey` call, the loop
+ bookkeeping — not just the engine call.
+- **It's a closed loop.** Each thread issues the next op only after the
+ previous one completed — `ReadRandom`'s `while (!duration.Done(1))` at
+ **7147** runs to `FinishedOps` at **7228** with nothing in between that
+ waits for a clock. This is the same structure as redis-benchmark
+ ([reading-redis-benchmark.md](reading-redis-benchmark.md)), so it suffers
+ **coordinated omission**: the measurement error where the generator, by
+ waiting for the server, stops issuing requests during a stall and therefore
+ under-samples exactly the worst moments. A compaction stall yields a handful
+ of bad samples instead of the thousands a paced workload would record. Topic
+ 34 measured the size of this on identical work: **closed-loop p99 = 1.0 µs
+ against open-loop p99 = 90 ms**, a 90,000× understatement
+ ([FINDINGS.md](../../FINDINGS.md) row 34).
+- **The rate limiter does not fix it — it is explicitly excluded from the
+ measurement.** `--benchmark_write_rate_limit` (1702-1705) does exist, and
+ `RunBenchmark` builds the limiter at 4590-4598. But look at what `DoWrite`
+ does immediately after waiting on it:
+
+```cpp
+// tools/db_bench_tool.cc — inside DoWrite, after the rate limiter's Request, 6524-6532
+ 6524 if (thread->shared->write_rate_limiter.get() != nullptr) {
+ 6525 thread->shared->write_rate_limiter->Request(
+ 6526 batch_bytes, Env::IO_HIGH, nullptr /* stats */,
+ 6527 RateLimiter::OpType::kWrite);
+ 6528 // Set time at which last op finished to Now() to hide latency and
+ 6529 // sleep from rate limiter. Also, do the check once per batch, not
+ 6530 // once per write.
+ 6531 thread->stats.ResetLastOpTime();
+ 6532 }
+```
+
+ Line **6531** calls `ResetLastOpTime` (2559-2562), whose whole body is
+ `last_op_finish_ = clock_->NowMicros()`. The comment on 6528-6529 says the
+ intent out loud: *hide* the pacing wait from the latency. So even the paced
+ mode measures service time by construction — there is no intended-arrival
+ timestamp anywhere in `Stats` to subtract from. Coordinated omission here is
+ not an oversight, it is a documented design choice.
+- **"Latency" here is service time by construction** for a second reason:
+ db_bench measures the *embedded* engine — no network, no queueing, no client
+ library. Legitimate for engine work; misleading if quoted as user-facing
+ latency.
+- **The histogram is coarse.** `HistogramImpl`'s buckets grow by **1.5×**
+ (`monitoring/histogram.cc:23-42`, the `bucket_val = 1.5 * bucket_val` on
+ line 28, rounded to two significant digits on 31-38), and `Percentile`
+ interpolates *linearly* inside the chosen bucket
+ (`monitoring/histogram.cc:130-160`, the interpolation on 137-147, clamped to
+ the observed min and max on 148-155). A p99 that lands mid-bucket is
+ therefore a linear guess across a range 50% wide. The printed set is fixed at
+ P50/P75/P99/P99.9/P99.99 (`monitoring/histogram.cc:197-199`) — there is no
+ p99.999, which is where Step 4's rare compaction stalls would have shown up.
+- One thing it gets *right*: per-thread histograms are **merged**, never
+ averaged. `RunBenchmark` folds each thread's `Stats` in at 4649-4652, and
+ `Stats::Merge` (2483-2495) adds the *bucket counts* together at 2491. You
+ cannot average percentiles (Tene's rule), and db_bench does not try. The
+ same function honours `exclude_from_merge_` at 2484-2486, which is how
+ Step 4's background writer drops out.
Why it matters: db_bench numbers are honest answers to narrow questions;
-distrust begins when they're quoted as answers to broad ones.
+distrust begins when they are quoted as answers to broad ones.
## Where each step lives in the code — the skim route (30–60 min)
-`tools/db_bench_tool.cc` is a ~10,400-line flag-driven monolith — **do not
-read it linearly**; hit these anchors:
+`tools/db_bench_tool.cc` is a **10,367-line** flag-driven monolith at `7c80a5a`
+— **do not read it linearly**; hit these anchors:
| Lines | What | Step |
|-------|------|------|
-| 115–170 | `DEFINE_string(benchmarks, ...)` — the full workload menu; read the help text below it (172+), it's the best documentation | 3, 4 |
-| 275–458 | The knobs that define a workload: `num`, `threads`, `value_size`, `histogram`, `read_random_exp_range` (452) | 5, 7 |
-| 1708–1717 | `keyrange_dist_a..d` — the mixgraph skew model | 5 |
-| 2436 | `class Stats` — per-thread stats, `HistogramImpl` per op type | 7 |
-| 2564 | `Stats::FinishedOps` — where each op's micros get recorded | 7 |
-| 3802 | `GenerateKeyFromInt` — int → fixed-width key; all key distributions reduce to picking the int | 2 |
-| 4030–4140 | `Benchmark::Run()` dispatch: `name == "fillseq"` → method pointer — the map from workload name to implementation | 3, 4, 6 |
-| 4583 | `RunBenchmark(n, name, method)` — spawns N threads, merges per-thread `Stats` (histogram merge at 2488, same lesson as Tene: merge histograms, never average percentiles) | 7 |
+| 115-170 | `DEFINE_string(benchmarks, …)` — the full workload menu; the help text at 172-273 is the best documentation the tool has | 1, 3, 4 |
+| 275-458 | The knobs that define a workload: `num` (275), `threads` (328), `value_size` (337), `key_size` (388), `read_random_exp_range` (452-456), `histogram` (458) | 1, 5, 7 |
+| 1691-1697 | `sine_a..d` — the diurnal QPS model, `f(x) = A sin(bx+c)+d` | 5 |
+| 1702-1705 | `benchmark_write_rate_limit` — the paced-write flag whose wait is hidden at 6531 | 7 |
+| 1708-1719 | `keyrange_dist_a..d` — mixgraph's two-term exponential key-range model | 5 |
+| 1723-1748 | `key_dist_a/b` (power), `value_*` and `iter_*` (Generalized Pareto) — the rest of the mixgraph fit | 5 |
+| 2436-2452 | `class Stats` — per-thread state; `hist_` is a map of `HistogramImpl` per op type (2450-2452) | 7 |
+| 2483-2495 | `Stats::Merge` — histogram buckets added at 2491, `exclude_from_merge_` honoured at 2484-2486 | 4, 7 |
+| 2559-2562 | `Stats::ResetLastOpTime` — one line, and the whole coordinated-omission story | 7 |
+| 2564-2584 | `Stats::FinishedOps` — `micros = now - last_op_finish_` (2571), recorded only under `FLAGS_histogram` (2569) | 7 |
+| 2692-2732 | `Stats::Report` — throughput always (2712-2716), percentiles only at 2717-2723 | 7 |
+| 3797-3842 | `GenerateKeyFromInt` — big-endian binary at 3833, `'0'` padding at 3840, not decimal zero-padding | 2 |
+| 3924-3935 | `Benchmark::Run` — `Open` once at 3928, comma split at 3933 | 6 |
+| 4030-4291 | The dispatch chain: `name == "fillseq"` → method pointer. Fill family 4030-4061, read family 4062-4090, `mixgraph` 4133, `seekrandom` 4143, `readwhilewriting` 4158-4160, unknown-name error 4290-4292 | 3, 4, 5, 6 |
+| 4295-4317 | `if (fresh_db)` → `DestroyDB` at 4304 — why some comma-list entries erase the ones before them | 6 |
+| 4583-4652 | `RunBenchmark` — rate limiters 4590-4598, thread spawn 4608-4634, per-thread merge 4649-4652 | 7 |
| 5869 | `enum WriteMode { RANDOM, SEQUENTIAL, UNIQUE_RANDOM }` | 2 |
-| 6088 | `class KeyGenerator` — how UNIQUE_RANDOM permutes the key space | 2 |
+| 6088-6134 | `class KeyGenerator` — the shuffle at 6098-6103, `Next()` at 6107-6119 | 2 |
+| 6158-6181 | `DoWrite` — one `KeyGenerator` per thread over the whole key space | 2, 3 |
+| 6524-6532 | the write rate limiter, and `ResetLastOpTime` hiding its wait | 7 |
+| 7103-7120 | `GetRandomKey` — exponential skew, then `kBigPrime` to destroy locality (7116-7119) | 5 |
+| 7147-7229 | `ReadRandom`'s loop — a closed loop, `FinishedOps` at 7228 | 7 |
+| 7941-7946 | mixgraph's `InitiateExpDistribution` — the two-term exponential, in code | 5 |
+| 8337-8343 | `ReadWhileWriting` — tid 0 writes, the rest read | 4 |
+| 8372-8373 | `SetExcludeFromMerge` — the writer drops out of the reported number | 4, 7 |
+
+Suggested route: the menu (115-170) and its help text → `Benchmark::Run`
+(3924) → the dispatch chain (4030-4291) for the three or four names you care
+about → `fresh_db` (4295-4317) → `KeyGenerator::Next` (6107) →
+`GenerateKeyFromInt` (3802) → `RunBenchmark` (4583) → `FinishedOps` (2564) and
+`Report` (2692). As you trace it, look for an intended-arrival timestamp
+anywhere in `Stats` (2436-2452); its absence is the last bullet of Step 7.
+
+Three anchors live outside `db_bench_tool.cc` and are worth the detour:
+`include/rocksdb/options.h:2502-2515` (what `sync = true` actually promises —
+Step 3), `monitoring/histogram.cc:23-42` and `130-160` (how coarse every
+printed percentile is — Step 7), and `table/merging_iterator.cc:23-39` (why
+`seekrandom` costs what it does — Step 4).
+
+## Questions to answer in notes.md
+
+1. `fillrandom` and `overwrite` dispatch to the *same* method, `WriteRandom`
+ (4039, 4051). Find the one line that differs, then say what
+ `fillrandom,overwrite,readrandom` measures that `fillrandom,readrandom`
+ does not.
+2. `GenerateKeyFromInt` writes the integer big-endian at 3833. Rewrite line
+ 3833 as little-endian in your head: which of Step 3's four fill workloads
+ changes character, and what happens to compaction under `fillseq`?
+3. Step 2's arithmetic says a 10 M-op `fillrandom` touches 6.32 M distinct
+ keys. Run the same derivation for `--num=1000 --writes=10000` (10 draws per
+ key): what fraction of the key space is still never written, and why does
+ the answer stop being 1/e?
+4. `--benchmark_write_rate_limit` paces writes, and 6531 then hides the pacing
+ wait from the histogram. If you wanted db_bench to report open-loop latency
+ instead, which field would you add to `class Stats` (2436-2452), and which
+ of 2571 and 6531 would have to change?
+5. `readwhilewriting` with `--threads=8` runs nine threads and reports eight
+ of them (4159, 8338, 8373). Design the smallest experiment using only the
+ flags in 275-458 and 1702-1705 that tells you whether a p99 regression came
+ from the writer's rate or from compaction.
## Takeaway
-db_bench's value is the workload taxonomy, not the harness. When topic 4 (LSM) and M4
-(backend shootout) arrive, name capstone benches in this vocabulary (`fillseq`,
-`readrandom`, `readwhilewriting`) so numbers are comparable against published RocksDB
-results.
+db_bench's value is the workload taxonomy, not the harness. When topic 4 (LSM)
+and M4 (backend shootout) arrive, name capstone benches in this vocabulary
+(`fillseq`, `readrandom`, `readwhilewriting`) so numbers are comparable against
+published RocksDB results — and record the comma list, not just the name, since
+Step 6 shows the list is the methodology.
## Done when
+Answer each before unfolding it.
+
- [ ] You can explain why a shared workload vocabulary matters more than any single number db_bench prints.
-- [ ] You can name the four members of the fill family and say which one is the adversarial case for a B-tree (and check that against topic 1's measured 63x space amp).
-- [ ] You can explain why a uniform key distribution flatters almost every engine, and what changes under Zipf.
-- [ ] You can read a db_bench comma list and reconstruct the methodology it encodes.
+
+ Answer
+
+ Because a single number names a point on a surface without naming the point.
+ The axes are operation mix, key order, insert-vs-overwrite, durability, and
+ what background work was running, and this repo has measured two of them
+ independently: the durability axis alone spans 856,898/s (buffered `write()`)
+ to 44,109/s (`fsync`) to 337/s (`F_FULLFSYNC`), a 2,542× range
+ ([FINDINGS.md](../../FINDINGS.md) row 5); the same 108 MB of records lands at
+ 0.45× space amplification on an LSM and 63.28× on a copy-on-write B-tree
+ (row 1). A number without the axes is compatible with almost any engine
+ quality.
+
+ A name fixes the axes. `fillrandom` means `WriteRandom` into a destroyed and
+ recreated DB (4037-4039 plus the `DestroyDB` at 4304); `overwrite` means the
+ same method against inherited state (4050-4051); `fillsync` means
+ `write_options_.sync = true` over `num_/1000` ops (4052-4056). Three names,
+ one method pointer, three different experiments — and anyone who has read
+ those twenty lines can reproduce yours.
+
+
+
+- [ ] You can name the four members of the fill family and say which one is the adversarial case for a B-tree — then check that against topic 1's measured 63.28× space amplification on redb.
+
+ Answer
+
+ `fillseq` (4030-4032, `SEQUENTIAL`), `fillrandom` (4037-4039, `RANDOM` into a
+ fresh DB), `overwrite` (4050-4051, `RANDOM` into inherited state), `fillsync`
+ (4052-4056, `RANDOM` with `sync = true` over `num_/1000` ops). A fifth,
+ `filluniquerandom` (4040-4049), is the isolation experiment: random order,
+ zero duplicates, single-threaded by force.
+
+ `fillrandom` is the adversarial case for a B-tree, and topic 1 measured
+ exactly it: 1.08 M records of 100 B in random key order, batched 1000 at a
+ time, gave redb (a copy-on-write B-tree) **63.28× space amplification** —
+ 6,833.9 MB on disk for 108.0 MB of logical data — against fjall's 0.45×
+ ([FINDINGS.md](../../FINDINGS.md) row 1,
+ [topic 1 notes](../01-storage-engine-landscape/notes.md)). The mechanism in
+ those notes is the same one Step 2 derives: random-order inserts touch a new
+ leaf almost every time, and each batch commit copies every page on the path
+ to the root without being able to free the old ones yet. Sequential order
+ (`fillseq`) removes it, which is why using `fillseq` as your headline write
+ number flatters both engine families and settles nothing.
+
+
+
+- [ ] You can explain why a uniform key distribution flatters almost every engine, and what changes under skew — including the difference between db_bench's two skew knobs.
+
+ Answer
+
+ Uniform is the default because line 6112 is `rand_->Next() % num_`, and it is
+ the *hardest* case for caching: with the key space larger than memory, no key
+ is requested often enough to stay resident, so the block cache hit rate
+ collapses toward the ratio of cache size to data size. Real traffic is
+ skewed — a Zipfian tail where popularity falls as `1/k^s` — so the same
+ engine on the same hardware serves most reads from cache and posts an
+ order-of-magnitude better number. Reporting uniform is not conservative; it
+ measures a workload nobody runs.
+
+ The two knobs differ in *where* the hot keys sit, not how hot they are.
+ `--read_random_exp_range` (452-456, implemented at 7103-7120) skews the draw
+ exponentially and then multiplies by `kBigPrime` at 7119, under a comment
+ that says "Map to a different number to avoid locality" — hotness with the
+ key-space locality deliberately removed. `mixgraph` (4133) does the opposite:
+ it models the hotness of *key-ranges* first (7941-7946, 7963-7965), so hot
+ keys share SST blocks. FAST '20 §7.1 is exactly this measurement — YCSB
+ reproduces the hotness curve but scatters the hot keys, which triggers "an
+ extremely large number of block reads", and the paper notes "db_bench has a
+ similar situation".
+
+
+
+- [ ] You can read a db_bench comma list and reconstruct the methodology it encodes — including which entries erased the ones before them.
+
+ Answer
+
+ The list is split on commas at 3933 and run in order against a database
+ opened once at 3928 — but every entry that set `fresh_db` in the dispatch
+ chain hits `DestroyDB` at 4304 before it runs. `fillseq` (4031), `fillrandom`
+ (4038), `filluniquerandom` (4042), `fillsync` (4053) and `fill100K` (4058)
+ all do; `overwrite` (4050-4051) pointedly does not.
+
+ So `fillseq,readrandom` reads a clean, fully-sorted, compaction-debt-free DB;
+ `fillrandom,readrandom` reads a fragmented one holding ~6.32 M distinct keys
+ out of 10 M writes (Step 2's arithmetic); `fillrandom,overwrite,readrandom`
+ reads a DB thick with dead versions, because `overwrite` inherited
+ `fillrandom`'s keys and shadowed them. And `fillseq,fillrandom,readrandom`
+ contains a lie of omission: `fillrandom` destroyed everything `fillseq`
+ built, so the first name contributed nothing but its own throughput line. One
+ more flag changes it again — `--use_existing_db` (4297-4300) converts the
+ destroy into a *skip*, so the fill entry prints "skipped" and the read runs
+ against whatever was on disk.
+
+
+
- [ ] You can name three things in a reported db_bench figure you would refuse to take at face value, and what you would ask for instead.
+ Answer
+
+ First, **a percentile without `--histogram`**. `Stats::Report` prints ops/s,
+ MB/s and a mean `micros/op` (2712-2716); the percentile block is inside
+ `if (FLAGS_histogram)` at 2717-2723, and `FinishedOps` does not even record
+ the sample otherwise (2569). Ask for the full command line. Then ask which
+ percentiles: the set is fixed at P50/P75/P99/P99.9/P99.99
+ (`monitoring/histogram.cc:197-199`), the buckets grow by 1.5×
+ (`monitoring/histogram.cc:23-42`), and `Percentile` interpolates linearly
+ inside them (130-160), so a mid-bucket p99 is a guess across a 50%-wide range.
+
+ Second, **any tail number at all**, because the loop is closed:
+ `ReadRandom` runs 7147→7228 with nothing waiting on a clock, and even the
+ paced mode hides the wait — `DoWrite` calls `ResetLastOpTime` right after the
+ rate limiter (6531), under a comment that says the goal is to "hide latency
+ and sleep from rate limiter" (6528-6529). Topic 34 measured what that costs
+ on identical work: p99 of 1.0 µs closed-loop against 90 ms open-loop, a
+ 90,000× understatement ([FINDINGS.md](../../FINDINGS.md) row 34). Ask for an
+ open-loop rerun, or treat the figure as service time only.
+
+ Third, **the benchmark name without its comma list and its flags**, per
+ Step 6: `readrandom` alone does not say whether `DestroyDB` ran at 4304
+ before it, whether the keys were uniform or bent by
+ `--read_random_exp_range` (452), or — for `readwhilewriting` — that the
+ printed number came from `--threads` readers while a writer that
+ `SetExcludeFromMerge`'d itself (8373) ran at an unstated rate. Ask for the
+ whole invocation, not the name.
+
+
+
+- [ ] You can say what `fillsync` actually measures, and why "three to four orders of magnitude below fillseq" is the wrong number for it.
+
+ Answer
+
+ `fillsync` is `WriteRandom` with `write_options_.sync = true` (4055) over
+ `num_ / 1000` ops (4053-4056) into a freshly destroyed DB — so it varies two
+ things against `fillseq` at once, key order *and* durability, which already
+ makes the comparison a poor isolation experiment. `fillrandom` is the right
+ control.
+
+ The size of the durability term is pinned by RocksDB's own header:
+ `include/rocksdb/options.h:2512-2515` says a write with `sync == true` has
+ "similar crash semantics to a `write()` system call followed by
+ `fdatasync()`" — the `fdatasync` rung, not a drive cache flush. Topic 5
+ measured that ladder on this hardware: `write()` p50 1.17 µs → 856,898
+ commits/s, `fsync` p50 22.67 µs → 44,109/s, `F_FULLFSYNC` p50 2.97 ms →
+ 337/s ([FINDINGS.md](../../FINDINGS.md) row 5). 856,898 / 44,109 = **19.4×**,
+ about 1.3 orders of magnitude. The 3–4 orders belongs to the bottom rung:
+ 856,898 / 337 = 2,542×, or 3.4 orders — and that rung costs 131× more than
+ the one `sync = true` promises. So quote 19× unless you know the platform's
+ `WritableFile::Sync()` reached the cache flush, and say which you mean.
+
+
+
## References
**Papers**
-- Cao, Dong, Vemuri, Du — "Characterizing, Modeling, and Benchmarking
- RocksDB Key-Value Workloads at Facebook" (FAST 2020) — the measured
- production distributions behind `mixgraph`; optional, skim §4-5
+- Cao, Dong, Vemuri, Du — "Characterizing, Modeling, and Benchmarking RocksDB
+ Key-Value Workloads at Facebook", FAST '20
+ ([PDF](https://www.usenix.org/system/files/fast20-cao_zhichao.pdf)) — the
+ measured production distributions behind `mixgraph`. Read **§7.1** (why YCSB
+ and db_bench mislead: hot keys scattered across the key space cause "an
+ extremely large number of block reads"; "db_bench has a similar situation"),
+ **§7.2** (key-range based modeling, and why the key-range size is the average
+ number of KV-pairs per SST file), and **§7.4** (the fitted models: two-term
+ *power* for key-range access counts, simple power within a range, Generalized
+ Pareto for value sizes and iterator scan lengths, Sine for QPS). Note the
+ mismatch flagged in Step 5: db_bench implements a two-term *exponential*
+ where §7.4 reports a two-term *power* fit.
**Code**
- [rocksdb](https://github.com/facebook/rocksdb) `tools/db_bench_tool.cc`
- (~10,400 lines, shallow clone @ `7c80a5a`) — **do not read this
- linearly**; it's a flag-driven monolith — follow the skim route table
- above (30–60 min)
+ (**10,367 lines** at `7c80a5a`, the commit in `resources/codebases.md`'s pin
+ table) — **do not read this linearly**; it is a flag-driven monolith. Follow
+ the skim route above (30–60 min).
+
+| File | Lines | What |
+|------|-------|------|
+| `tools/db_bench_tool.cc` | 115-170 | the workload menu, with its help text at 172-273 |
+| `tools/db_bench_tool.cc` | 275-458 | the knobs: `num`, `threads`, `value_size`, `key_size`, `read_random_exp_range`, `histogram` |
+| `tools/db_bench_tool.cc` | 1708-1748 | mixgraph's fitted-distribution flags |
+| `tools/db_bench_tool.cc` | 2483-2495 | `Stats::Merge` — histograms added, never averaged |
+| `tools/db_bench_tool.cc` | 2559-2562 | `ResetLastOpTime` — the pacing wait, hidden |
+| `tools/db_bench_tool.cc` | 2564-2584 | `FinishedOps` — the only per-op clock |
+| `tools/db_bench_tool.cc` | 2692-2732 | `Stats::Report` — throughput always, percentiles under a flag |
+| `tools/db_bench_tool.cc` | 3802-3842 | `GenerateKeyFromInt` — big-endian binary, `'0'`-padded |
+| `tools/db_bench_tool.cc` | 4030-4291 | the name → method dispatch chain |
+| `tools/db_bench_tool.cc` | 4295-4317 | `fresh_db` → `DestroyDB` |
+| `tools/db_bench_tool.cc` | 4583-4652 | `RunBenchmark` — spawn, join, merge |
+| `tools/db_bench_tool.cc` | 5869 | `enum WriteMode` |
+| `tools/db_bench_tool.cc` | 6088-6134 | `KeyGenerator` — the three key orders |
+| `tools/db_bench_tool.cc` | 6524-6532 | the write rate limiter and its hidden wait |
+| `tools/db_bench_tool.cc` | 7103-7120 | `GetRandomKey` — exponential skew, locality destroyed |
+| `tools/db_bench_tool.cc` | 8337-8343 | `ReadWhileWriting` — tid 0 writes |
+| `monitoring/histogram.cc` | 23-42 | 1.5× bucket growth — the resolution of every db_bench percentile |
+| `monitoring/histogram.cc` | 130-160 | `Percentile` — linear interpolation inside a 50%-wide bucket |
+| `monitoring/histogram.cc` | 197-199 | the fixed printed set: P50, P75, P99, P99.9, P99.99 |
+| `include/rocksdb/options.h` | 2502-2515 | `WriteOptions::sync` — "similar crash semantics to a `write()` followed by `fdatasync()`", which is the rung `fillsync` lands on |
+| `table/merging_iterator.cc` | 23-39 | the min-heap invariant every `Seek*()` restores — why `seekrandom` is not a point `Get` |
+
+**Connections**
+- [reading-redis-benchmark.md](reading-redis-benchmark.md) — the same
+ closed-loop defect in a network load generator, with the open-loop fix
+ sketched.
+- [topic 34](../34-debugging/README.md) — coordinated omission measured:
+ p99 1.0 µs closed-loop against 90 ms open-loop.
+- [topic 5](../05-durability-wal/README.md) — the fsync ladder `fillsync`
+ lands on.
+- [topic 1](../01-storage-engine-landscape/README.md) — `fillrandom`'s space
+ amplification, measured on two engine families.
diff --git a/topics/01-storage-engine-landscape/reading-architecture-of-a-dbms.md b/topics/01-storage-engine-landscape/reading-architecture-of-a-dbms.md
index e020e1d..30e495b 100644
--- a/topics/01-storage-engine-landscape/reading-architecture-of-a-dbms.md
+++ b/topics/01-storage-engine-landscape/reading-architecture-of-a-dbms.md
@@ -9,25 +9,56 @@ query, and what breaks without it. Then it routes you through the paper:
read the map chapters this week, return per-topic as each box gets built.
You are NOT reading all ~120 pages now; budget 2 h.
+Every section number below was checked against the PDF of *Foundations and
+Trends® in Databases* Vol. 1, No. 2 (2007), pp. 141–259 — the version linked in
+the References. **The previous version of this chapter had the section map
+wrong**: storage management is §5, not §6, and §6 is transactions. The
+corrected routing table is in "How to read the paper" below, and it matters,
+because §6 is the section you would land in if you followed the old table
+looking for the buffer pool.
+
## The problem in one sentence
-PostgreSQL is ~1.5 million lines of C, and a single `SELECT name FROM users
-WHERE id = 42` passes through five major subsystems on its way to one row —
-without a map of those subsystems, every later topic in this curriculum is
-a tree with no forest.
+The paper's own description of the systems it is surveying is "multi-million
+line code bases, most of which are well over a decade old" (§1) — and a single
+`SELECT name FROM users WHERE id = 42` passes through five major subsystems of
+one of them on its way to one row, so without a map of those subsystems every
+later topic in this curriculum is a tree with no forest.
## The concepts, step by step
Follow the query. It arrives as bytes on a TCP socket and leaves as a row;
-each step is the next box it passes through.
+each step is the next box it passes through. The paper follows the same query
+in §1.1, using a gate agent at an airport clicking a form to request the
+passenger list for a flight — one button click, one single-query transaction,
+five boxes.
+
+Two words the paper defines before it uses them, and so does this chapter. A
+**DBMS client** is the library implementing the API an application calls
+(JDBC, ODBC, or a driver speaking a proprietary protocol); a **DBMS worker**
+is "the thread of execution in the DBMS that does work on behalf of a DBMS
+Client" (§2, definitions), and the paper insists on a 1:1 mapping between the
+two — one worker handles all SQL requests from one client. Everything in
+Step 2 is about what a "worker" is made of.
### Step 1 — the client communications manager: bytes in, rows out
+> **In:** nothing yet — an incoming TCP connection carrying an undelimited
+> byte stream.
+> **Out:** connection state (credentials, current SQL command) plus one framed
+> query message, forwarded "deeper into the DBMS" (§1.1, item 1) to Step 2.
+
The client communications manager is the code that speaks the **wire
protocol** — the byte format client and server agree on for shipping queries
-in and results out. It accepts connections, authenticates them, frames the
-incoming byte stream into discrete query messages, and streams result rows
-back:
+in and results out. The paper's statement of its job is deliberately narrow
+(§1.1, item 1): "to establish and remember the connection state for the caller
+(be it a client or a middleware server), to respond to SQL commands from the
+caller, and to return both data and control messages (result codes, errors,
+etc.) as appropriate."
+
+**Framing** is the part that word "establish" hides: TCP delivers a stream with
+no message boundaries, so the manager must decide where one query ends and the
+next begins.
```
client server
@@ -37,167 +68,554 @@ back:
Concretely: PostgreSQL has its own binary protocol; Redis uses RESP (a
text-framed protocol the capstone adopts in topic 7 because it's ~1 page of
-spec). The non-obvious job is **result streaming**: a 10-million-row result
-must flow out incrementally, and a slow client must back-pressure the
-executor instead of forcing the server to buffer everything.
-
-Without this box there is no way in — and a naive version that buffers whole
-results turns one big query into an out-of-memory crash.
+spec). The paper adds a tier count you will recognize: client→DBMS directly is
+"two-tier", a web server or TP monitor in between makes it "three-tier", and an
+application server between those makes four (§1.1, item 1) — which is why "a
+typical DBMS needs to be compatible with many different connectivity
+protocols".
+
+The non-obvious job is **result streaming**: a large result must flow out
+incrementally rather than being materialized in server memory. §1.1, item 5
+states the mechanism — "for large result sets, the client typically will make
+additional calls to fetch more data incrementally from the query, resulting in
+multiple iterations through the communications manager, query executor, and
+storage manager." §2.1.4 names the shape of that loop: "SQL is typically used
+in a 'pull' model: clients consume result tuples from a query cursor by
+repeatedly issuing the SQL FETCH request", and most systems work *ahead* of
+that stream, using the client communications socket itself as the queue.
+
+That last detail is where **back-pressure** — a slow consumer forcing the
+producer to slow down instead of buffering without bound — comes from for free:
+if the enqueue target is the socket, a client that stops reading eventually
+fills the socket buffer and stalls the worker. A naive implementation that
+buffers the whole result in the server's heap instead turns one big query into
+an out-of-memory crash.
+
+Without this box there is no way in.
### Step 2 — the process manager: who actually runs the query
-The process manager decides which OS process or thread executes your query —
-it is the mapping between N client connections and M workers. The paper's
-taxonomy, still exhaustive today:
-
-- **process-per-worker** — fork one OS process per connection (classic
- PostgreSQL): crash-isolated, but heavy.
-- **thread-per-worker** — one thread per connection (MySQL): lighter, shares
- one address space.
-- **event/async** — a small pool of threads multiplexes thousands of
- connections (Redis, most Rust servers): cheapest per connection, hardest
- to program.
+> **In:** the framed SQL command and connection state from Step 1.
+> **Out:** a DBMS worker — an OS process, an OS thread, or a slot in a pool —
+> bound to that connection and *admitted* to run, which is the execution
+> context Step 3's plan runs inside.
+
+The process manager decides which unit of OS execution runs your query. The
+paper's §1.1 item 2 puts the decision first: "the DBMS must assign a 'thread of
+computation' to the command", and "the most important decision that the DBMS
+needs to make at this stage in the query regards admission control".
+
+Three definitions, all §2's, because the taxonomy is meaningless without them.
+An **OS process** has a private address space and its own OS resource handles
+and security context. An **OS thread** ("k-thread") has neither: it shares the
+address space of every other thread in its process, and is scheduled by the
+kernel. A **lightweight thread** is scheduled in *user space* by the
+application, so switching one costs no kernel mode switch — at the price that
+"any blocking operation such as a synchronous I/O by any thread will block all
+threads in the process", which is why LWT packages must issue only
+asynchronous I/O. A DBMS that ships its own LWT package calls them **DBMS
+threads** (§2.2).
+
+§2.1's taxonomy has exactly three entries, "from the simplest to the most
+complex":
+
+- **process per DBMS worker** — one OS process per connection. Crash-isolated
+ and debugger-friendly, but the shared structures (buffer pool, lock table)
+ have to be moved into OS shared memory, "which reduces some of the advantages
+ of address space separation" (§2.1.1). PostgreSQL "runs the process per DBMS
+ worker model exclusively on all supported operating systems" (§2.3).
+- **thread per DBMS worker** — one multi-threaded process hosts every worker; a
+ dispatcher thread accepts connections and hands each one a thread (§2.1.2).
+ MySQL uses this, and DB2 defaults to it where OS threads are good (§2.3).
+- **process pool** — "a central process holds all DBMS client connections and,
+ as each SQL request comes in from a client, the request is given to one of
+ the processes in the process pool" (§2.1.3). Bounded, often fixed size; a
+ request arriving when every process is busy waits.
+
+The third entry is the one an earlier version of this chapter got wrong: it
+listed "event/async" as the paper's third model. It is not. The paper's third
+model is the **process pool**, and its modern descendant is named in §2.3 as
+the pool's thread-based variant: "DBMS workers multiplexed over a thread
+pool — Microsoft SQL Server defaults to this model and over 99% of the SQL
+Server installations run this way." An event loop over a small thread pool
+(Redis, most Rust servers) is that row of the paper's table, not a fourth
+family. The paper's own list of exotica is instead about *where the scheduler
+lives*: DBMS threads on OS processes (Sybase, Informix) or DBMS threads on OS
+threads (SQL Server's "Fibers", §2.3).
+
+Why does the pool exist at all? §2.1.3 says only that "the memory overhead of
+each connection requiring a full process is a clear disadvantage" and §2.1.1
+that "a process has more state than a thread and consequently consumes more
+memory" — the paper prints no per-process figure, and neither will this
+chapter. But the shape of the argument is arithmetic, so state an assumption
+and run it. Assume a per-worker private footprint of 2 MB for a process and
+64 KB for a pooled worker's stack, and take §2.3's "tens of thousands of
+concurrently connected users" at its low end, 10,000:
-The numbers that decide it: 10,000 connections × ~10 MB of per-process
-overhead ≈ 100 GB just to hold idle sessions, versus an async pool of 8
-threads. This box also owns **admission control** — deciding that a query
-must *wait in a queue* rather than start, so an overloaded server degrades
-into higher latency instead of thrashing (all queries slow, none finishing).
+```
+process per worker: 10,000 × 2 MB = 20,000 MB = 19.5 GiB of private state
+thread/process pool: 200 × 2 MB = 400 MB (200 pooled workers)
+ + 10,000 × 64 KB = 625 MB (idle connection state)
+ ───────────
+ ~1,025 MB = 1.0 GiB
+ratio 20,000 / 1,025 = 19.5×
+```
-Without this box, the server accepts every request at once and collapses
-under its own concurrency. The choice here directly shapes the capstone
-server (M7/M9).
+The 2 MB is an assumption, not a measurement; the point that survives any
+plausible substitution is that the pool decouples *connections* from
+*workers*, so the memory bill grows with the smaller of the two.
+
+This box also owns **admission control** — refusing to start new work "unless
+sufficient DBMS resources are available" (§2.4). Without it a system
+**thrashes**: past its peak, throughput "will begin to decrease radically",
+usually because the buffer pool cannot hold the working set and the system
+"spends all its time replacing pages", sometimes because transactions
+"continually deadlock with each other and need to be rolled back and
+restarted". With it, §2.4 promises graceful degradation: "transaction latencies
+will increase proportionally to the arrival rate, but throughput will remain at
+peak." Note the shape of that promise — latency degrades, throughput does not.
+Topic 35 measures what happens when it is missing: at 280 QPS against a 300 QPS
+capacity, goodput **never recovers** after a 10 s outage ([FINDINGS.md](../../FINDINGS.md)
+row 35).
+
+§2.4 also says admission control is two-tier: a connection-count check in the
+dispatcher, and a second controller *inside the query processor* that runs
+"after the query is parsed and optimized" and uses the optimizer's estimate of
+the query's memory footprint. That second tier is a dependency from Step 2 back
+onto Step 3, and it is the reason the boxes are a graph and not a pipeline.
+
+The choice here directly shapes the capstone server (M7/M9).
### Step 3 — the relational query processor: the database's compiler
-The relational query processor turns declarative SQL — you say *what* rows
-you want, never *how* to fetch them — into an executable plan. It is a
-four-stage compiler pipeline:
+> **In:** the SQL text from Step 1, running inside the worker Step 2 admitted.
+> **Out:** a query plan — a dataflow graph of operators — executed so that its
+> *leaves* issue the record-fetch calls Step 4 answers.
+
+The relational query processor turns declarative SQL — you say *what* rows you
+want, never *how* to fetch them — into an executable plan. §4 splits it into
+four stages, one per subsection, and this is the mapping the routing table at
+the bottom uses:
+
+1. **parser** (§4.1) — query text → internal format. Its four tasks, quoted:
+ "(1) check that the query is correctly specified, (2) resolve names and
+ references, (3) convert the query into the internal format used by the
+ optimizer, and (4) verify that the user is authorized to execute the query."
+ Name resolution means **canonicalization** — expanding `users` into the
+ four-part name `server.database.schema.table`, which requires the catalog of
+ Step 5.
+2. **rewriter** (§4.2) — expand views, fold constants, simplify.
+3. **optimizer** (§4.3) — choose which indexes to use and in what order to
+ join tables, using statistics about the data.
+4. **executor** (§4.4) — run the chosen plan. The paper is specific about how:
+ "most modern query executors employ the **iterator model** that was used in
+ the earliest relational systems", where every operator is a subclass of one
+ four-method interface (§4.4, Fig. 4.2):
-1. **parser** — query text → syntax tree;
-2. **rewriter** — expand views, fold constants, simplify;
-3. **optimizer** — choose which indexes to use and what order to join
- tables in, using statistics about the data;
-4. **executor** — run the chosen plan, typically as a tree of iterators
- each pulling rows from its children.
+```
+// ILLUSTRATION — this is the paper's own Fig. 4.2 pseudocode (§4.4, p. 189),
+// not code from any engine. Real instances of this interface in this repo:
+// topic 11's Volcano executor and turso's `BTreeCursor` (see
+// reading-turso-btree.md, core/storage/btree.rs).
+class iterator {
+ iterator &inputs[];
+ void init();
+ tuple get_next();
+ void close();
+}
+```
-The stakes are not cosmetic: for `WHERE id = 42`, scanning a 1M-row table
-reads ~everything, while descending an index touches 3–4 pages — the
-optimizer's choice is routinely a **1000× latency difference** on identical
-data.
+§4.4.1 draws the consequence the guide's later topics lean on: `get_next()` is
+an ordinary procedure call, so "a tuple is returned to a parent in the graph
+exactly when control is returned. This implies that only a single DBMS thread
+is needed to execute an entire query graph, and queues or rate-matching between
+iterators are not needed." Dataflow and control flow are the same edge. Topic 11
+measures what that costs: Volcano tops out at **103 M rows/s** and gets *slower*
+as selectivity rises ([FINDINGS.md](../../FINDINGS.md) row 11).
-Without this box you'd hand-write the access path for every query — which is
-exactly what programming directly against a raw storage engine API is. This
-is topics 10–11.
+The stakes of stage 3 are not cosmetic, and the arithmetic is worth doing
+rather than asserting. Assume a 1,000,000-row table, 100-byte rows, 8 KB pages,
+and a B+-tree index on `id` — the same 100-byte record size this topic's own
+bench lane uses:
-### Step 4 — the transactional storage manager: the box that owns the bytes
+```
+rows per page 8192 B / 100 B = 81 rows
+pages in a full scan 1,000,000 / 81 = 12,346 pages read
+index descent height 3 + 1 leaf = 4 pages read
+ratio 12,346 / 4 = 3,087× fewer pages
+```
-The transactional storage manager stores the data on disk, caches it in
-memory, and guarantees that neither concurrent transactions nor a crash can
-corrupt it. It is itself four cooperating sub-managers:
+So "the optimizer's choice is worth about 3,000× on this table" — a figure that
+falls out of page size, row size and tree height, not out of folklore. It grows
+linearly with the table and only logarithmically with the descent, which is why
+the gap widens as data grows. Topic 3 measures the caveat: pages touched is not
+the same as time, because lookups climb **862 → 1101 ns** from 1e6 to 4e6 keys
+while height stays at 3 ([FINDINGS.md](../../FINDINGS.md) row 3).
-- **access methods** — the on-disk data structures (B-trees, heaps) that
- actually locate rows;
-- **buffer pool** — the database's own cache of fixed-size disk pages in
- RAM, with its own eviction policy (topic 6);
-- **lock manager** — coordinates concurrent transactions (topics 8–9);
-- **log manager** — the write-ahead log that makes committed changes
- survive a crash (topic 5).
+Without this box you would hand-write the access path for every query — which
+is exactly what programming directly against a raw storage engine API is. This
+is topics 10–11.
-This one box is the subject of topics 1–6 and 8–9 — and it is *all* that
-fjall and redb are. "Storage engine" names this box, not the database.
+### Step 4 — the transactional storage manager: the box that owns the bytes
-The paper's §6 adds the fight with the operating system: if the OS also
-caches file pages, every hot page sits in RAM **twice** (buffer pool + OS
-page cache — "double buffering", half your memory wasted), and the OS may
-flush pages to disk in an order that violates the log manager's
-write-ahead rule. Hence `O_DIRECT` and databases doing their own IO.
+> **In:** the record-fetch and record-modify calls issued by the leaves of
+> Step 3's plan.
+> **Out:** tuples, read from pages under locks, with log records written for
+> anything modified — returned up the iterator stack to Step 3 and out through
+> Step 1.
+
+The transactional storage manager stores the data, caches it in memory, and
+guarantees that neither concurrent transactions nor a crash can corrupt it.
+§1.1, item 4 lists its parts, and the paper splits them across two whole
+sections — **§5 Storage Management** and **§6 Transactions**:
+
+- **access methods** (§4.5) — the on-disk structures that actually locate
+ rows: "basic structures like tables and indexes" (§1.1). B-trees are topics
+ 1 and 3.
+- **buffer pool** (§5.3) — "a large shared buffer pool in its own memory
+ space", "organized as an array of frames, where each frame is a region of
+ memory the size of a database disk block". Two pieces of per-frame metadata
+ are worth memorizing now because topic 6 implements both: a **dirty bit**,
+ set when the page changed since it was read, and a **pin count**, non-zero
+ meaning "not eligible for participation in the page-replacement algorithm".
+- **lock manager** (§6.3) — coordinates concurrent transactions (topics 8–9).
+- **log manager** (§6.4) — the write-ahead log. §2.1.4 names its in-memory
+ half, the **log tail**: an in-memory queue of log entries "periodically
+ flushed to the log disk(s) in FIFO order", where "a transaction cannot be
+ reported as successfully committed until a commit log record is flushed to
+ the log device", and where **group commit** batches several transactions'
+ commit records into one I/O. Topic 5 measures the price of that flush:
+ `write()` **857k/s**, `fsync` **44k/s**, `F_FULLFSYNC` **337/s**
+ ([FINDINGS.md](../../FINDINGS.md) row 5).
+
+This one box is the subject of topics 1–6 and 8–9 — and it is *all* that fjall
+and redb are. "Storage engine" names this box, not the database. It is also
+the only box this topic's bench lane measures: the same 108 MB of records
+costs fjall **48 MB** on disk and redb **6.8 GB**, space amplification 0.45×
+against 63.28× ([FINDINGS.md](../../FINDINGS.md) row 1). Nothing in Steps 1, 2,
+3 or 5 moved; the 140× is entirely inside this box.
+
+§5 is the section that justifies this topic's existence, and it opens with the
+two dimensions of control a storage manager is fighting for:
+
+- **Spatial control** (§5.1) — *where* on the disk a block goes. The reason it
+ matters is one of the paper's few hard ratios: "sequential bandwidth to and
+ from disk is between 10 and 100 times faster than random access, and this
+ ratio is increasing", because density doubles every 18 months and bandwidth
+ rises as its square root while "disk arm movement... [improves] at about
+ 7%/year". The maximal answer is **raw-mode access** — bypass the filesystem
+ and address the block device directly — but §5.1 then measures the
+ alternative honestly and finds it nearly free: comparing raw access with one
+ very large file on a mid-sized system, "only a 6% degradation when running
+ the TPC-C benchmark", and "DB2 reports file system overhead as low as 1% when
+ using Direct I/O (DIO)". The paper's own conclusion is that vendors "typically
+ no longer recommend raw storage".
+- **Temporal control** (§5.2) — *when* a write actually reaches the disk.
+
+§5.2 is the section to read twice, because it names **three** distinct problems
+with letting the OS buffer your writes, not two:
+
+1. **Correctness.** "The DBMS cannot guarantee atomic recovery after software
+ or hardware failure without explicitly controlling the timing and ordering
+ of disk writes" — the write-ahead logging protocol requires log writes to
+ precede the corresponding data writes, and OS buffering "can confound the
+ intention of the DBMS logic by silently postponing or reordering writes".
+2. **The prefetch mismatch.** OS read-ahead "depends on the contiguity of
+ physical byte offsets in files", while the DBMS knows the *logical* future:
+ the paper's example is scanning B+-tree leaves that are not physically
+ contiguous, which the query plan can predict and the filesystem cannot.
+3. **Double buffering and copy cost.** **Double buffering** is the same page
+ living in the OS page cache and the DBMS buffer pool at once. §5.2 charges
+ it twice: "it wastes system memory by effectively reducing the memory
+ available for doing useful work", and "it wastes time and processing
+ resources, by causing an additional copying step: on reads, data is first
+ copied from the disk to the OS buffer, and then copied again to the DBMS
+ buffer pool. On writes, both of these copies are required in reverse."
+
+The escape hatches §5.2 names are `mmap`/`msync` and the platform DIO/CIO
+interfaces — which is what `O_DIRECT` is on Linux. Topic 6 measures why the
+`mmap` half of that answer is a trap: mmap page reads are p50 **42 ns** and max
+**182 µs**, a 4300× spread that is entirely minor page faults the database
+cannot see or schedule ([FINDINGS.md](../../FINDINGS.md) row 6). §5.2's third
+problem is solved; its first is not, because a page fault is still the OS
+choosing when to do I/O.
### Step 5 — shared components: the utilities everyone calls
-The shared components are the services every other box depends on: the
-**catalog** (the database's metadata — tables, columns, indexes, and
-statistics, itself stored as ordinary tables), the memory allocator,
-replication (topic 22), and admin/monitoring tools.
-
-The catalog is the load-bearing one: Step 3's 1000× optimizer win is only
-possible because the catalog stores row counts and value histograms for the
-optimizer to cost plans with. Without it, nothing in the system even knows
-what columns a table has.
+> **In:** metadata and memory requests arriving from Steps 1, 3 and 4 — the
+> parser asking whether a table exists, the optimizer asking how many rows it
+> has, every operator asking for scratch memory.
+> **Out:** catalog rows, memory contexts, replicated log records and admin
+> surfaces — the services with no place in the query's linear path, which is
+> why they are drawn beside it rather than in it.
+
+§7's shared components are the services every other box depends on. Three of
+them matter now.
+
+The **catalog** (§7.1) is the database's metadata — "the names of basic
+entities in the system (users, schemas, tables, columns, indexes, etc.) and
+their relationships" — and the load-bearing design decision is that it "is
+itself stored as a set of tables in the database". The paper's argument for
+that is code reuse: "users can employ the same language and tools to
+investigate the metadata that they use for other data, and the internal system
+code for managing the metadata is largely the same as the code for managing
+other tables", and it adds a warning from experience — "this code and language
+reuse is an important lesson that is often overlooked in early stage
+implementations, typically to the significant regret of developers later on."
+
+The catalog is not small. §7.1's example: "one major Enterprise Resource
+Planning application... has over 60,000 tables, with between 4 and 8 columns
+per table, and typically two or three indexes per table." Work that out at the
+paper's midpoints — 6 columns and 2.5 indexes per table — and the catalog
+alone is 60,000 table rows, 360,000 column rows and 150,000 index rows before
+a single user row exists. Which is why §7.1 also says high-traffic parts are
+"materialized in main memory... in data structures that 'denormalize' the flat
+relational structure of the catalogs into a main-memory network of objects".
+
+The **memory allocator** (§7.2) is the second, and the paper's point is that
+the textbook focus on the buffer pool is misleading: "database systems allocate
+significant amounts of memory for other tasks as well" — Selinger-style
+optimization builds dynamic-programming state, hash joins and sorts allocate at
+runtime. The idiom is a **memory context**: a named region list you allocate
+from and free *all at once*, which turns "did every operator free its
+temporaries?" into one call.
+
+Third, **replication services** (§7.4) — topic 15, where follower fsync policy
+alone spans **59×** ([FINDINGS.md](../../FINDINGS.md) row 15) — and
+**administration, monitoring and utilities** (§7.5).
+
+The catalog is the one that closes the loop with Step 3: the 3,087× optimizer
+win computed above is only possible because something stored the row count and
+the fact that an index on `id` exists. Without the catalog, nothing in the
+system even knows what columns a table has.
### Step 6 — the assembled map
+> **In:** all five boxes, from Steps 1–5.
+> **Out:** one diagram, and a reading order for the next thirty topics.
+
Put the five boxes together and you get the org chart the rest of the
-curriculum fills in, box by box — the paper's §1 figure, annotated with
-where each box gets built:
+curriculum fills in, box by box — the paper's Figure 1.1, annotated with where
+each box gets built:
```mermaid
flowchart TB
- CM["Client communications manager
(topic 7: protocol, RESP)"] --> PC["Process manager
(topic 7/9: threads, admission)"]
- PC --> RP["Relational query processor
parse → rewrite → optimize → execute
(topics 10-11)"]
- RP --> TS["Transactional storage manager
access methods + buffer + locks + log
(topics 1-6, 8-9)"]
- TS --> SC["Shared components
catalog, memory allocator, replication
(topics 15, 22)"]
+ CM["Client communications manager
§1.1 item 1
(topic 7: protocol, RESP)"] --> PC["Process manager
§2
(topic 7/9: workers, admission)"]
+ PC --> RP["Relational query processor
§4: parse → rewrite → optimize → execute
(topics 10-11)"]
+ RP --> TS["Transactional storage manager
§5 storage + §6 transactions
access methods + buffer + locks + log
(topics 1-6, 8-9)"]
+ TS --> SC["Shared components
§7: catalog, allocator, replication
(topics 15, 22)"]
```
Memorize this diagram; it is the table of contents for topics 3–16. The
-punchline for this topic: everything the engine-shootout benchmarks measure
-lives inside one box (Step 4) — the capstone builds the other four around
-it, milestone by milestone.
+punchline for this topic: everything the engine-shootout benchmark measures
+lives inside one box (Step 4) — the 140× space-amplification spread of
+[FINDINGS.md](../../FINDINGS.md) row 1 is a fact about access methods and
+buffering alone, with no query processor, no optimizer and no client protocol
+anywhere near it. The capstone builds the other four around it, milestone by
+milestone.
## How to read the paper (with the concepts in hand)
+**The section numbers below are the corrected ones.** §5 is Storage
+Management; §6 is Transactions; §3 is Parallel Architecture, which the old
+version of this table did not mention at all.
+
Read NOW (topic 1):
-- **§1 (main components)** — the five-box diagram, i.e. Steps 1–6 in the
- authors' own words. Skim fast; you already have the picture — your job is
- to attach their vocabulary to it.
-- **§2 (process models)** — Step 2 in depth: process- vs thread- vs
- event-per-worker, and where admission control lives. Directly informs the
- capstone server (M7/M9).
-- **§6 (storage management)** — Step 4's fight with the OS: spatial control
- (why DBs fight the filesystem), buffer pools vs the OS page cache, the
- double-buffering problem. This is the section that justifies this topic's
- existence.
+- **§1 (introduction, esp. §1.1 the life of a query)** — the five-box
+ Figure 1.1, i.e. Steps 1–6 in the authors' own words, told through the gate
+ agent's passenger-list query. Skim fast; you already have the picture — your
+ job is to attach their vocabulary to it.
+- **§2 (process models)** — Step 2 in depth: the definitions block first
+ (process / OS thread / lightweight thread / DBMS thread / client / worker),
+ then §2.1's three models, §2.3's who-does-what table, and §2.4 admission
+ control.
+- **§5 (storage management)** — Step 4's fight with the OS: §5.1 spatial
+ control and the 10–100× sequential/random ratio, §5.2 temporal control and
+ the three problems with OS buffering, §5.3 the buffer pool's frames, dirty
+ bits and pin counts. This is the section that justifies this topic's
+ existence, and it is **not** §6.
Skim NOW, return LATER:
| Section | Concept | Return at |
|---------|---------|-----------|
-| §3 parser/rewriter | Step 3, stages 1–2 | topic 10 |
-| §4 query processor internals | Step 3, stages 3–4 | topics 10–11 |
-| §5 transactions, ACID, locking | Step 4's lock + log managers | topics 8–9 |
-| §7 shared components (catalog, replication) | Step 5 | topics 15–16 |
+| §3 parallel architecture (shared-memory / shared-nothing / shared-disk / NUMA) | not on the single-node query path at all | topics 36–37 |
+| §4.1–4.2 parser, authorization, rewrite | Step 3, stages 1–2 | topic 10 |
+| §4.3–4.4 optimizer, executor and the iterator model | Step 3, stages 3–4 | topics 10–11 |
+| §4.5 access methods | Step 4's B-trees, from the query processor's side | topics 3, 11 |
+| §6 transactions: ACID, serializability, locking, the log manager | Step 4's lock + log managers | topics 5, 8–9 |
+| §7 shared components (catalog, allocator, replication) | Step 5 | topics 15–16 |
## Questions to answer in notes.md
-1. §6 argues the DBMS should bypass OS caching (O_DIRECT). What are the *two*
- distinct problems with letting the OS cache pages? (Double buffering; the OS
- evicts/flushes with zero knowledge of WAL ordering.)
-2. Which of the five §1 boxes does fjall implement? redb? (Neither has a query
- processor or client manager — "storage engine" ≠ "database". The capstone builds
- the other boxes on top, milestone by milestone.)
-3. 2007 blind spots: name three things the paper couldn't see coming. (Candidates:
- NVMe erasing the seek-time mental model, cloud disaggregation — topic 28, columnar
- dominance for analytics — topic 12, LSM taking over write paths.)
+1. §5.2 argues the DBMS should bypass OS caching. It gives **three** distinct
+ groups of reasons, not two — name all three, and say which one `O_DIRECT`
+ fixes and which one `mmap` leaves in place. (Connect the second answer to
+ topic 6's measured 42 ns / 182 µs mmap spread.)
+2. Which of the five §1.1 boxes does fjall implement? redb? Which do they
+ deliberately not implement, and what does the capstone have to add to turn
+ one into a database?
+3. §5.1 measures raw-device access against one large file at "only a 6%
+ degradation" on TPC-C, and DB2's DIO overhead "as low as 1%". Given that,
+ why does §5.1 still spend two pages on spatial control? (Hint: what is the
+ 6% a measurement *of*, and what does §5.1 say has changed about "raw"
+ devices since?)
+4. §2.4 promises that admission control makes latency degrade proportionally
+ while *throughput stays at peak*. Topic 35's lane shows goodput at zero for
+ 121 s after a 10 s outage. Which half of §2.4's promise broke, and what
+ would have to exist in the loop for it to hold?
+5. 2007 blind spots: name three things the paper could not see coming, and for
+ each say which section would have to be rewritten. (Candidates: NVMe
+ erasing §5.1's seek-time mental model; cloud disaggregation — topic 28
+ measures S3 p50 at 14.17 ms against local NVMe at 0.10 ms; columnar
+ dominance for analytics — topic 12; LSM taking over write paths — the rest
+ of this topic.)
## The one-line takeaway
-A database is five cooperating managers, and a storage engine is just one of them —
-this paper is the org chart for everything the capstone will build.
+A database is five cooperating managers, and a storage engine is just one of
+them — this paper is the org chart for everything the capstone will build.
## Done when
+Answer each before unfolding it.
+
- [ ] You can draw the five boxes from memory and say which one owns the bytes on disk.
+
+ Answer
+
+ Client communications manager, process manager, relational query processor,
+ transactional storage manager, and the shared components drawn beside all
+ four (§1.1, Figure 1.1). The transactional storage manager owns the bytes:
+ §1.1 item 4 says it "manages all data access (read) and manipulation (create,
+ update, delete) calls", and it is the box holding the access methods, the
+ buffer pool, the lock manager and the log manager.
+
+ The check that you have the boundary right: fjall and redb are *only* that
+ box. This topic's bench lane changes nothing else in the diagram — same
+ records, same durability, same client — and still gets 48 MB against 6.8 GB
+ on disk, 0.45× against 63.28× space amplification
+ ([FINDINGS.md](../../FINDINGS.md) row 1). A 140× spread with four of the five
+ boxes absent is the sharpest possible demonstration of where the bytes live.
+
+
+
- [ ] You can trace one query through all five, naming the four stages inside the query processor.
-- [ ] You can state both arguments §6 gives for bypassing OS caching — and connect them to the mmap tail you will measure in topic 6.
+
+ Answer
+
+ Bytes arrive on a socket; the communications manager frames them into a SQL
+ command and remembers the connection state (§1.1 item 1). The process manager
+ assigns a "thread of computation" and decides admission (§1.1 item 2, §2.4).
+ The query processor runs four stages, one per §4 subsection: parse and
+ authorize (§4.1), rewrite (§4.2), optimize (§4.3), execute (§4.4). The
+ executor's leaf operators call into the transactional storage manager, which
+ takes locks, reads pages through the buffer pool and writes log records (§1.1
+ item 4). Then §1.1 item 5's "unwinding the stack": tuples flow back up the
+ iterator graph into the client communications buffer and out.
+
+ The catalog is touched at three of those stages without appearing in the
+ path: §4.1 calls it to canonicalize `users` into `server.database.schema.table`
+ and to type-check expressions, and §4.3 needs its statistics to cost a plan
+ at all. That is why §7's components are drawn to the side — every box calls
+ them, no box passes through them.
+
+
+
+- [ ] You can state all three arguments §5.2 gives against letting the OS buffer writes — and say which section number that discussion is actually in.
+
+ Answer
+
+ It is **§5.2, Temporal Control: Buffering** — not §6, which is transactions.
+ Getting this wrong costs you an hour in the wrong chapter, which is why the
+ routing table above was corrected.
+
+ The three: (1) *correctness* — WAL requires log writes to precede data
+ writes and commits to return only after the commit record is on the log
+ device, and OS buffering "can confound the intention of the DBMS logic by
+ silently postponing or reordering writes"; (2) *the prefetch mismatch* — OS
+ read-ahead reasons about physical contiguity, while the query plan knows the
+ logical future, the paper's example being a scan of non-contiguous B+-tree
+ leaves; (3) *double buffering and copy cost* — the same page in the OS cache
+ and the buffer pool wastes memory outright and adds a copy in each direction,
+ and §5.2 insists copies matter because "throughput in a well-tuned
+ transaction processing DBMS is typically not I/O-bound".
+
+ `O_DIRECT`/DIO fixes (3) and most of (1). `mmap` fixes (3) only: topic 6
+ measures mmap page reads at p50 **42 ns** and max **182 µs**
+ ([FINDINGS.md](../../FINDINGS.md) row 6), and every microsecond of that tail
+ is the kernel deciding when to do I/O — exactly the control §5.2's first
+ argument says the DBMS must keep.
+
+
+
- [ ] You can say which of the five boxes fjall and redb implement, and which they deliberately do not.
-- [ ] You wrote answers to both questions in notes.md.
+
+ Answer
+
+ Both implement Step 4 and nothing else. fjall has access methods (memtable,
+ SSTs), a buffer/cache layer and a journal — the log manager of §6.4 in the
+ form of a WAL. redb has access methods (a copy-on-write B-tree), a page
+ cache and its own durability mechanism. Neither has a client communications
+ manager (you call them in-process, so there is no wire protocol and no
+ framing), a process manager (your threads are the workers; there is no
+ admission control, which is why an unbounded write loop can thrash them), a
+ relational query processor (no SQL, no plan, no optimizer — you *are* the
+ access path, which is Step 3's "without this box" clause made literal), and
+ no catalog beyond a keyspace/table-name registry.
+
+ That is the whole shape of the capstone: M1 defines the storage trait over
+ this box, and the later milestones add the other four — M7 the protocol
+ (Step 1), M7/M9 the worker model and admission (Step 2), M10–M11 planning
+ and execution (Step 3).
+
+
+
+- [ ] You wrote answers to all five questions in notes.md.
+
+ Answer
+
+ Nothing to unfold — the questions are the exercise, and they go under
+ `## Papers → Architecture of a DBMS (2007)` in this topic's `notes.md`.
+
+ The bar for question 1: three groups, named in §5.2's own order, with the
+ `O_DIRECT`-versus-`mmap` split stated as a claim about *which* problem each
+ solves rather than a preference. The bar for question 5: a blind spot is only
+ a blind spot if you can name the section it invalidates. "The paper predates
+ NVMe" is not an answer; "§5.1's whole spatial-control argument rests on arm
+ movement improving at 7%/year, and an SSD has no arm" is.
+
+
## References
**Papers**
-- Hellerstein, Stonebraker, Hamilton — "Architecture of a Database
- System" (Foundations and Trends in Databases, 2007) —
- [PDF](https://dsf.berkeley.edu/papers/fntdb07-architecture.pdf) — read
- §1–2 + §6 now (2 h); §3–§5 and §7 are reference material to return to
- per the table above
+- Hellerstein, Stonebraker, Hamilton — "Architecture of a Database System"
+ (*Foundations and Trends® in Databases*, Vol. 1, No. 2, 2007, pp. 141–259) —
+ [PDF](https://dsf.berkeley.edu/papers/fntdb07-architecture.pdf) — read §1,
+ §2 and §5 now (2 h); §3, §4, §6 and §7 are reference material to return to
+ per the routing table above.
+
+| Section | What this chapter took from it |
+|---|---|
+| §1 | "multi-million line code bases, most of which are well over a decade old" |
+| §1.1 | Figure 1.1's five components, and the gate agent's query walked through all of them; the communications manager's three jobs; incremental fetch for large result sets |
+| §2 (definitions) | OS process / OS thread / lightweight thread / DBMS thread / DBMS client / DBMS worker, and the 1:1 client-to-worker mapping |
+| §2.1.1–2.1.3 | the three process models: process per worker, thread per worker, process pool |
+| §2.1.4 | shared buffer pool and lock table across process boundaries; the log tail and group commit; SQL's pull model and the socket as result queue |
+| §2.3 | PostgreSQL is process-per-worker exclusively; MySQL and DB2 thread-per-worker; SQL Server defaults to a thread pool, "over 99% of installations"; Sybase/Informix DBMS threads on processes; SQL Server Fibers |
+| §2.4 | admission control, thrashing, two-tier structure, and the "latency degrades, throughput stays at peak" promise |
+| §4.1–4.4 | the parser's four tasks and four-part name canonicalization; the optimizer; the iterator model and Fig. 4.2's four-method interface; §4.4.1 on dataflow coupled to control flow |
+| §5.1 | sequential 10–100× random and why the ratio grows (density ×2/18 months, bandwidth ~√density, arm movement 7%/year); raw device vs one large file at 6% on TPC-C; DB2 DIO overhead as low as 1% |
+| §5.2 | the three problems with OS buffering: WAL ordering, prefetch mismatch, double buffering plus copy cost; mmap/msync and DIO/CIO as the escape hatches |
+| §5.3 | the buffer pool as an array of frames, with a dirty bit and a pin count per frame |
+| §6.3–6.4 | lock manager and log manager, as the transactional half of Step 4 |
+| §7.1 | the catalog stored as ordinary tables, and the 60,000-table ERP example |
+| §7.2 | memory contexts, and why the buffer pool is not the whole memory story |
+
+**This repo's measurements cited above**
+- [FINDINGS.md](../../FINDINGS.md) row 1 (this topic's own 0.45× vs 63.28×
+ space amplification), row 3 (B-tree height vs cache residency), row 5 (fsync
+ ladder), row 6 (mmap tail), row 11 (Volcano throughput), row 15 (follower
+ fsync policy), row 28 (S3 vs local NVMe), row 35 (goodput after an outage).
diff --git a/topics/01-storage-engine-landscape/reading-comer-btree.md b/topics/01-storage-engine-landscape/reading-comer-btree.md
index 26cd742..462f113 100644
--- a/topics/01-storage-engine-landscape/reading-comer-btree.md
+++ b/topics/01-storage-engine-landscape/reading-comer-btree.md
@@ -9,129 +9,394 @@ invariants that fix it — one step at a time. Everything in turso's
`btree.rs` is a footnote to this paper, and §3's B+ variant is the shape
every real engine actually shipped.
+Every section reference below was checked against the PDF of *ACM Computing
+Surveys* Vol. 11, No. 2 (June 1979), pp. 121–137. **The previous version of
+this chapter had the section map wrong** in two places: insertion and deletion
+are taught in §1 (under the subheads "Balancing", "Insertion", "Deletion"),
+not §2 — §2 is the *cost* analysis — and VSAM is §5, not §4, because §4 is the
+multiuser chapter. The corrected reading order is below. Code anchors are
+turso at `dd775bc`, the commit this repo's pin table records.
+
## The problem in one sentence
-Find one record among a million on a 1979 disk, where every disk access
-costs ~30 ms: a balanced binary search tree needs ~20 accesses (**600 ms per
-lookup**); a B-tree needs 3 (~90 ms then, 3 cached-or-not page reads now) —
-and the structure that closes that gap still sits under nearly every
-database shipped since.
+Find one record among a million on a disk where, in Comer's own framing, "the
+time required to access secondary storage is the main component of the total
+time required to process the data" (Introduction, *Operations on a File*): a
+balanced binary search tree needs ~20 accesses, while Comer's Table I shows a
+B-tree of order 50 needs **4 in the worst case** — "later we will see that this
+estimate is too high; simple implementation techniques lower the worst case
+cost to 3, and the average cost to less" (§2) — and the structure that closes
+that gap still sits under nearly every database shipped since.
## The concepts, step by step
### Step 1 — the disk access model: cost = blocks touched
-A disk does not hand you bytes; it hands you fixed-size **blocks** (a few
-KB), and in 1979 each block fetch costs a mechanical seek plus rotation —
-tens of milliseconds — while comparing keys already in memory costs
-microseconds. So the only number that matters for a disk-resident structure
-is **how many distinct blocks it touches**, not how many comparisons it
-does. The RAM model (count comparisons) prices algorithms in the wrong
-currency here; the disk model (count block reads) is off by a factor of
-~10,000 per operation.
-
-This is the same observation the turso chapter's Step 1 makes for pages —
-"one disk IO" always means "one block/page" — and the same block-transfer
-logic as CPU cache lines in topic 0, three orders of magnitude up the
-hierarchy.
+> **In:** nothing yet — this step fixes the cost currency every later step
+> prices things in.
+> **Out:** one number, "distinct blocks touched", which Steps 2, 3 and 5 all
+> minimize and Step 6 finally drives to ~1.
+
+A disk does not hand you bytes; it hands you fixed-size **blocks** — a
+contiguous run of bytes that the device transfers as one unit, a few KB on
+modern hardware. Comer's Introduction states the model and the reason for it in
+two sentences: "with current hardware technology, the time required to access
+secondary storage is the main component of the total time required to process
+the data. Furthermore, most random access devices transfer a fixed amount of
+data per read operation, so that the total time required is linearly related to
+the number of reads. Therefore, the number of secondary storage accesses serves
+as a reasonable cost measure for evaluating index methods."
+
+Read that carefully, because it is doing two things. It declares the **cost
+model** — the thing you count when you compare two algorithms — to be block
+accesses rather than comparisons, and it justifies the swap by an *empirical*
+claim about the hardware, not a mathematical one. Comer never prints a
+millisecond figure anywhere in the paper; a previous version of this chapter
+attributed "~30 ms per access, so 600 ms per lookup" to him, and that number is
+not his. The honest form of the claim is his: accesses dominate, so count
+accesses.
+
+Comer does list what the model deliberately ignores, and it is worth having the
+list: "other less important costs include the time to process data once it has
+been placed in main memory, the secondary storage space utilization, and the
+ratio of the space required by the index to the space required by the
+associated information." Two of those three come back to bite — space
+utilization is Step 4, and the index-to-data ratio is why Step 6's B+ shape
+won.
+
+The **RAM model** — count comparisons, assume every memory access costs the
+same — prices algorithms in the wrong currency here. This is the same
+observation the turso chapter's Step 1 makes for pages ("one disk IO" always
+means "one page"), and the same block-transfer logic as CPU cache lines in
+topic 0, three orders of magnitude up the hierarchy.
### Step 2 — why binary trees fail on disk
-A binary search tree stores one key and two child pointers per node, so
-finding one key among n takes ~log₂(n) pointer hops — and on disk, every
-hop lands on a different block:
+> **In:** the block-counting cost model from Step 1.
+> **Out:** two independent failures of the binary tree under that model —
+> height and transfer waste — which Step 3 has to fix simultaneously.
+
+A **binary search tree** stores one key and two child pointers per node, and
+the branch taken at a node depends on comparing the query key against the
+node's key — Comer's Figure 2 shows exactly this, with the path for the query
+"15" darkened. Finding one key among n takes about log₂(n) pointer hops, and
+under Step 1's model every hop lands on a different block:
```
-binary tree, 1M keys, nodes scattered on disk:
+binary tree, 1,000,000 keys, nodes scattered on disk:
- hop 1 → block read (~30 ms)
- hop 2 → block read (~30 ms) height = log₂(1,000,000) ≈ 20
- ... ⇒ ~20 block reads ≈ 600 ms/lookup
- hop 20 → block read (~30 ms)
+ hop 1 → block read height ≈ log2(1,000,000)
+ hop 2 → block read = ln(1e6)/ln(2)
+ ... = 13.8155 / 0.6931 = 19.93 ⇒ 20 reads
+ hop 20 → block read
- and each read fetches a ~4 KB block to use ~16 bytes of it → 99.6% wasted
+ and each read fetches a 4096 B block to use 16 bytes of it:
+ 16 / 4096 = 0.39% used
+ 4080 / 4096 = 99.6% of the transfer thrown away
```
-Two independent failures: the *height* is 20 (each level is one IO), and
-the *transfer* is wasted (one tiny node per big block). Any disk structure
-must fix both at once.
+Two independent failures, and both are Step 1's currency: the *height* is 20,
+and each of those 20 transfers is 99.6% waste. Fixing only the second (pack
+several binary nodes per block) still leaves a tree whose height is set by
+log₂. Fixing only the first is what Step 3 does — and it fixes the second for
+free, which is the elegance.
+
+Comer builds the fix the same way, as a generalization rather than a
+replacement: §1 says it presents the B-tree "as a generalization of the binary
+search tree in which more than two paths leave a given node", and Figure 3
+shows the intermediate case — two keys and three branches per node, where "the
+query, 15, is less than 42 so the leftmost would be taken at the root."
### Step 3 — the fix: one node = one block, packed with keys
-The B-tree's move is to make one tree node exactly one disk block and pack
-it with as many sorted keys as fit; the number of children a node can have
-is its **fanout**. Now each block read consumes the *entire* transfer, and
-the height shrinks from log-base-2 to log-base-fanout:
+> **In:** the two failures from Step 2.
+> **Out:** the fanout formula and a height in single digits — the budget Step 5's
+> mechanics have to preserve and Step 6 finally halves again.
+
+The B-tree's move is to make one tree node exactly one disk block and pack it
+with as many sorted keys as fit. The number of children a node can have is its
+**fanout**; Comer's parameter is the **order** *d*, defined in §1 as: "each
+node in a B-tree of order d contains at most 2d keys and 2d + 1 pointers…
+each must have at least d keys and d + 1 pointers." So order d means fanout
+between d + 1 and 2d + 1, and the two ends of that range give two different
+heights — a guaranteed one and a typical one. Both are worth computing.
+
+**The formula, with its symbols named.** Comer derives the height bound in §2
+(*Retrieval Costs*) by counting the minimum number of nodes at each depth —
+"the number of nodes at depths 0, 1, 2, … must be at least 2, 2d, 2d², 2d³ …"
+— and arrives at
+
+```
+ h ≤ log_d ( (n + 1) / 2 )
+
+ h the height: the number of nodes visited by a find, i.e. block reads
+ d the order: the MINIMUM number of keys in a non-root node
+ n the number of keys in the file
+```
+
+The `/2` and the `log_d` rather than `log_2d` are the worst case being paid
+for: the root may hold as few as one key, and every other node may be only
+half full. Run it on Comer's own example, order 50 indexing 10⁶ records:
+
+```
+h ≤ log_50(500,000.5) = ln(500,000.5) / ln(50) = 13.1224 / 3.9120 = 3.354 ⇒ h = 4
+```
+
+which is exactly the `4` in Table I's row for node size 50, column 10⁶. Table I
+in full, recomputed from the formula to check the transcription — every cell
+below matches the paper:
+
+| order d | n = 10³ | 10⁴ | 10⁵ | 10⁶ | 10⁷ |
+|---|---|---|---|---|---|
+| 10 | 3 | 4 | 5 | 6 | 7 |
+| 50 | 2 | 3 | 3 | 4 | 4 |
+| 100 | 2 | 2 | 3 | 3 | 4 |
+| 150 | 2 | 2 | 3 | 3 | 4 |
+
+**Now derive the fanout from a real page format**, which is the arithmetic this
+repo's own topic 3 records and the half Comer leaves to the implementer.
+Fanout is not chosen, it is what fits:
+
+```
+ F = floor( (P - H) / (c + s) ) maximum entries in one page
+ P page size in bytes
+ H page-header bytes
+ c bytes per cell (the key plus whatever rides with it)
+ s bytes per cell pointer in the slot array
+```
+
+Topic 3's page format (`topics/03-btree-internals/experiments/src/page.rs`,
+module docs at lines 1–20) is 4096-byte pages, an 8-byte header, a 2-byte cell
+pointer, and an interior cell of `child u32 ∥ key_len u16 ∥ key`. For an 8-byte
+key:
+
+```
+interior cell 4 + 2 + 8 = 14 B
+plus its slot 14 + 2 = 16 B per entry
+fanout (4096 - 8) / 16 = 4088 / 16 = 255.5 ⇒ F = 255
-- 4 KB block ÷ ~40 bytes per key+child-pointer ≈ **100 keys per node**;
-- height = log₁₀₀(1,000,000) = **3** — versus the binary tree's 20;
-- at 100-way fanout, 4 levels already index 100⁴ = 100 million keys.
+leaf cell 2 + 2 + 8 + 8 = 20 B (key_len, val_len, key, value)
+plus its slot 20 + 2 = 22 B per entry
+leaf capacity 4088 / 22 = 185.8 ⇒ L = 185
+```
+
+`255` and `185` are exactly the numbers in topic 3's recorded fanout table
+(`topics/03-btree-internals/notes.md`, *Fanout arithmetic*). Now the height, in
+the two-part form a real engine has — leaves first, then interior levels above
+them:
+
+```
+leaves n / L = 1,000,000 / 185 = 5,406 leaf pages
+interior log_F(...) = ln(5406)/ln(255)
+ = 8.5952 / 5.5413 = 1.551 ⇒ 2 interior levels
+height 2 + 1 = 3
+```
-Fanout is derived, not chosen: **fanout ≈ block size ÷ entry size**. Bigger
-blocks or smaller keys ⇒ flatter tree. The turso chapter's Step 2 draws
-this exact tree-of-pages picture (and its Step 3 covers how one page
-physically stores variable-length entries — the slotted layout — which
-Comer doesn't need and this chapter won't re-explain).
+and at n = 10⁹: 10⁹/185 = 5,405,406 leaves, log₂₅₅(5,405,406) = 15.5030/5.5413
+= 2.798 ⇒ 3 interior levels ⇒ **height 4**. Both match topic 3's table. Widen
+the key to 32 bytes and the same two formulas give F = 4088/40 = 102 and
+L = 4088/46 = 88, hence 11,364 leaves and log₁₀₂(11,364) = 9.3383/4.6250 =
+2.019 ⇒ 3 interior levels ⇒ **height 4 at a million rows**, one more than the
+8-byte key needs. That is the whole cost of a wide key, and it is why suffix
+truncation exists.
+
+Bigger blocks or smaller keys ⇒ flatter tree. Comer's §2 warns that you cannot
+simply keep growing the node: "most hardware systems bound the amount of data
+that can be transferred with one access", the constant factor grows with the
+transfer size, and "each device has some fixed track size which must be
+accommodated to avoid wasting large amounts of space", so "optimum node size
+depends critically on the characteristics of the system and the devices".
+
+The turso chapter's Step 2 draws this exact tree-of-pages picture, and its
+Step 3 covers how one page physically stores variable-length entries — the
+slotted layout — which Comer does not need and this chapter will not
+re-explain.
### Step 4 — the invariants: what "B-tree" actually promises
-A B-tree of order d enforces three rules at all times: (1) every node
-except the root holds between d and 2d keys — **at least half full**; (2)
-all leaves sit at the same depth — **perfectly balanced, always**; (3) keys
-within a node are sorted, and child subtrees fall strictly between adjacent
-keys.
+> **In:** the order d and the fanout F from Step 3.
+> **Out:** three rules, and the two guarantees they buy — a worst-case height
+> and a bounded space overhead — which Step 5's algorithms must maintain on
+> every single insert.
+
+A B-tree of order d enforces three rules at all times:
+
+1. **Occupancy.** Every node except the root holds between d and 2d keys, so
+ in Comer's words "each node is at least ½ full" (§1).
+2. **Balance.** All leaves sit at the same depth. Comer calls this the point of
+ the whole structure: "the beauty of B-trees lies in the methods for
+ inserting and deleting records that always leave the tree balanced" (§1,
+ *Balancing*).
+3. **Order.** Keys within a node are sorted, and each child subtree holds
+ exactly the keys falling between its two bracketing separators — the
+ generalization of the binary search tree's left/right split (§1).
+
+What the rules buy, and it is two distinct things:
+
+- **The height bound of Step 3 is worst-case, not average-case.** Rule 1 is
+ what puts the `d` under the logarithm: with a *minimum* of d children per
+ node, depth i has at least 2dⁱ⁻¹ nodes no matter what order the keys arrived
+ in. There is no insertion sequence that degrades a B-tree the way sorted
+ input turns a naive binary search tree into a linked list.
+- **Wasted space is capped.** **Storage utilization** — the fraction of the
+ allocated bytes that hold live entries — is at least 50% by rule 1. The
+ expected value is better: Comer reports in §3 (*2-3 Trees and Theoretical
+ Results*) that "extending the analysis to B-trees of higher order, Yao has
+ shown that the expected storage utilization is ln 2 [≈] 69%" [YAO78].
+
+Turn 69% into this topic's currency. **Space amplification** is physical bytes
+on disk divided by logical bytes stored, so a structure sitting at ln 2
+occupancy has, from slack alone,
-What the rules buy: the height bound of Step 3 is *worst-case*, not
-average-case — there is no insertion order that degrades a B-tree the way
-sorted input turns a naive binary tree into a linked list. And the
-half-full rule caps wasted space: pages are 50–100% full, ~69% (ln 2) on
-average in practice — that gap is the B-tree's space overhead, and it's
-bounded.
+```
+ 1 / 0.6931 = 1.443× space amplification from page slack, in steady state
+```
+
+Hold that number next to what this topic actually measured: redb, a
+copy-on-write B-tree fed 108 MB of records in random key order, wrote **6.8 GB**
+— space amplification **63.28×** ([FINDINGS.md](../../FINDINGS.md) row 1). The
+gap between 1.44× and 63.28× is the measure of how much of a real B-tree's
+space cost is *not* the thing Comer analysed. Page slack is bounded and
+predictable; copy-on-write page versions retained across 1080 commits are
+neither. Comer's 1979 B-tree updates in place, so 1.44× is the whole story
+there — and that is precisely the assumption Step 6 of the
+[LSM chapter](reading-lsm-paper.md) and this topic's README both attack.
### Step 5 — search, insert, split: the mechanics
-Search descends one block per level: read the root, binary-search its keys,
-follow the child pointer that brackets your key, repeat until a leaf —
-height block reads, exactly the Step 3 budget. Insert descends the same
-way, then places the key in a leaf; the interesting case is a full leaf
-(2d+1 keys): **split** it into two d-key nodes and push the middle key up
-into the parent. The push can overflow the parent too, so splits propagate
-upward; splitting the root is the *only* way the tree gets taller — it
-grows from the top, which is what keeps all leaves level (invariant 2 for
-free). Deletion mirrors it: a node under d keys **borrows** a key from a
-sibling or **merges** with one.
-
-Map to turso: `balance_non_root` (btree.rs:2995) is the "borrow from
-siblings first" refinement — Comer explicitly calls out redistribution as
-reducing splits, and turso's ≤3-sibling rebalance is that idea implemented.
-Cost gradient: one insert usually dirties 1 block, occasionally a split
-chain of O(height) blocks.
+> **In:** the invariants from Step 4 and the tree shape from Step 3.
+> **Out:** three algorithms that all cost O(h) blocks, and the one operation —
+> the root split — that is allowed to change h.
+
+**Search** descends one block per level: read the root, search its keys,
+follow the child pointer that brackets your key, repeat until a leaf. That is
+h block reads, exactly Step 3's budget. What happens *inside* the node is
+Comer's "less important cost" from Step 1, and §3 notes the options anyway:
+Clampet suggests binary search rather than linear scan, Knuth's refinement is
+that "a binary search might be useful if the node is large, while a sequential
+search might be best for small nodes."
+
+**Insert** descends the same way and places the key in a leaf. The interesting
+case is a full leaf, holding its maximum 2d keys: **split** it into two nodes
+of d keys each and push the middle key — the **separator**, the key that
+divides which subtree a search descends into — up into the parent. The push can
+overflow the parent too, so splits propagate upward. Splitting the root is the
+*only* way the tree gets taller, which is why it grows from the top, and why
+invariant 2 (all leaves level) holds for free: every leaf gains a level at the
+same instant.
+
+**Delete** mirrors it. A node dropping below d keys **borrows** a key from a
+sibling (redistribution) or **merges** with one (concatenation).
+
+The cost, per §2 (*Insertion and Deletion Costs*): an insert or delete "may
+require additional secondary storage accesses beyond the cost of a find
+operation as it progresses back up the tree. Overall, the costs are at most
+doubled, so the height of the tree still dominates". So the gradient is: one
+insert usually dirties 1 block, occasionally a split chain of O(h) blocks, and
+never more than 2h accesses.
+
+**Map to turso.** Comer's §3 opens with the refinement turso implements:
+"instead of splitting a node as soon as it fills up, keys could merely be
+distributed into a neighboring node, splitting only when two neighbors fill."
+turso's `balance_non_root` is that idea, and the number of siblings it will
+consider is a named constant:
+
+```rust
+// core/storage/btree.rs at turso dd775bc — the constant at 136, and the two
+// lines of balance_non_root (2995) that consume it. Elided between them:
+// 137-2994, the page/cell format, the cursor and the seek machinery.
+ 136 pub const MAX_SIBLING_PAGES_TO_BALANCE: usize = 3;
+// ... 137-2994: page layout, cursor, seek ...
+ 2995 fn balance_non_root(&mut self) -> Result> {
+// ... 2996-3073: state machine, parent bookkeeping, assertions ...
+ 3074 let mut pages_to_balance: [Option; MAX_SIBLING_PAGES_TO_BALANCE] =
+ 3075 [const { None }; MAX_SIBLING_PAGES_TO_BALANCE];
+```
+
+The line that carries the argument is **136**: the redistribution window is
+three pages wide, fixed at compile time. Line 3074 is where that width becomes
+the actual array of pages the balance operates on. A wider window would pack
+pages fuller (Step 4's utilization rises toward Step 6's B\*-tree bound) at the
+cost of reading more siblings per split — the same tradeoff Comer describes in
+prose, with a number attached.
### Step 6 — B-tree vs B+-tree: the variant everyone shipped
-In Comer's original B-tree every node stores full records; in the
-**B+-tree** variant, interior nodes store only keys (pure routing
-information), all records live in the leaves, and the leaves are chained
-into a linked list:
+> **In:** everything above — a tree whose interior nodes carry records.
+> **Out:** the shape every shipped engine uses instead, and the one operation
+> (`next`) whose cost it changes by a factor of h.
+
+In Comer's original B-tree every node stores full records. In the **B+-tree**,
+"all keys reside in the leaves. The upper levels, which are organized as a
+B-tree, consist only of an index, a roadmap to enable rapid location of the
+index and key parts" (§3, *B+-Trees*), and the leaves are "usually linked
+together left-to-right". Comer names that chain: "the linked list of leaves is
+referred to as the **sequence set**."
```
B-tree: keys+values in ALL nodes B+tree: values ONLY in leaves
┌─────k,v─────┐ ┌──────k──────┐ routing only
┌─k,v─┐ ┌─k,v─┐ ┌──k──┐ ┌──k──┐
- ... [k,v|k,v] ↔ [k,v|k,v] linked leaves
+ ... [k,v|k,v] ↔ [k,v|k,v] sequence set
└── range scan = list walk
```
-Why every real engine chose B+: (a) interior nodes hold only keys → higher
-fanout (Step 3's formula with smaller entries) → shorter tree; (b) the
-leaf-level linked list → range scans walk sideways without re-descending;
-(c) uniform "all data at leaf depth" simplifies everything.
+A terminology warning Comer spends a footnote on: "perhaps the most misused
+term in B-tree literature is B\*-tree." Knuth's actual **B\*-tree** is a
+different thing from the B+-tree — see below — and Comer adopts "B+-tree" for
+"Knuth's unnamed implementation" precisely to stop the confusion. So when a
+codebase says "B\*", check which one it means.
+
+Why every real engine chose B+, in the order Comer argues it:
+
+1. **`next` gets cheap, by a factor of h.** This is the headline, and it is the
+ problem §2 (*Sequential Processing*) leaves hanging. In a plain B-tree, a
+ preorder walk "requires space for at least h = log_d(n + 1) nodes in main
+ memory since it stacks the nodes along a path", and finding the smallest key
+ means descending from the root to the leftmost leaf (Figure 12). In a
+ B+-tree, §3 says the structure "retains the logarithmic cost properties for
+ operations by key, but gains the advantage of requiring **at most 1 access
+ to satisfy a next operation**. Moreover, during the sequential processing of
+ a file, no node will be accessed more than once, so space for only 1 node
+ need be available in main memory."
+2. **Higher fanout, shorter tree.** Interior entries carry a separator and a
+ child pointer instead of a whole record. Run Step 3's formula on topic 3's
+ format with an 8-byte key and a 100-byte value: the *leaf* capacity falls to
+ `(2 + 2 + 8 + 100 + 2) = 114 B` per entry, `4088/114 = 35` records per leaf,
+ while the interior fanout stays at **255** because the value never enters an
+ interior cell. A B-tree that stored those records in interior nodes too
+ would have a fanout of 35 there as well — `log₃₅(1e6/35) = 10.259/3.555 =
+ 2.89 ⇒ 3` interior levels instead of 2, a whole extra IO per lookup on the
+ same data. Topic 3 records the height as 3 for this shape, which is the
+ B+ answer.
+3. **Uniformity.** All data at leaf depth means deletion never has to hunt for
+ a record inside an interior node — §3: "the key to be deleted must always
+ reside in a leaf so its removal is simple", and a stale separator left
+ behind in the index still routes searches correctly (Comer's Figure 14).
+
+And the variant that *is* called B\*, since Step 5 already met its mechanism:
+Knuth's B\*-tree keeps every node "at least 2/3 full (instead of just 1/2
+full)" by delaying a split until two siblings are full and then "the 2 nodes
+are divided into 3, each 2/3 full. This scheme guarantees that storage
+utilization is at least 66%" (§3, *B\*-Trees*). Compare it against Step 4's
+numbers in this topic's currency:
+
+```
+B-tree guaranteed 50% ⇒ space amp ≤ 2.000× expected ln 2 = 69% ⇒ 1.443×
+B*-tree guaranteed 66% ⇒ space amp ≤ 1.515×
+```
+
+Comer adds the second-order win: "increasing storage utilization has the side
+effect of speeding up the search since the height of the resulting tree is
+smaller."
The paper's core loop, in the B+ shape §3 argues for — note that the cost of
this function is *exactly* its iteration count:
```rust
-// height = number of page reads = ceil(log_fanout(n)) — the whole game
+// ILLUSTRATION — not quoted from any engine. The real descent this sketches is
+// turso's cursor seek in core/storage/btree.rs:2995 (the balance path) and the
+// page/cell accessors above it; the height arithmetic in the comment is Step 3's.
fn lookup(pager: &Pager, root: PageId, key: u64) -> Option {
let mut page = pager.read(root); // each read: 1 potential IO
loop {
@@ -141,66 +406,234 @@ fn lookup(pager: &Pager, root: PageId, key: u64) -> Option {
page = pager.read(page.child(i)); // descend one level
}
Leaf => return page.find(key), // B+: values ONLY here;
- } // leaf link → range scans
+ } // sequence set → range scans
}
}
-// 4 KB page ≈ 100 keys ⇒ 1 billion rows at height 5, top 3–4 levels cached
+// 4 KB page, 8 B key, topic 3's format ⇒ F = 255, L = 185
+// 1e9 rows ⇒ 5,405,406 leaves ⇒ 3 interior levels ⇒ height 4
```
-And the modern payoff of Steps 3–6 combined: 1 billion rows fit in height
-5, and the root plus interior levels are ~1–2% of the data — they stay in
-the buffer pool, so a point lookup is typically **one actual disk IO**.
+The modern payoff of Steps 3–6 combined is a caching argument, and Comer makes
+it himself in §3 (*Virtual B-Trees*): under an LRU policy "the most active
+nodes are those close to the root; these tend to stay in memory", and "at
+least, the root should remain in main memory since it is accessed for each
+search." Size it with the numbers above, at n = 10⁹ and 4 KB pages:
+
+```
+leaf level 5,405,406 pages × 4096 B = 22.14 GB
+interior levels 5,405,406/255 = 21,198
+ + 21,198/255 = 84
+ + 84/255 = 1 = 21,283 pages × 4096 B = 87.2 MB
+interior share 87.2 MB / 22,140 MB = 0.39% of the file
+```
+
+Under half a percent of the file is routing, so the top three levels fit in any
+plausible buffer pool and a point lookup costs **one actual disk IO**. Topic 3
+measures the catch: lookups still climb **862 → 1101 ns** from 1e6 to 4e6 keys
+with height pinned at 3 ([FINDINGS.md](../../FINDINGS.md) row 3), because
+"resident in the buffer pool" and "resident in CPU cache" are different
+questions and Comer's model has no term for the second.
## How to read the paper (with the concepts in hand)
-Read in this order:
-
-1. **§1–2 (the problem + the structure)** — Steps 1–4 in Comer's words: why
- balanced trees on disk need high fanout — tree height = number of IOs,
- and height = log_fanout(n). A 4 KB page holding ~100 keys ⇒ 1 billion
- rows in height 5, of which 3–4 levels cache-resident. This is the whole
- game.
-2. **§2.1–2.2 (insertion/deletion)** — Step 5's mechanics: split on
- overflow, merge/borrow on underflow. Map to turso as you read:
- `balance_non_root` (btree.rs:2995) is the "borrow from siblings first"
- refinement — Comer calls redistribution out as reducing splits.
-3. **§3 (B+-tree, B*-tree variants)** — Step 6; the section that matters
- most, because B+ is what every real engine shipped. The B*-tree's
- deferred split (redistribute into a sibling before splitting) is
- question 2 below.
-4. **§4 (applications: VSAM, etc.)** — skim for flavor; 1979's product
- landscape.
+~15 pages, 2 h. The order below is the corrected one — §4 is concurrency, not
+applications.
+
+1. **Introduction, *Operations on a File*** — Step 1. The four operations
+ (`insert`, `delete`, `find`, `next`), and the two sentences that declare
+ block accesses to be the cost measure. `next` is the one to keep in mind;
+ §3 is where it gets fixed.
+2. **§1 The Basic B-Tree** (subheads *Balancing*, *Insertion*, *Deletion*) —
+ Steps 2, 4 and 5 in Comer's words: the generalization from the binary search
+ tree (Figures 2 and 3), the order-d definition, and the split/merge
+ algorithms. Map to turso as you read: `balance_non_root`
+ (`core/storage/btree.rs:2995`) with `MAX_SIBLING_PAGES_TO_BALANCE = 3`
+ (`:136`) is §3's "redistribute before splitting" refinement, implemented.
+3. **§2 The Cost of Operations** (*Retrieval Costs*, *Insertion and Deletion
+ Costs*, *Sequential Processing*) — Step 3's height bound and **Table I**, the
+ single most quotable artifact in the paper. Read *Sequential Processing*
+ last and notice it ends by deferring `next` to the next section — that
+ deferral is the reason B+ exists.
+4. **§3 B-Tree Variants** — Step 6, and the section that matters most. Read
+ *B\*-Trees* and *B+-Trees* adjacently so the naming confusion lands, then
+ *Virtual B-Trees* (the caching argument) and *2-3 Trees and Theoretical
+ Results* (Yao's ln 2). Skim *Prefix B+-Trees* and *Compression* — they are
+ topic 3's suffix-truncation exercise.
+5. **§4 B-Trees in a Multiuser Environment** — skim now, return at topics 8–9;
+ this is lock coupling before it had that name.
+6. **§5 A General Purpose Access Method Using B+-Trees** — IBM's VSAM. Skim for
+ flavour; 1979's product landscape.
## Questions to answer in notes.md
-1. Why do B-trees guarantee ≥50% page occupancy, and what's the *measured average*
- (~69%, ln 2)? Connect to space amplification in the README.
-2. B*-tree defers splits by redistributing into siblings. What does turso implement —
- B+, B*, or a hybrid?
-3. Comer's B-trees assume one page write is atomic. It isn't (torn writes). Which
- later machinery patches this hole? (WAL — topic 5; checksums — topic 3.)
+1. Comer's height bound is `h ≤ log_d((n+1)/2)` where `d` is the *minimum*
+ fanout, but Step 3's page arithmetic computes the *maximum*, F. Work both for
+ topic 3's 8-byte-key format (F = 255, so d = 127) at n = 10⁶ and say how far
+ apart the guaranteed and typical heights are. Which one does a latency SLO
+ care about?
+2. Why do B-trees guarantee ≥50% page occupancy, and what is the expected
+ value? (Yao's ln 2 ≈ 69%, §3.) Convert both to space amplification and put
+ them beside this topic's measured 63.28× for redb — what accounts for the
+ rest?
+3. Comer's §3 describes redistribution-before-split as a way to "delay
+ splitting and eliminate the associated overhead", and Knuth's B\*-tree as
+ the 2/3-full version of it. Read turso's `balance_non_root`
+ (`core/storage/btree.rs:2995`) and decide: is turso B+, B\*, or a hybrid?
+ Name the line that decides it.
+4. Comer's B-trees assume one page write is atomic. It is not — a **torn
+ write** is a page that hit the disk half-updated after a crash. Which later
+ machinery patches this hole, and which topic measures its cost?
+5. §2's Table I stops at 10⁷ records. Extend it: with topic 3's F = 255 and
+ L = 185, at what n does the height reach 5, and how big is the file then?
+ (Step 3 gives you both formulas.)
## The one-line takeaway
-The B-tree is the memory hierarchy turned into a data structure: node size = transfer
-unit, fanout = whatever fits, height = the IO budget.
+The B-tree is the memory hierarchy turned into a data structure: node size =
+transfer unit, fanout = whatever fits, height = the IO budget.
## Done when
+Answer each before unfolding it.
+
- [ ] You can state the disk access model in one line — cost is blocks touched, not comparisons made — and use it to explain why a binary search tree is the wrong shape.
+
+ Answer
+
+ Comer's Introduction: "most random access devices transfer a fixed amount of
+ data per read operation, so that the total time required is linearly related
+ to the number of reads. Therefore, the number of secondary storage accesses
+ serves as a reasonable cost measure." Count block reads, not comparisons.
+
+ The binary search tree fails that model twice over, and the two failures are
+ independent. Its height is log₂(n) — 19.93, so 20 reads at a million keys —
+ because each node has two children. And each of those reads pulls a 4096-byte
+ block to consume roughly 16 bytes of it, throwing away 99.6% of the transfer.
+ Packing several binary nodes into one block fixes the second and leaves the
+ first; only making one node *be* one block fixes both, which is Step 3.
+
+
+
- [ ] You can list the B-tree invariants and say which one forces the >=50% occupancy guarantee.
+
+ Answer
+
+ Occupancy, balance, order. Occupancy is the one that does the work: §1's
+ definition is that a node of order d holds "at most 2d keys and 2d + 1
+ pointers… each must have at least d keys and d + 1 pointers. As a result,
+ each node is at least ½ full." A node is sized for 2d keys and never allowed
+ below d, so the floor is d/2d = 50% by construction.
+
+ That same rule is what makes Step 3's height a *guarantee*: with a minimum of
+ d children per node, depth i holds at least 2dⁱ⁻¹ nodes regardless of
+ insertion order, which is the inequality §2 turns into `h ≤ log_d((n+1)/2)`.
+ Occupancy and worst-case height are the same rule seen from two ends. The
+ expected occupancy is better than the guarantee — Yao's ln 2 ≈ 69% (§3) — but
+ the guarantee is what you can quote in a capacity plan: at 50%, space
+ amplification from slack alone is at most 2.000×, and at 69% it is 1.443×.
+
+
+
- [ ] You can narrate a split and say where the separator key ends up in a B-tree versus a B+-tree.
+
+ Answer
+
+ A leaf holding its maximum 2d keys receives one more. It splits into two
+ nodes of d keys, and the middle key becomes the separator pushed into the
+ parent. If the parent is also full it splits too, so splits propagate upward;
+ splitting the root is the only operation that increases the height, and
+ because it lifts every leaf at once, the "all leaves at the same depth"
+ invariant is maintained for free.
+
+ The difference is what happens to the middle key itself. In a plain B-tree it
+ **moves** up: it now lives in the parent and nowhere else, so a search that
+ matches a key in an interior node stops there. In a B+-tree the algorithm
+ "promotes a copy of the key, retaining the actual key in the right leaf" (§3),
+ so the search "does not stop if a key in the index equals the query value.
+ Instead, the nearest right pointer is followed, and the search proceeds all
+ the way to a leaf." The consequence Comer draws out is a deletion
+ simplification: the copy in the index can be left behind as a pure separator
+ even after the real key is deleted, and searches still land correctly
+ (Figure 14).
+
+
+
- [ ] You can compute fanout and height for a given page size and key width, and check yourself against topic 3's measured table (185 leaf cells and fanout 255 for 8 B keys).
-- [ ] You wrote answers to both questions in notes.md, including what turso actually implements.
+
+ Answer
+
+ `F = floor((P - H) / (c + s))` — page size, header, cell bytes, slot-pointer
+ bytes. Topic 3's format is P = 4096, H = 8, s = 2, interior cell
+ `child u32 ∥ key_len u16 ∥ key` and leaf cell
+ `key_len u16 ∥ val_len u16 ∥ key ∥ val`. For an 8-byte key and 8-byte value:
+
+ ```
+ interior 4 + 2 + 8 = 14, + 2 slot = 16 ⇒ 4088 / 16 = 255.5 ⇒ F = 255
+ leaf 2+2+8+8 = 20, + 2 slot = 22 ⇒ 4088 / 22 = 185.8 ⇒ L = 185
+ ```
+
+ Then height in two parts: `n / L` leaves, and `ceil(log_F(leaves))` interior
+ levels above them. At n = 10⁶: 5,406 leaves, log₂₅₅(5406) = 1.551 ⇒ 2 interior
+ levels ⇒ height 3. At n = 10⁹: 5,405,406 leaves, log₂₅₅(…) = 2.798 ⇒ 3 ⇒
+ height 4. Both match `topics/03-btree-internals/notes.md`.
+
+ The check that you have understood rather than memorized: widen the key to 32
+ bytes and the interior entry becomes 40 B, so F drops to 102 and the height at
+ 10⁶ rises to 4 — one extra IO on identical data, bought entirely with 24 bytes
+ of key. Topic 3 records that row too.
+
+
+
+- [ ] You wrote answers to all five questions in notes.md, including what turso actually implements.
+
+ Answer
+
+ Nothing to unfold — the questions are the exercise, and they go under
+ `## Papers → Comer '79` in this topic's `notes.md`.
+
+ The bar for question 3, since it is the one with a checkable answer in code:
+ turso is a B+-tree with the redistribution refinement, not a B\*-tree. The
+ deciding evidence is `MAX_SIBLING_PAGES_TO_BALANCE = 3`
+ (`core/storage/btree.rs:136`, turso `dd775bc`) consumed at
+ `core/storage/btree.rs:3074` — a fixed three-page redistribution window, which
+ is §3's "distribute into a neighbouring node" rather than Knuth's 2-into-3
+ split with its 66% floor. An answer that says "B\*, because it redistributes"
+ has confused the mechanism with the guarantee: B\* is defined by the
+ occupancy bound, and turso never promises one.
+
+
## References
**Papers**
-- Comer — "The Ubiquitous B-Tree" (ACM Computing Surveys 1979) — ~15
- pages, 2 h; read §1–3 in order, §3 (the B+/B* variants) matters most,
- skim §4
+- Comer — "The Ubiquitous B-Tree" (*ACM Computing Surveys*, Vol. 11, No. 2,
+ June 1979, pp. 121–137) — ~15 pages, 2 h; read the Introduction, §1, §2 and
+ §3 in order, §3 (the B+/B\* variants) matters most, skim §4 (multiuser) and
+ §5 (VSAM).
+
+| Section | What this chapter took from it |
+|---|---|
+| Introduction, *Operations on a File* | the four operations; "the number of secondary storage accesses serves as a reasonable cost measure"; the three costs the model ignores |
+| §1 | the B-tree as a generalization of the binary search tree (Figures 2–3); order d = "at most 2d keys and 2d + 1 pointers… at least d keys", hence "each node is at least ½ full"; balancing, insertion, deletion |
+| §2, *Retrieval Costs* | the node counts 2, 2d, 2d², 2d³ …; the bound `h ≤ log_d((n+1)/2)`; **Table I**, and "a B-tree of order 50 which indexes a file of one million records can be searched with only 4 disk accesses in the worst case… simple implementation techniques lower the worst case cost to 3" |
+| §2, *Insertion and Deletion Costs* | costs "at most doubled" over a find; the practical limits on node size (transfer bound, constant factor, track size) |
+| §2, *Sequential Processing* | a plain B-tree's `next` may cost log_d n accesses and needs h nodes stacked in memory |
+| §3, opening | redistribute into a neighbour before splitting — the refinement turso implements |
+| §3, *B\*-Trees* | Knuth's definition: ≥2/3 full, split 2 nodes into 3, "storage utilization is at least 66%"; and the warning that the term is "perhaps the most misused" in the literature |
+| §3, *B+-Trees* | all keys in the leaves, upper levels a "roadmap"; the **sequence set**; a promoted *copy* of the separator; "at most 1 access to satisfy a next operation" and space for only 1 node |
+| §3, *Virtual B-Trees* | LRU keeps the nodes closest to the root resident; "at least, the root should remain in main memory" |
+| §3, *2-3 Trees and Theoretical Results* | Yao's result: "the expected storage utilization is ln 2 [≈] 69%" |
**Code**
-- [turso](https://github.com/tursodatabase/turso)
- `core/storage/btree.rs` — the living counterpart; walked in
- [reading-turso-btree.md](reading-turso-btree.md)
+- [turso](https://github.com/tursodatabase/turso) at `dd775bc` —
+ `core/storage/btree.rs:136` (`MAX_SIBLING_PAGES_TO_BALANCE = 3`) and
+ `core/storage/btree.rs:2995` (`balance_non_root`), the living counterpart;
+ walked in [reading-turso-btree.md](reading-turso-btree.md).
+
+**This repo's measurements cited above**
+- `topics/03-btree-internals/notes.md` — the fanout table (255/185 for 8 B keys,
+ 102/88 for 32 B keys, 255/35 for a 100 B value) that Step 3's formula
+ reproduces, and `topics/03-btree-internals/experiments/src/page.rs:1-20` for
+ the cell format it is derived from.
+- [FINDINGS.md](../../FINDINGS.md) row 1 (redb's 63.28× space amplification) and
+ row 3 (862 → 1101 ns at constant height).
diff --git a/topics/01-storage-engine-landscape/reading-fjall.md b/topics/01-storage-engine-landscape/reading-fjall.md
index 3597ac0..1971ef6 100644
--- a/topics/01-storage-engine-landscape/reading-fjall.md
+++ b/topics/01-storage-engine-landscape/reading-fjall.md
@@ -3,13 +3,22 @@
The LSM protagonist of this topic — a codebase small enough that insert-to-SST
is traceable in an afternoon, and layered well enough to steal from. fjall is
the *keyspace/journal/scheduling* layer; the actual tree (memtable, SSTs,
-blooms, block index) lives in the external `lsm-tree` crate (Cargo.toml:29).
-Before touching the code, this chapter builds the LSM machine step by step —
-why writes are buffered, what a memtable and journal are, what a flush
-produces, how a read finds anything, and why compaction and tombstones exist.
-Then it hands you the file and line anchors to watch each step happen.
-Reading fjall shows you the LSM *lifecycle*; topic 4 descends into `lsm-tree`
-itself.
+blooms, block index) lives in the external `lsm-tree` crate, pinned at
+`~3.1.6` in `Cargo.toml:29`. Before touching the code, this chapter builds the
+LSM machine step by step — why writes are buffered, what a memtable and journal
+are, what a flush produces, how a read finds anything, and why compaction and
+tombstones exist. Then it hands you file and line anchors to watch each step
+happen. Reading fjall shows you the LSM *lifecycle*; topic 4 descends into
+`lsm-tree` itself.
+
+**All line numbers below are from `fjall-rs/fjall@80cf6bc`** (crate version
+3.1.6), the commit in this repo's pin table — check with
+`python3 tools/pinned-source.py ref fjall`, and read any file at that commit
+with `python3 tools/pinned-source.py show fjall -r A:B`. One API caveat:
+this topic's own experiment (`experiments/Cargo.toml:7`) pins **fjall 2.x**
+(2.11.2 in the lockfile), where the type now called `Keyspace` was called
+`Partition` and the type now called `Database` was called `Keyspace`. The
+concepts are identical; the names moved in 3.0.
## The problem in one sentence
@@ -21,104 +30,166 @@ write on crash, and while still answering point reads in a handful of IOs.
### Step 1 — why buffer writes in memory: sequential beats random
-Storage devices reward sequential access and punish random access. An NVMe
-SSD streams sequential writes at ~2–5 GB/s, but random 4 KB writes top out
-around 50–500K IOPS — and each small random write also forces the device to
-rewrite a whole internal flash block (write amplification inside the drive).
-An update-in-place engine (like the B-tree in the turso chapter) turns every
-insert with a random key into a random page write. The LSM (log-structured
-merge) idea inverts this: **never update in place**. Accumulate incoming
-writes in RAM — where random access is free — until you have a few MB, then
-write them to disk in one big sequential burst.
+> **In:** a stream of inserts whose keys arrive in random order, and a block
+> device with wildly asymmetric sequential and random throughput.
+> **Out:** the reason every LSM starts with a RAM buffer, and the name of the
+> cost that buys.
-```
+Storage devices reward sequential access and punish random access. An
+**update-in-place** engine — one that modifies a record where it already lives,
+like the B-tree in the turso chapter — turns every insert with a random key into
+a random page write, because the target page is wherever the key sorts.
+
+The LSM (log-structured merge) idea inverts this: **never update in place**.
+Accumulate incoming writes in RAM — where random access is nearly free —
+until you have tens of megabytes, then write them to disk in one big sequential
+burst.
+
+```text
update-in-place: insert(k₉₃₁), insert(k₀₂), insert(k₅₅₀) ...
→ 3 random 4 KB page writes, scattered across the file
- log-structured: insert(k₉₃₁), insert(k₀₂), insert(k₅₅₀) ... × ~100K
- → buffered in RAM, sorted, then ONE 8-64 MB sequential write
+ log-structured: insert(k₉₃₁), insert(k₀₂), insert(k₅₅₀) ... × ~600K
+ → buffered in RAM, sorted, then ONE 64 MiB sequential write
```
-What it costs: the data on disk is now *many files written at different
-times* instead of one tree — reads and space reclamation get harder. Steps
-4–6 are the price being paid.
+That 64 MiB is not a guess: fjall's default `max_memtable_size` is
+`64 * 1_024 * 1_024` at `src/keyspace/options.rs:91`, and at this topic's
+100-byte records that is 64 MiB / 100 B ≈ **671,000 records per flush**.
+
+What it costs: the data on disk is now *many files written at different times*
+instead of one tree — reads and space reclamation get harder. Steps 4–6 are the
+price being paid, and [FINDINGS.md](../../FINDINGS.md) row 1 is what the trade
+is worth on this topic's workload: on the same 108.0 MB of records, fjall's LSM
+occupies **48.4 MB** (space amp 0.45×) against redb's copy-on-write B-tree at
+**6,833.9 MB** (63.28×), a **140× spread**.
### Step 2 — the memtable and the journal: RAM for speed, a log for safety
+> **In:** the decision to buffer writes in RAM.
+> **Out:** the two structures that decision forces (a sorted in-memory map and
+> an append-only log), the ordering rule between them, and fjall's actual
+> `insert` from line 905.
+
The in-RAM buffer is the **memtable** — an in-memory *sorted* map (fjall's
`lsm-tree` uses a skip list) that absorbs every write and can be range-scanned
-in key order. Sorted matters: when it's time to write to disk, the data must
-come out in key order (Step 3), and reads must be able to search it.
+in key order. Sorted matters twice: the data must come out in key order when it
+is written to disk (Step 3), and reads must be able to search it.
-RAM alone is a durability hole: crash before the buffer hits disk and the
-writes are gone. The fix is the **journal** (also called a WAL, write-ahead
-log): an append-only file on disk. The rule that gives it its name — every
-write is appended to the journal *before* it enters the memtable. Appending
-is sequential (the fast case from Step 1), so durability costs one sequential
-append, not a random write. After a crash, replaying the journal rebuilds
-the memtable.
+RAM alone is a durability hole: crash before the buffer hits disk and the writes
+are gone. The fix is the **journal** (also called a WAL, write-ahead log): an
+append-only file on disk. The rule that gives it its name — every write is
+appended to the journal *before* it enters the memtable — is what makes replay
+correct. Appending is sequential (the fast case from Step 1), so durability
+costs one sequential append, not a random write.
-fjall's `Keyspace::insert()` — `src/keyspace/mod.rs:905` — *is* this step,
-de-sugared to ten lines:
+Here is `Keyspace::insert` verbatim, with the doc comment and error paths
+elided:
```rust
-fn insert(&self, key: &[u8], value: &[u8]) -> Result<()> {
- let journal = self.journal.lock(); // journal lock BEFORE memtable —
- journal.write_raw(key, value)?; // replay order must equal apply order
- journal.persist(self.durability)?; // fsync per policy, not per write
- let bytes = self.tree.insert(key, value); // memtable: sorted, in RAM
- self.write_buffer.fetch_add(bytes); // atomic accounting → backpressure
- if self.memtable_over_size_limit() {
- self.rotate_memtable(); // seal it + enqueue flush task —
- } // event-driven, no polling
- Ok(())
-}
+// src/keyspace/mod.rs at fjall-rs/fjall@80cf6bc — Keyspace::insert, lines 905-950,
+// with the doc comment and two guard clauses (912-914, 921-924) elided.
+905 pub fn insert, V: Into>(
+906 &self,
+907 key: K,
+908 value: V,
+909 ) -> crate::Result<()> {
+919 let mut journal_writer = self.supervisor.journal.get_writer();
+926 let seqno = self.supervisor.seqno.next();
+928 journal_writer.write_raw(self.id, &key, &value, lsm_tree::ValueType::Value, seqno)?;
+930 if !self.config.manual_journal_persist {
+931 journal_writer
+932 .persist(crate::PersistMode::Buffer)
+937 ?;
+938 }
+940 let (item_size, memtable_size) = self.tree.insert(key, value, seqno);
+942 self.supervisor.snapshot_tracker.publish(seqno);
+944 drop(journal_writer);
+946 self.supervisor.write_buffer_size.allocate(item_size);
+947 self.maintenance(memtable_size);
+949 Ok(())
+950 }
```
-Two costs to notice: every write is written *twice* (journal + eventually an
-SST — the first factor of **write amplification**, the ratio of bytes written
-to disk per byte of user data), and the fsync policy on line 3 decides
-whether durability is per-write or batched — the single biggest write-latency
-knob in any LSM.
+Five things this function tells you that prose would not:
+
+1. **Line 919 takes the journal lock before anything else, and line 944 drops it
+ only after the memtable insert.** That interval is what guarantees replay
+ order equals apply order. The comment at line 921 explains a second reason —
+ the poison flag must be checked *after* acquiring the mutex, otherwise
+ TOCTOU.
+2. **Line 926: a sequence number is allocated per write.** Seqnos are the spine
+ of LSM correctness — they are how newest-wins is decided in Step 4 and how
+ GC knows what is safe to drop in Step 5. Line 942 publishes it to the
+ snapshot tracker.
+3. **Line 932's default is `PersistMode::Buffer`, not an fsync.** The enum is at
+ `src/journal/writer.rs:35`: `Buffer` (:41) hands bytes to the OS page cache
+ only; `SyncData` (:46) calls `sync_data()` (:226); `SyncAll` (:49) calls
+ `sync_all()` (:220). So out of the box fjall survives a *process* crash but
+ not a *power* loss — the single biggest write-latency knob in any LSM, and
+ the one you must match across engines before comparing them.
+4. **Line 946 is accounting, not backpressure.** `write_buffer_size.allocate`
+ just bumps a counter; the actual throttling is in `maintenance` (line 947).
+5. **Every write is written twice** — once to the journal, later to a segment.
+ That is the first factor of **write amplification**: bytes physically written
+ to disk per byte of user data.
### Step 3 — flush: the memtable becomes an immutable sorted file
-When the memtable reaches its size limit (typically 8–64 MB), it is
-**rotated**: marked immutable ("sealed"), swapped for a fresh empty memtable,
-and handed to a background thread that writes it out as an **SSTable**
-(sorted string table; fjall calls them **segments**) — an *immutable* file of
-key-value pairs in sorted order, plus two small helpers:
+> **In:** a memtable that has hit 64 MiB.
+> **Out:** what a segment file contains, why immutability is the point, and
+> which fjall defaults set the layout.
-```
+When the memtable exceeds `max_memtable_size`, it is **rotated**: marked
+immutable ("sealed"), swapped for a fresh empty memtable, and handed to a
+background thread that writes it out as an **SSTable** (sorted string table;
+fjall calls them **segments** or *tables*) — an *immutable* file of key-value
+pairs in sorted order, plus two small helpers:
+
+```text
one segment (SSTable) on disk:
┌───────────────────────────────┬──────────────┬──────────────┐
- │ data blocks (~4 KB each, │ block index │ bloom filter │
- │ sorted key-value pairs) │ first key → │ ~10 bits/key │
- │ [a..f][g..m][n..s][t..z] ... │ block offset │ ≈1% false pos│
+ │ data blocks (4 KiB each, │ block index │ bloom filter │
+ │ sorted key-value pairs) │ first key → │ 10 bits/key │
+ │ [a..f][g..m][n..s][t..z] ... │ block offset │ ⇒ 0.82% FPR │
└───────────────────────────────┴──────────────┴──────────────┘
- 64 MB data ~tens of KB ~80 KB per 64K keys
+ 64 MiB data ~tens of KB ~840 KB / 671K keys
```
-- The **block index** maps "first key of each ~4 KB block → file offset", so
- finding a key inside a segment costs one binary search in RAM plus **one**
- disk read.
-- The **bloom filter** is a probabilistic set-membership structure (a bit
- array written to by k hash functions): "definitely not here" or "maybe
- here". At ~10 bits per key it answers "is key X in this file?" with ~1%
- false positives — for the cost of a few hashes, no IO.
-
-Because the segment is immutable, it never needs locking and can be written
-as one sequential stream. The journal entries covering the flushed memtable
-can now be dropped. Cost: the same key may now exist in several segments
-(old versions in older files) — nothing has been overwritten, only shadowed.
+Every number in that box is a fjall default you can check:
+
+- **4 KiB data blocks** — `data_block_size_policy: BlockSizePolicy::all(/* 4 KiB
+ */ 4 * 1_024)` at `src/keyspace/options.rs:95`.
+- The **block index** maps "first key of each block → file offset", so finding a
+ key inside a segment costs one binary search in RAM plus **one** disk read.
+ fjall pins the index blocks of the top two levels in memory
+ (`index_block_pinning_policy: PinningPolicy::new([true, true, false])`,
+ `options.rs:100`) and partitions them from level 3 down
+ (`options.rs:103`).
+- The **bloom filter** is a probabilistic set-membership structure — a bit array
+ written by *k* hash functions — that answers "definitely not here" or "maybe
+ here", never a false negative. At the standard optimal *k*, the false-positive
+ rate for *m* bits per key is `0.6185^m`, so fjall's default of 10 bits/key
+ gives `0.6185^10` = **0.82%**, and 671,000 keys × 10 bits = 839 KB of filter
+ per segment. The policy is at `src/keyspace/options.rs:108–111` and Step 4
+ explains why it is an *array*.
+
+Because the segment is immutable it never needs locking and can be written as
+one sequential stream; the journal entries covering the flushed memtable can
+then be dropped. Cost: the same key may now exist in several segments — nothing
+has been overwritten, only *shadowed* by a higher seqno.
### Step 4 — the read path: newest wins, blooms skip the rest
-A key can live in the active memtable, a sealed-but-not-yet-flushed memtable,
-or any segment. Since newer always shadows older, a read checks locations
-**newest-first** and returns the first hit:
+> **In:** a key that may live in the active memtable, a sealed memtable, or any
+> segment on any level.
+> **Out:** the newest-first search order, the arithmetic of what blooms save,
+> and the one fjall default that is Monkey shipped as a product.
-```
+Since newer always shadows older, a read checks locations **newest-first** and
+returns the first hit:
+
+```text
get(k): active memtable → sealed memtables (newest first)
→ segments, newest first:
bloom says "no"? → skip, zero IO (the common case)
@@ -126,127 +197,365 @@ or any segment. Since newer always shadows older, a read checks locations
```
The number of places a single read might have to check is **read
-amplification**. Bloom filters are what keep it tolerable: with 20 segments
-and 1% false positives, a lookup for an absent key does ~0.2 disk reads
-instead of 20. In fjall, `Keyspace::get()` — `src/keyspace/mod.rs:623` — is
-two lines delegating to `tree.get(key, SeqNo::MAX)`; the whole
-newest-first/bloom dance lives inside `lsm-tree`. But the bloom *policy* is
-configured in fjall: `src/keyspace/config/filter.rs:8–43` (`BitsPerKey` vs
-`FalsePositiveRate`, per-level policies — Monkey's idea productized; topic 4).
+amplification**. Blooms are what keep it tolerable. Concretely, with 20 segments
+and fjall's default 10 bits/key (0.82% FPR), a lookup for an *absent* key does
+20 × 0.0082 = **0.16 expected disk reads** instead of 20 — a 122× reduction, for
+the cost of 20 in-memory hash probes.
+
+In fjall the search itself is two lines:
+
+```rust
+// src/keyspace/mod.rs at fjall-rs/fjall@80cf6bc — Keyspace::get, lines 623-625.
+// SeqNo::MAX means "read the newest version of everything" — a snapshot read
+// would pass its own seqno here instead (topic 8's MVCC preview).
+623 pub fn get>(&self, key: K) -> crate::Result