Systems From First Principles
A 15-project, 130-week program that trains you to reconstruct foundational computer systems from their constraints — Transformers, ANN indexes, vector databases, LSM engines, distributed key-value stores, MapReduce, stream processors, recommenders, experimentation platforms, a programming language, an OS kernel, a tensor framework, and a matrix-multiplication accelerator simulator — then measure them, break them, modify them, and write them up like papers.
Not a curriculum. There are no lectures to consume. Every unit ends in running code, a benchmark with tail latencies, a falsified or surviving hypothesis, and a technical report.
Table of Contents
- The Honest Duration
- What This Trains
- Start Here
- The Twenty Deliverables
- Repository Layout
- The Runnable Tools
- The Universal Project Loop
- Operating Rules
- What This Journey Refuses To Do
- Honest Status
- References
The Honest Duration
You asked for 18–30 months at 8–12 focused hours per week, and you asked me not to compress the plan artificially. Those two constraints are in tension, so here is the arithmetic before anything else.
The fifteen projects, scoped to minimum-viable plus standard implementation and excluding every optional research extension, total 1,430 focused hours across 130 active weeks. The per-project derivation is in Project Portfolio; the weeks are the same numbers divided by an 11 h/week pace.
Calendar time is not active weeks. Assume 46 productive weeks per year — six weeks lost annually to travel, illness, work crunch, and the two weeks after any production incident at your day job. Then:
| Sustained pace | Hours / year | Calendar duration | Verdict |
|---|---|---|---|
| 8 h/week | 368 | 47 months | Outside your envelope. Cut scope or raise the pace. |
| 10 h/week | 460 | 37 months | Achievable, but 7 months past your ceiling. |
| 11 h/week | 506 | 34 months | The realistic default. Plan for this. |
| 12 h/week | 552 | 31 months | Your stated ceiling, with zero margin for a bad quarter. |
Headline: 34 months. Roughly two years and ten months. I am not going to tell you 18, because 18 months × 12 h/week is 936 hours and you cannot build a kernel and a language and a distributed KV store and a tensor framework in 936 hours at a depth that produces defensible original work. The compressed version of this plan exists and it is called a tutorial series.
If the 30-month ceiling is genuinely hard
There are exactly six scope cuts that preserve the core mechanism of every project. Take all six — 15 weeks — and you land at 115 active weeks / 1,265 hours / exactly 30 months at 11 h/week:
| Cut | Saves | What you lose | What you keep |
|---|---|---|---|
| Fold Project 10 into Project 9 as a module, not a standalone build | 4 wk | A separate A/B service and its own report | Assignment, SRM, power, guardrails — all still implemented |
| Take the user-space option for Project 12 instead of a bootable kernel | 3 wk | Real boot, real interrupts, real MMU | Scheduling, paging simulation, syscall boundary, context-switch measurement |
| Project 14 at MVP only — CPU tiling and the systolic simulator, no GPU | 2 wk | CUDA/Triton kernels | Roofline, tiling, arithmetic intensity, the accelerator simulator |
| Skip the static type checker in Project 11 Phase II | 2 wk | Type inference and checking | Lexer, parser, tree-walk, bytecode, VM, GC, optimization passes |
| Build Project 7 as an extension of Project 6's runtime, not a new system | 2 wk | Independent streaming architecture | Windows, watermarks, checkpointing, backpressure, exactly-once |
| Fold Project 3's versioning and concurrency into Project 5 | 2 wk | Snapshot-isolated readers in the vector DB | Storage format, WAL, recovery, compaction, the filtering study |
Taking only the first five saves 13 weeks, which lands at 117 weeks / 1,287 hours / 30.5 months — near enough that the sixth cut is optional.
Do not achieve 30 months by working faster on the full scope. That produces fifteen half-built systems and no reports, which is worth less than five finished ones.
Full derivation, dependency graph, and stage boundaries: Roadmap.
What This Trains
The stated goal is the technical depth of people who built MapReduce, Spanner, TensorFlow, TPUs, language runtimes, kernels, databases, and Transformers. That depth is not a body of knowledge. It is a set of habits, and each one is trained by a specific mechanic that appears in every project:
| Habit | The mechanic that trains it | Enforced by |
|---|---|---|
| Reconstruct a system from its constraints | You write the naive design before reading the paper | Notebook §Naive Design |
| Know why the canonical design exists | You predict the naive design's failure point, then measure it | Notebook §Predictions |
| Implement mechanisms, not glue | The central mechanism of every project is hand-written | AI Policy, Rule 6 |
| Experimental discipline | Prediction is recorded before the run; you cannot retro-fit | AI Policy, Rule 2 |
| Performance analysis | Every claim carries p50/p95/p99 and a confidence interval | tools/bench.py |
| Failure analysis | Every distributed project ships a fault injector before it ships a feature | Project 5 |
| Original technical thinking | Every project ends with a hypothesis you might lose | Scorecard §Originality |
| Research-quality communication | Every project ends in a report with a negative-results section | Scorecard §Communication |
The failure mode this program is designed against is yours, specifically: you told me you spread attention across too many subjects and stay in consumption mode. So the guardrails are not advice, they are rules with a stated enforcement mechanism. See Guardrails. The single most important one:
One primary implementation project at a time. Reading never completes anything.
Start Here
| Step | Do this | Time |
|---|---|---|
| 0 | Take the calibration battery, cold, then python3 tools/calibrate.py. It sizes the first three projects to you | 3 h 30 m |
| 1 | Read The Honest Duration above and decide your pace. Write the number down. | 10 min |
| 2 | Read the Operating Model — the weekly rhythm, the allocation, the guardrails | 25 min |
| 3 | Read the AI Assistant Policy and commit to it in writing | 15 min |
| 4 | Copy templates/notebook.md to notebook/000-calibration.md and fill the Problem and Constraints sections for Project 1 | 30 min |
| 5 | Execute the First Week Action Plan — it starts with git init and ends with a measured baseline | 11 h |
| 6 | Continue with the First Twelve Weeks, week by week | 12 wk |
You do not need to read the fifteen project pages before you start. Read Project 1 and the Roadmap; read each subsequent project page in the week before you begin it. Reading all fifteen now is itself the consumption-mode failure this program exists to break.
The Twenty Deliverables
Every item you asked for, and where it lives.
| # | Deliverable | Location |
|---|---|---|
| 1 | Realistic total duration | Above · Roadmap §Duration |
| 2 | Dependency graph for all projects | Roadmap §Dependency Graph |
| 3 | Stage-by-stage roadmap | Roadmap §The Six Stages |
| 4 | Table summarizing every project | Projects §Master Table |
| 5 | Detailed monthly milestones | Roadmap §Monthly Milestones |
| 6 | Detailed plans for the first twelve weeks | First 12 Weeks |
| 7 | Estimated effort for every project | Projects §Effort |
| 8 | Required and optional project scope | Projects §Three Scope Tiers + each project page |
| 9 | Primary-source readings for every project | Readings + each project page |
| 10 | Experiments and benchmarks for every project | Each project page, §Experiments |
| 11 | Exit criteria for every project | Each project page, §Exit Criteria |
| 12 | Reusable research-notebook template | Notebook Template · templates/notebook.md |
| 13 | Project scorecard | Scorecard |
| 14 | AI-assistant usage policy | AI Policy |
| 15 | Portfolio and publication strategy | Portfolio |
| 16 | What not to study yet | Not Yet |
| 17 | Projects that can become original research | Research Directions |
| 18 | Final integrated-system plan | Final System · Project 15 |
| 19 | First-week action plan | Week One |
| 20 | Sustainable strategy while working full-time | Sustainability |
Plus seven you did not ask for but will need:
| Addition | Why it is here |
|---|---|
| Calibration | A 3-hour battery that sizes the first three projects to you, not to my guess. tools/calibrate.py turns the scores into an adjusted schedule |
| State | The one ledger spanning all 130 weeks — projects, hours, estimation multiplier, scorecard trend, what you are blocked on |
| Audit of the Fifteen | What the project list covers, covers twice, and misses — written because you chose the list and I never questioned it |
| How To Read | The protocol for 183 hours of primary sources, with two worked examples |
| Retention | Four mechanisms so month-3 knowledge survives to month 34. The original plan budgeted 1% for this |
| External Feedback | Five mechanisms against the track's weakest property: every score is self-assigned |
| Datasets, Hardware and Money | What it physically needs, what it costs (under $100), and the two real dependencies |
| The Numbers | Every constant the journey depends on, derived and measured rather than cited — including the four measurements that were wrong before they were right |
| Proofs | Eighteen derivations the track leans on, each worked in full and numerically verified — 116 checks, all passing |
| Walkthroughs | Six executable miniatures, 40–60 min each, every one ending in a measured result that contradicts a common belief |
| Hands-On Builds | One page per project — 109 numbered lego blocks and 15 assemblies, 4,260 lines of runnable Python. Every number on every page is captured from a real run by handson/build_pages.py, and seven pages document a prediction the measurement refuted |
| Glossary | ~90 concepts explained from first principles: what, why, internals, connections, production |
| The Week Generator | The procedure that produced weeks 1–12, so you can run it yourself for weeks 13–130 |
| Project Scaffold | ./new-project.sh <slug> <python|rust|go> — verified for all three languages |
| Language strategy | Four languages, each assigned by which mechanism it exposes |
| Just-in-time mathematics | ~60 hours total, each topic scheduled to the project that needs it |
| Worked notebook entry | A real completed entry, so the template is not scaffolding-without-content |
Repository Layout
README.md this file
roadmap.md duration derivation, dependency graph, 6 stages, 34 monthly milestones
projects.md master table, effort model, three scope tiers
projects/ p01..p15 — the full specification of each build
first-12-weeks.md week-by-week for weeks 1-12
week-one.md hour-by-hour for week 1, starting now
week-generator.md how to decompose weeks 13-130 yourself, with 3 worked examples
calibration.md the 3-hour battery, taken before week 1
STATE.md the ledger spanning all 130 weeks
portfolio-audit.md what the fifteen cover, cover twice, and miss
reading-method.md three-pass reading, and reading code
retention.md review queue, rebuild drills, forced reuse, teaching tests
feedback-loops.md adversarial self-review, publishing, reproduction exchange
datasets-and-hardware.md what it needs, what it costs, the two real dependencies
numbers.md every constant, derived and measured, with what it decides
proofs.md 18 derivations, each verified by tools/proofs.py
glossary.md ~90 concepts, explained rather than defined, 18 with runnable code
walkthroughs.md six 45-minute executable miniatures
walkthroughs/ w1..w6 -- the scripts behind them
handson/ h01..h15, the 15 generated pages, and build_pages.py
scaffold/ new-project.sh + templates for python / rust / go
scores/ scorecard.json, trended by tools/scorecard.py
operating-model.md weekly rhythm, time allocation, thinking exercises, guardrails
notebook-template.md the research notebook, explained field by field
notebook/ example-filled.md — a real, completed notebook entry
templates/ notebook.md, report.md, experiment.md — copy these
scorecard.md 12 categories, 1/3/5 anchors, completion definition
ai-policy.md the ten rules, with enforcement
languages.md Python / Go / Rust / C / CUDA — which mechanism each exposes
math.md just-in-time mathematics, tied to the project that needs it
readings.md every primary source, by project, with why-read-this notes
portfolio.md artifacts, publication venues, the paper
not-yet.md what to actively refuse to study, and when it unlocks
research-directions.md the seven that can become original work
final-system.md Project 15's architecture and its research question
sustainability.md completing this while employed full-time
tools/ bench.py, roofline.py, metrics.py, annlab.py, bloom.py
The Runnable Tools
Five scripts you use across the whole journey. All are executed and their real output is quoted in the pages that reference them — no unverified numbers appear anywhere in this track.
cd tools
cc -O2 -o machine-baseline machine-baseline.c && ./machine-baseline
# regenerates every hardware constant in numbers.md
./machine-baseline --json > mine.json && python3 baseline.py mine.json
# diff vs the reference + 8 invariant checks
python3 calibrate.py 3 4 2 1 3 # calibration scores -> adjusted schedule
python3 scorecard.py trend # 12 categories x 15 projects, what is rising
python3 proofs.py # 116 checks over every derivation in proofs.md
python3 -m pytest tests/ -q # 63 tests over the tools themselves
python3 bench.py demo # the measurement harness: p50/p95/p99 + bootstrap CI
# + an honest "no measurable difference" verdict
python3 roofline.py table # ridge points; the batch size that ends memory-boundedness
python3 roofline.py decode --params 7e9 --hw h100
python3 roofline.py gemm --m 4096 --n 4096 --k 4096
python3 metrics.py demo # recall/precision/NDCG/MRR/coverage/novelty/Gini,
# Welch t, power sizing, sample-ratio-mismatch chi-square
python3 bloom.py # Bloom theory vs measurement, and where measurement fails
python3 annlab.py # brute force vs a small-world graph: the recall/latency curve
bench.py, metrics.py, and roofline.py are dependency-free. annlab.py needs
numpy.
Read tools/bench.py before you write a single benchmark. It encodes
four rules — distribution not mean, warmup separated from measurement, bootstrap
interval on the median, environment recorded — and if you internalise only one artifact
from this whole track, that is a good one to pick.
The Universal Project Loop
Every one of the fifteen projects runs the same fourteen steps, in order. The loop is the actual curriculum; the projects are just the substrate it runs on.
1 Problem definition What breaks if this system does not exist?
2 Requirements/constraints What is fixed? What is negotiable? What is assumed?
3 Naive design Your design, written BEFORE you read the paper.
4 Predicted failure points Where and at what scale you expect it to break.
5 Minimal implementation The smallest thing that exhibits the mechanism.
6 Correctness testing Property tests and invariants. Before any timing.
7 Instrumentation Counters and timers in the code, not around it.
8 Baseline measurement A number you can be beaten by.
9 Bottleneck analysis Where the time actually goes. Measured, not guessed.
10 Hypothesis One falsifiable claim, with its falsifier stated.
11 Modification The smallest change that tests the hypothesis.
12 Controlled experiment One variable. Fixed seeds. Repeated trials.
13 Failure analysis What broke, why, and what it generalises to.
14 Technical report Including the results that made you wrong.
Steps 3 and 4 are the ones people skip, and they are the ones that produce the ability you actually want. A person who has read the MapReduce paper knows what MapReduce is. A person who designed a batch framework, predicted that stragglers would dominate, and then measured a 6× tail inflation from one slow worker understands why the paper has a section on backup tasks. Those are different skills and only one of them survives a hard design review.
Full field-by-field guidance: Notebook Template. A completed example with real measured numbers: Worked Notebook Entry.
Operating Rules
Applied without exception. Each is here because it prevents a specific documented failure mode of self-directed systems study.
- One primary implementation project at a time. Plus at most one small maintenance or writing task from an earlier project, plus at most one bounded reading thread that directly supports the current project. Nothing else.
- Reading never completes anything. A unit is done when code runs, a benchmark is recorded, and a report exists.
- No optimization without a baseline. If you cannot state the number you are trying to beat, you are not optimizing, you are fiddling.
- No performance claim without tail latency. Mean latency is the number that hides the bug.
- No performance work while a correctness test is red. Fast and wrong is a regression, not a result.
- Predictions are written before runs. A prediction recorded after the result is not a prediction, and the habit it builds is the opposite of the one you want.
- Negative results are shipped, not buried. The report section titled "What I Expected And Did Not Get" is mandatory and is scored.
- Extensions are locked until exit criteria are met. Every project has an attractive optional extension. It is not available until the standard version passes.
- Every project produces an artifact that is useful on its own. If it only makes sense as part of the journey, it is not a portfolio piece.
- A stalled project gets a written postmortem, not a silent abandonment. See the two-week rule.
What This Journey Refuses To Do
Stated up front so you can disagree now rather than in month nine.
- It will not make anything production-grade. No auth, no multi-tenancy, no packaging, no ops. Every hour spent on production polish is an hour not spent on a mechanism. The one exception is Project 15, and only for the specific subsystem its research question depends on.
- It will not chase completeness. The kernel will not run a shell. The language will not have a standard library. The database will not be ACID. Completeness is a substitute activity for depth.
- It will not let you rewrite things in a new language because the new language is interesting. Language choice is fixed per project and justified by which mechanism it exposes.
- It will not treat complexity as originality. A system with nine components and no measured claim scores lower than a system with two components and a falsified hypothesis. See Scorecard.
- It will not pretend the schedule survives contact with your job. See Sustainability, which budgets for the bad quarters instead of assuming they will not happen.
Honest Status
This track is a plan and a toolkit, not a completed body of work. What exists today:
- Complete: the roadmap and its arithmetic, all fifteen project specifications, the first twelve weeks in day-level detail, the week-one hour-by-hour plan, the notebook template plus one fully worked entry with real measured numbers, the scorecard, the AI policy, the language and mathematics strategies, the portfolio plan, and five executed, verified tools.
- By design absent: the fifteen implementations. Those are yours. This track will not contain a reference solution for any project, because a reference solution available on day one destroys step 3 of the loop, which is the step that matters most.
- Numbers: every measured figure quoted in these pages was produced by running the
script named next to it on the machine described in
tools/bench.py's environment block — a 12-core arm64 macOS laptop, CPython 3.14.0, under a nonzero load average. They are illustrative of shape, not of your hardware. Re-run them on your machine in week 1; the numbers will differ and the shapes will not.
References
The complete, per-project reading list with annotations is in Readings. The handful below define the intellectual posture of the whole track and are worth reading in the first month.
On the method
- Hamming, R. W. You and Your Research. Bell Communications Research Colloquium Seminar, 1986. The canonical talk on choosing important problems and working with open doors. Read this in week 1.
- Bell, C. G., Newell, A. Computer Structures: Readings and Examples. McGraw-Hill, 1971. The original argument that systems should be studied as designed artifacts with explicit tradeoffs.
- Lampson, B. W. Hints for Computer System Design. SOSP '83, ACM Operating Systems Review 17(5), 1983. Thirty-odd design maxims from someone who built the systems. "Handle normal and worst case separately" is the one you will re-derive most often.
- Feynman, R. P. Cargo Cult Science. Caltech commencement address, 1974. On the discipline of not fooling yourself, which is the entire content of the experimental sections here.
- Wilson, G. et al. Best Practices for Scientific Computing. PLoS Biology 12(1), 2014. Reproducibility as an engineering practice.
On measurement
- Williams, S., Waterman, A., Patterson, D. Roofline: An Insightful Visual Performance
Model for Multicore Architectures. CACM 52(4), 2009. Implemented in
tools/roofline.py. - Gregg, B. Systems Performance: Enterprise and the Cloud, 2nd ed. Pearson, 2020. The USE method, and the best available treatment of "which of the four resources is actually saturated".
- Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013. Why p99 is the number that matters and why it gets worse as you add machines.
- Mytkowicz, T. et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS '09. Measurement bias from link order and environment size — read before you trust your first speedup.
- Fleming, P. J., Wallace, J. J. How not to lie with statistics: the correct way to summarize benchmark results. CACM 29(3), 1986. Why the arithmetic mean of ratios is wrong.
On writing systems up
- Shewchuk, J. R. Three Sins of Authors in Computer Science and Math. 1997.
- Zobel, J. Writing for Computer Science, 3rd ed. Springer, 2014. The standard
reference for the report format used in
templates/report.md. - Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004. Specifically: write the paper first, then do the research — which is the same instruction as "write your hypothesis before you run the experiment".
State — The Progress Ledger
The one file that spans all 130 weeks. RESUME.md lives inside each project and tells
you where you stopped yesterday. This tells you where you are in the journey.
Update it at every project boundary and every stage review — roughly twenty times across the journey, not weekly. A ledger you must maintain constantly gets abandoned; one you touch every six weeks does not.
Everything below is the template with the starting state filled in. Overwrite as you go. The
<!-- comments -->explain each field; delete them once the habit is formed.
Right Now
DATE : not started
PHASE : pre-calibration
CURRENT PROJECT : none
CURRENT WEEK : 0 of 130
PACE : undecided (see README "The Honest Duration")
MODE : — (full 11 h/wk | maintenance 3 h/wk | zero, planned)
Next action: take the 3-hour calibration battery, cold, then run
python3 tools/calibrate.py C1 C2 C3 C4 C5 and record the adjusted schedule below.
Calibration
| Task | Score | Adjustment applied |
|---|---|---|
| C1 attention | _/5 | |
| C2 nearest neighbour | _/5 | |
| C3 systems arithmetic | _/5 | |
| C4 ownership / Rust | _/5 | |
| C5 measurement judgement | _/5 | |
| Total | _/25 | net _ weeks → _ active weeks |
Re-takes (C3 / C5 only):
| Stage review | C3 | C5 | Note |
|---|---|---|---|
| M7 | |||
| M15 | |||
| M22 | |||
| M26 | |||
| M31 |
Projects
Gate = the eight-item completion gate. A project is done when the gate passes, not when the code works.
| # | Project | Weeks | Status | Planned h | Actual h | Gate | Report | Weakest category |
|---|---|---|---|---|---|---|---|---|
| P01 | Transformer | 1–8 | not started | 88 | ☐ | ☐ | ||
| P02 | ANN index | 9–15 | not started | 77 | ☐ | ☐ | ||
| P11-I | Tree-walk interpreter | 16–20 | not started | 55 | ☐ | ☐ | ||
| P13-I | Autodiff core | 21–26 | not started | 66 | ☐ | ☐ | ||
| P03 | Vector database | 27–34 | not started | 88 | ☐ | ☐ | ||
| P04 | LSM engine | 35–43 | not started | 99 | ☐ | ☐ | ||
| P11-II | Bytecode VM + GC | 44–50 | not started | 77 | ☐ | ☐ | ||
| P13-II | Execution optimisation | 51–54 | not started | 44 | ☐ | ☐ | ||
| P05 | Distributed KV | 55–67 | not started | 143 | ☐ | ☐ | ||
| P06 | MapReduce | 68–75 | not started | 88 | ☐ | ☐ | ||
| P07 | Streaming | 76–83 | not started | 88 | ☐ | ☐ | ||
| P08 | Recommender | 84–89 | not started | 66 | ☐ | ☐ | ||
| P09 | Simulator | 90–95 | not started | 66 | ☐ | ☐ | ||
| P10 | A/B platform | 96–99 | not started | 44 | ☐ | ☐ | ||
| P12 | Kernel | 100–110 | not started | 121 | ☐ | ☐ | ||
| P14 | Hardware-aware | 111–117 | not started | 77 | ☐ | ☐ | ||
| P15 | Integrated | 118–130 | not started | 143 | ☐ | ☐ | ||
| Total | 130 | 0/17 | 1,430 | 0 |
Status values: not started · in progress · complete · cut (reason) ·
abandoned (postmortem written).
Estimation Calibration
| Project | Planned h | Actual h | Ratio | What ran over, and why |
|---|---|---|---|---|
Running multiplier: —
If the multiplier exceeds 1.3 after three projects, your real pace is lower than planned. Re-derive the end date from the duration table and move it. Do not plan to catch up; nobody catches up.
Scorecard Trend
Recorded by tools/scorecard.py into
scores/scorecard.json. Summarise here at each stage review.
| Stage | Weakest category | Chosen focus for next stage | Did it move? |
|---|---|---|---|
| 1 (M7) | |||
| 2 (M15) | |||
| 3 (M22) | |||
| 4 (M26) | |||
| 5 (M31) |
cd tools && python3 scorecard.py trend
Retention
| Week | Rebuild drill | Outcome | Note |
|---|---|---|---|
| 15 | Multi-head attention | ☐ | |
| 26 | Beam search + distance counter | ☐ | |
| 40 | Bloom filter | ☐ | |
| 54 | SSTable reader | ☐ | |
| 67 | Raft election logic | ☐ | |
| 83 | Watermark tracker | ☐ | |
| 99 | EMA profile + metric suite | ☐ | |
| 117 | Blocked matmul | ☐ |
Outcomes: working in 3h (you own it) · bugs findable (mostly) ·
cannot start (you never owned it — schedule a re-read, add queue items).
Review queue: _ items · last run: —
External Feedback
| Stage | Adversarial self-review | Published | Reproduction exchange | Reviewer conversation |
|---|---|---|---|---|
| 1 | ☐ | ☐ W20 | ☐ | ☐ |
| 2 | ☐ | ☐ W34, W43 | ☐ | ☐ |
| 3 | ☐ | ☐ W67, W83 | ☐ | ☐ |
| 4 | ☐ | ☐ W99 | ☐ | ☐ |
| 5 | ☐ | ☐ W110, W117 | ☐ | ☐ |
| 6 | ☐ | ☐ W126, W130 | ☐ | ☐ |
Corrections received that changed a score:
Decisions and Cuts
| Date | Week | Decision | Reason | Cost |
|---|---|---|---|---|
Blocked On
| Item | Since | Needed by | Notes |
|---|---|---|---|
| Choose a pace (11 h/wk default) | — | Week 1 | Duration table |
| Take the calibration battery | — | Week 1 | 3 h 10 m, cold |
| Decide P12 target (RISC-V vs user-space) | — | Week 100 | Re-decide at week 3 of P12 |
| Register for MIND | — | Month 20 | Datasets |
| Find one reproduction partner | — | Stage 1 review | Feedback |
| Decide the P15 research question | — | Week 118 | Six candidates |
| What is this ultimately for? | — | — | Career, research, curiosity. Changes P15's question and several priorities |
Parking List
- (empty)
Log
[not started] Track built. Calibration is the next action.
Roadmap
The duration arithmetic, the dependency structure, the six stages, and thirty-four monthly milestones.
Table of Contents
- Duration Derivation
- Dependency Graph
- Why This Order
- Legal Reorderings
- The Six Stages
- The Active-Week Schedule
- Monthly Milestones (M1–M34)
- Checkpoint Reviews
- References
Duration Derivation
Step 1 — effort per project
Each project's hour figure is the sum of its milestones, estimated at the pace of a senior engineer who knows the language but not the domain, and excluding optional research extensions. The full milestone breakdown is on each project page; the totals:
| # | Project | Size | Hours | Weeks @ 11 h |
|---|---|---|---|---|
| P01 | Transformer from scratch | Medium | 88 | 8 |
| P02 | Approximate nearest-neighbour index | Medium | 77 | 7 |
| P03 | Small vector database | Medium | 88 | 8 |
| P04 | Log-structured storage engine | Medium | 99 | 9 |
| P05 | Distributed key-value store | Large | 143 | 13 |
| P06 | MapReduce-style framework | Medium | 88 | 8 |
| P07 | Stream-processing system | Medium | 88 | 8 |
| P08 | Recommendation system | Medium | 66 | 6 |
| P09 | Recommendation simulator | Medium | 66 | 6 |
| P10 | A/B testing platform | Small | 44 | 4 |
| P11 | Programming language and VM (I + II) | Large | 132 | 12 |
| P12 | OS kernel or kernel subsystems | Large | 121 | 11 |
| P13 | Tensor framework and autodiff (I + II) | Large | 110 | 10 |
| P14 | Hardware-aware ML system | Medium | 77 | 7 |
| P15 | Integrated final system | Large | 143 | 13 |
| Total | 1,430 | 130 |
Step 2 — active weeks to calendar time
\[ \text{calendar months} = \frac{H}{W_{\text{prod}} \times p} \times 12 \]
where \(H = 1430\) hours, \(p\) is your sustained weekly pace, and \(W_{\text{prod}} = 46\) is productive weeks per year. Forty-six, not fifty-two, because six weeks a year vanish into travel, illness, a launch at work, and the fortnight after any real production incident. A plan that assumes fifty-two productive weeks is not optimistic, it is arithmetically wrong.
| p | h/year | years | months |
|---|---|---|---|
| 8 | 368 | 3.89 | 47 |
| 10 | 460 | 3.11 | 37 |
| 11 | 506 | 2.83 | 34 ← plan for this |
| 12 | 552 | 2.59 | 31 |
Step 3 — the honest verdict
34 months. Your stated ceiling of 30 months is reachable two ways, and only two:
- Sustain 12 h/week for the full scope — 1,430 h / 552 h per year → 31 months, with no margin for a bad quarter.
- Stay at 11 h/week and take the six scope cuts listed in the README — 1,265 h / 115 active weeks → exactly 30 months.
Anything shorter is achieved by deleting projects, not by working faster. If you want a shorter honest journey, the right cut is P01, P02, P04, P05, P13 and P15 only — six projects, 660 hours, 60 active weeks, ≈16 months at 11 h/week — which still covers a Transformer, an ANN index, a storage engine, a distributed system, an autodiff framework and an integrated contribution. That is a real 16-month plan. A 16-month plan containing all fifteen projects is not.
Dependency Graph
Solid arrows are hard dependencies: the downstream project consumes the upstream project's code or its measured results. Dashed arrows are soft: the downstream project is much easier if you have done the upstream one, but does not import it.
STAGE 1 STAGE 2 STAGE 3 STAGE 4 STAGE 5 STAGE 6
foundations storage & exec distributed product low-level contribution
P01 Transformer
│ ┊
│ ┊ (motivates)
▼ ┊
P13-I Autodiff ───► P13-II Exec Opt ──────────────────────────────► P14 HW-aware ─┐
│ ▲ │
└───────────────────────────────────────────────────────────────────────┘ │
│
P02 ANN index ────► P03 Vector DB ─────────────────────────────────────────────────┤
│ │ ▲ │
│ │ ┊ (persistence borrowed from) │
│ │ ┊ │
│ P04 LSM engine ──► P05 Distributed KV ──► P06 MapReduce │
│ │ ▲ │ │
│ │ ┊ ▼ │
│ │ ┊ P07 Streaming ───────┤
│ │ ┊ │ │
│ └──────┴──────────────┘ │
│ (fault injector, reused by both) │
│ │
└──────────────► P08 Recommender ──► P09 Simulator ──► P10 A/B platform ─────────┤
▲ │
┊ (embeddings from) │
┊ ▼
P01/P13 P15 Integrated System
P11-I Tree-walk ──► P11-II Bytecode VM ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┘
┊
┊ (GC + dispatch intuition feeds the kernel)
▼
P12 Kernel
Edge table
Every edge, with the specific thing that crosses it. An edge with no concrete payload is not a dependency, it is a vibe, and it should not constrain your ordering.
| From | To | Hard? | What actually crosses the edge |
|---|---|---|---|
| P01 | P13-I | soft | Motivation and a target: your autodiff must reproduce the Transformer's gradients to within 1e-5 of PyTorch's |
| P13-I | P13-II | hard | The graph IR and tensor class that fusion operates on |
| P13-II | P14 | hard | The blocked-matmul kernel and its measured GFLOP/s become the accelerator simulator's CPU baseline |
| P01 | P14 | soft | The decode-step roofline analysis from P01's inference experiments |
| P02 | P03 | hard | The HNSW index — P03 is literally built around it |
| P04 | P03 | soft | If you do P04 first you will rewrite P03's persistence layer; the roadmap accepts this deliberately (see Why This Order) |
| P04 | P05 | hard | The single-node engine that gets partitioned and replicated |
| P05 | P06 | hard | The fault-injection harness; the membership and failure detector |
| P05 | P07 | hard | The same harness plus the replicated log, which becomes the event log |
| P06 | P07 | soft | The task scheduler and worker pool, generalised to long-running operators |
| P02 | P08 | hard | Candidate retrieval — the recommender calls your index, not a library's |
| P01/P13 | P08 | soft | Understanding of what an embedding actually is, and the ability to generate them |
| P08 | P09 | hard | The ranking pipeline the simulator evaluates |
| P09 | P10 | hard | The simulated population that the A/B platform assigns and analyses |
| P11-I | P11-II | hard | The AST and the semantics the bytecode compiler must preserve |
| P11-II | P12 | soft | Concrete intuition for dispatch loops, stack frames, and GC pauses — all of which reappear as scheduler loops, kernel stacks, and page reclamation |
| P03, P05, P07, P08, P09, P10, P13, P14 | P15 | hard | P15 integrates them; see Final System |
Critical path
Because you run one project at a time, the schedule is 130 sequential weeks. But the longest chain of true dependencies is only 51 weeks:
P04 (9) → P05 (13) → P06 (8) → P07 (8) → P15 (13) = 51 weeks
Two other long chains:
P02 (7) → P03 (8) → P08 (6) → P09 (6) → P10 (4) → P15 (13) = 44 weeks
P01 (8) → P13-I (6) → P13-II (4) → P14 (7) → P15 (13) = 38 weeks
That 51-vs-130 gap is your reordering budget. It means the sequence below is one valid topological order out of very many, and if life makes a particular project impossible this quarter, you can almost always slide it without breaking anything. See Legal Reorderings.
Why This Order
Three ordering decisions are non-obvious and worth defending, because you will be tempted to undo all three.
1. The Transformer comes first, before the autodiff framework it logically sits on top of.
The logical order is backwards. You should build tensors, then autodiff, then a model. The roadmap does the reverse, deliberately:
- Motivation survives contact with difficulty better than logic does. Week 1 of a Transformer produces a model that generates text; week 1 of an autodiff framework produces a class that can add two arrays. You are 130 weeks from the end and the first eight weeks decide whether there is a week nine.
- Building autodiff after you have used
loss.backward()a hundred times converts the project from "implement a thing described in a blog post" to "explain a thing you have already relied on". That is the reconstruction skill this whole track is for. - P13's exit criterion is concrete precisely because P01 exists first: your autograd must reproduce your Transformer's gradients to 1e-5 against PyTorch. Without P01 there is nothing to check against.
2. The vector database comes before the LSM engine, even though it needs persistence.
You will build a naive append-only file with a full in-memory index in P03, measure its recovery time at 1M vectors, watch it take minutes, and then build the LSM engine in P04 knowing exactly which problem sparse indexes and Bloom filters solve. Doing P04 first would give you a better P03 and a worse education. This is the single clearest instance of the "naive design first" rule at the level of the roadmap itself, and it costs roughly 6 hours of rework in P03 — a price the plan pays on purpose.
3. The kernel comes at month 27, not month 3.
Kernel work is the most likely project to consume unbounded time and the least likely to produce a portfolio artifact anyone can evaluate. Putting it late means you arrive with two years of scoping discipline, an existing benchmark harness, and a working definition of "done". Putting it early means you spend four months on a bootloader and quit.
Legal Reorderings
Given the dependency table, these swaps are safe. Anything that preserves the edge directions is legal; these are the ones you are most likely to want.
| Situation | Legal move |
|---|---|
| You want distributed systems sooner (job reasons) | Move P04+P05 to Stage 2, pushing P11-II and P13-II to Stage 3. Costs nothing. |
| You are blocked on hardware for P14 | Swap P14 and P12 within Stage 5, or move P14 to just before P15. |
| Recommenders are your day job and you want them early | P02 → P08 is the only hard edge. P08/P09/P10 can run as Stage 2 immediately after P02, before P03. |
| You lose a quarter to work | Drop the stage boundary, not a project: extend Stage 3 and delete the Stage 4 buffer weeks. |
| You want to publish sooner | P02, P04, and P14 are the three most publishable early projects. See Research Directions. |
Two things that are not legal:
- Starting P15 before at least six upstream projects have passed their exit criteria. P15 is an integration and a research question; integrating three half-built systems produces a demo, not a contribution.
- Running two Large projects concurrently. Ever. This is the guardrail that the entire program is structured around.
The Six Stages
Stage 1 — Foundations and Reconstruction · W1–W26 · 286 h · M1–M7
Theme: learn the loop on projects where correctness is cheap to check.
Everything in this stage is single-process, single-machine, and deterministic. That is the point: you are learning to write a naive design, predict its failure, instrument it, and report on it, and you should not be simultaneously learning to debug a network partition.
| Project | Weeks | Deliverable |
|---|---|---|
| P01 Transformer | W1–8 | A character/BPE-level Transformer that trains, generates, and has a measured RoPE-vs-learned-vs-sinusoidal comparison |
| P02 ANN index | W9–15 | HNSW-like index with a recall/QPS curve that beats brute force at a stated n, and a comparison against hnswlib |
| P11-I Tree-walk interpreter | W16–20 | A language with closures and lexical scope, and a measured dispatch-cost breakdown |
| P13-I Autodiff core | W21–26 | Reverse-mode autodiff that reproduces P01's gradients to 1e-5 |
Stage exit: four technical reports; four repositories another engineer can clone and reproduce; the benchmark harness is now yours by muscle memory.
Stage 2 — Storage and Execution · W27–W54 · 308 h · M8–M15
Theme: durability, amplification, and the cost of a byte moved.
| Project | Weeks | Deliverable |
|---|---|---|
| P03 Vector DB | W27–34 | CRUD + filtering + persistence + recovery over P02's index, with a filtered-recall study |
| P04 LSM engine | W35–43 | WAL, memtable, SSTables, Bloom filters, both compaction strategies, and the three-amplification tradeoff measured |
| P11-II Bytecode VM | W44–50 | Compiler, stack VM, GC, and an AST-vs-bytecode speedup with the sources of the speedup attributed |
| P13-II Execution optimisation | W51–54 | Fusion, memory reuse, eager-vs-graph, and a measured arithmetic-intensity improvement |
Stage exit: you can explain read/write/space amplification with your own numbers, and you have twice built a thing whose performance is dominated by data movement rather than instruction count.
Stage 3 — Distributed Systems · W55–W83 · 319 h · M15–M22
Theme: partial failure. The hardest stage; budgeted accordingly.
| Project | Weeks | Deliverable |
|---|---|---|
| P05 Distributed KV | W55–67 | Partitioning, replication, simplified Raft, membership, rebalancing — and the fault injector, built first |
| P06 MapReduce | W68–75 | Coordinator, workers, shuffle, speculative execution, with a straggler study |
| P07 Streaming | W76–83 | Partitions, offsets, windows, watermarks, checkpointing, backpressure, and an event-time-vs-processing-time study |
Stage exit: a linearizability checker that has found at least one real bug in your own code, and a written failure analysis of a split-brain you caused on purpose.
Stage 4 — Product and Experimentation Systems · W84–W99 · 176 h · M23–M26
Theme: systems whose correctness is statistical, not logical. This is where your existing domain knowledge is deepest, so the stage is short and the bar is high — you should be producing near-publishable work here.
| Project | Weeks | Deliverable |
|---|---|---|
| P08 Recommender | W84–89 | Retrieval + ranking + diversity + freshness over your own index, against four baselines |
| P09 Simulator | W90–95 | Persona-driven simulated users with drift, fatigue, and delayed feedback; an experiment harness with seeds and CIs |
| P10 A/B platform | W96–99 | Assignment, exposure logging, guardrails, SRM detection, power analysis, and an offline-vs-online divergence study |
Stage exit: a documented case where an offline metric improved and the simulated online metric did not, with an explanation of the mechanism.
Stage 5 — Low-Level and Hardware-Aware Systems · W100–W117 · 198 h · M27–M31
Theme: the layers your abstractions have been standing on for four stages.
| Project | Weeks | Deliverable |
|---|---|---|
| P12 Kernel | W100–110 | Boot or user-space kernel, scheduler, paging, syscalls, context switching — with syscall and context-switch cost measured |
| P14 Hardware-aware ML | W111–117 | Blocked/vectorised matmul, a systolic-array simulator, quantization, and a roofline study |
Stage exit: you can answer "why is this kernel memory-bound" with a measurement rather than an opinion, at every level from L1 to HBM.
Stage 6 — Original Contribution · W118–W130 · 143 h · M31–M34
| Project | Weeks | Deliverable |
|---|---|---|
| P15 Integrated system | W118–130 | One system, one research question, an ablation suite, a benchmark, a paper, and a public demonstration |
Stage exit: the paper is written, the repository is reproducible from a clean machine by someone who is not you, and the negative results are in the paper rather than in a drawer.
The Active-Week Schedule
Stage 1 W1 ─────────────────────────────── W26
[P01 ·8·][P02 ·7·][P11-I 5][P13-I 6]
Stage 2 W27 ────────────────────────────── W54
[P03 ·8·][P04 ··9··][P11-II 7][P13-II 4]
Stage 3 W55 ────────────────────────────── W83
[P05 ·····13·····][P06 ·8·][P07 ·8·]
Stage 4 W84 ────────────── W99
[P08 6][P09 6][P10 4]
Stage 5 W100 ─────────────────── W117
[P12 ····11····][P14 ·7·]
Stage 6 W118 ───────────── W130
[P15 ·····13·····]
Monthly Milestones (M1–M34)
Calendar months, each covering ~3.8 active weeks at 46 productive weeks/year. The milestone column is the thing that must exist by month end — not a topic covered, an artifact that exists on disk and runs.
| Month | Weeks | Project | Milestone that must exist |
|---|---|---|---|
| M1 | W1–3 | P01 | Tokenizer + embeddings + a single attention head that passes a hand-computed 3-token test; benchmark harness working on your machine |
| M2 | W4–7 | P01 | Full multi-head pre-norm block, training loop, overfits 200 tokens to loss < 0.1; sampling works |
| M3 | W8–11 | P01→P02 | P01 report shipped with the RoPE/sinusoidal/learned ablation. P02 brute force + exact ground truth + recall harness |
| M4 | W12–15 | P02 | Random-graph → NSW → HNSW; efSearch/efConstruction sweep; hnswlib comparison. P02 report shipped |
| M5 | W16–19 | P11-I | Lexer, Pratt parser, AST, tree-walk evaluator with closures and lexical scope |
| M6 | W20–23 | P11-I→P13-I | P11-I report shipped (dispatch cost breakdown). P13-I Tensor class, broadcasting, and the backward graph |
| M7 | W24–26 | P13-I | Reverse-mode autodiff matches PyTorch gradients to 1e-5 on a Transformer block. P13-I report shipped. Stage 1 review. |
| M8 | W27–30 | P03 | Vector + metadata storage, insert/search/delete, tombstones, mmap-backed segments |
| M9 | W31–34 | P03 | Pre- vs post-filtering, recovery, snapshots, compaction; P03 report shipped with the filtered-recall cliff documented |
| M10 | W35–38 | P04 | WAL + memtable + SSTable + sparse index + Bloom; crash-recovery test passes under kill -9 |
| M11 | W39–42 | P04 | Both compaction strategies; the three amplifications measured under uniform/Zipfian/sequential keys |
| M12 | W43–46 | P04→P11-II | P04 report shipped. P11-II bytecode compiler and stack VM executing the Stage-1 test suite |
| M13 | W47–49 | P11-II | Mark-sweep GC with measured pause distribution; constant folding and dead-code elimination |
| M14 | W50–53 | P11-II→P13-II | P11-II report shipped (AST vs bytecode, speedup attributed by source). P13-II fusion + memory reuse |
| M15 | W54–57 | P13-II→P05 | P13-II report shipped. Stage 2 review. P05: the fault injector, built and tested before any distributed feature |
| M16 | W58–61 | P05 | Consistent hashing, partitioning, leader-based replication, replicated log |
| M17 | W62–65 | P05 | Simplified Raft: election, log replication, safety. Survives leader kill under injection |
| M18 | W66–69 | P05→P06 | Membership, failure detection, rebalancing, snapshots. P05 report shipped with a linearizability violation found and fixed. P06 begins |
| M19 | W70–72 | P06 | Input splitting, map, shuffle, sort, reduce; coordinator with task retry |
| M20 | W73–76 | P06→P07 | Speculative execution and the straggler study. P06 report shipped. P07 ingestion, partitions, offsets |
| M21 | W77–80 | P07 | Windows, watermarks, late events, stateful operators |
| M22 | W81–84 | P07→P08 | Checkpointing, recovery time, backpressure. P07 report shipped. Stage 3 review. P08 begins |
| M23 | W85–88 | P08 | Item embeddings, average and EMA user profiles, ANN retrieval, ranking with freshness and dedup |
| M24 | W89–92 | P08→P09 | Four baselines beaten or honestly not beaten. P08 report shipped. P09 personas, drift, click/skip/dwell models |
| M25 | W93–95 | P09 | Twelve scenarios including breaking news, fatigue, and failed embedding generation; seeds and CIs |
| M26 | W96–99 | P09→P10 | P09 report shipped. P10 assignment, bucketing, exposure logs, guardrails, SRM, power. P10 report. Stage 4 review |
| M27 | W100–103 | P12 | Boot (or user-space harness), memory layout, interrupts or their simulation, first syscall |
| M28 | W104–107 | P12 | Processes/threads, context switching, a measured context-switch cost |
| M29 | W108–111 | P12→P14 | Paging, page replacement policies compared, a scheduler-policy study. P12 report shipped. P14 begins |
| M30 | W112–115 | P14 | Naive → blocked → vectorised matmul with a roofline for each; quantization study |
| M31 | W116–118 | P14→P15 | Systolic-array simulator; the specialised-vs-general explanation with numbers. P14 report. Stage 5 review. P15 question locked |
| M32 | W119–122 | P15 | Integration skeleton end to end: ingest → store → embed → index → serve → simulate |
| M33 | W123–126 | P15 | The experiment: ablations, baselines, repeated trials, confidence intervals |
| M34 | W127–130 | P15 | Paper written. Repository reproducible by a stranger. Demonstration recorded. Journey complete. |
Checkpoint Reviews
At each stage boundary — M7, M15, M22, M26, M31 — stop for one full session (3 hours)
and do this, in writing, in notebook/stage-N-review.md:
- Score every project of the stage on the scorecard. Twelve categories, 1–5, with the evidence for each score named. Score down when unsure.
- Recompute the schedule. Actual hours spent vs. planned. If you are more than 15% over, do not "try harder" — cut scope from the next stage using the cut table, and record what you cut.
- Answer the four questions: What did I build that I could not have built at the start of this stage? Which of my predictions were wrong, and was there a pattern in how they were wrong? What did I avoid because it was hard? What is the single weakest of my twelve scorecard categories, and what specific mechanic in the next stage will train it?
- Re-read one report from the previous stage. You will find an unsupported claim. Fix it or retract it.
- Update Research Directions with anything you saw that might be novel. One line each. Do not chase it now.
The review is not optional and it is not a formality. It is the only mechanism in the program that catches slow drift, and slow drift over 34 months is the failure mode that actually ends journeys like this one.
References
- Brooks, F. P. The Mythical Man-Month, anniversary ed. Addison-Wesley, 1995. Chapter 2 on scheduling estimation, and the observation that adding effort to a late project makes it later — which is why the response to slipping here is scope cuts, not hours.
- DeMarco, T., Lister, T. Peopleware: Productive Projects and Teams, 3rd ed. Addison-Wesley, 2013. On sustained-pace work and the cost of interruption; the source of the 46-productive-weeks assumption.
- Hofstadter, D. R. Gödel, Escher, Bach. Basic Books, 1979, p. 152. Hofstadter's Law: "It always takes longer than you expect, even when you take into account Hofstadter's Law." The 34-month figure already includes one application of this; assume it needs a second.
- Boehm, B. W. Software Engineering Economics. Prentice-Hall, 1981. The cone of uncertainty: early estimates are reliably off by 4× in either direction, which is why every stage boundary here recomputes rather than reasserts.
- Lampson, B. W. Hints for Computer System Design. SOSP '83. "Keep it simple", "do one thing well", and the observation that most system complexity is unpaid-for generality — the intellectual basis for the scope-boundary section on every project page.
Project Portfolio
The fifteen builds: size, effort, dependencies, scope tiers, and the one sentence that says why each exists.
Table of Contents
- Master Table
- Effort and Size Classification
- The Three Scope Tiers
- What Each Project Teaches That No Other Project Teaches
- Language Assignment
- The Deliverable Every Project Produces
- Reading This Track's Project Pages
- References
Master Table
| # | Project | Size | Hours | Weeks | Stage | Language | Hard deps | Central question |
|---|---|---|---|---|---|---|---|---|
| P01 | Transformer from scratch | Medium | 88 | W1–8 | 1 | Python | — | What does attention actually compute, and what does it cost? |
| P02 | ANN index | Medium | 77 | W9–15 | 1 | Python→Rust ext | — | What exactly do you give up when you stop being exact? |
| P11-I | Tree-walk interpreter | Small | 55 | W16–20 | 1 | Rust | — | Where does the time go in a language that "does nothing"? |
| P13-I | Autodiff core | Medium | 66 | W21–26 | 1 | Python | P01 (soft) | Why is reverse mode the right default and when is it not? |
| P03 | Vector database | Medium | 88 | W27–34 | 2 | Python/Rust | P02 | What breaks when an index must survive a power cut? |
| P04 | LSM storage engine | Medium | 99 | W35–43 | 2 | Rust | — | Which of the three amplifications are you choosing to pay? |
| P11-II | Bytecode VM + GC | Medium | 77 | W44–50 | 2 | Rust | P11-I | Where does the AST→bytecode speedup actually come from? |
| P13-II | Tensor execution opt | Small | 44 | W51–54 | 2 | Python/C | P13-I | Is your framework compute-bound or dispatch-bound? |
| P05 | Distributed KV store | Large | 143 | W55–67 | 3 | Go | P04 | What does your system do when it cannot tell "slow" from "dead"? |
| P06 | MapReduce framework | Medium | 88 | W68–75 | 3 | Go | P05 | Why does a restricted programming model beat a general one? |
| P07 | Stream processor | Medium | 88 | W76–83 | 3 | Go | P05 | What is a "correct" answer when the input never ends? |
| P08 | Recommender | Medium | 66 | W84–89 | 4 | Python | P02 | Which of your gains is real and which is popularity bias? |
| P09 | Recsys simulator | Medium | 66 | W90–95 | 4 | Python | P08 | Can a simulated user population rank real algorithms correctly? |
| P10 | A/B testing platform | Small | 44 | W96–99 | 4 | Python | P09 | When does an offline win fail to become an online win? |
| P12 | OS kernel | Large | 121 | W100–110 | 5 | Rust/C | P11-II (soft) | What does a syscall, a page fault, and a context switch cost? |
| P14 | Hardware-aware ML | Medium | 77 | W111–117 | 5 | C/CUDA | P13-II | Why can specialised silicon beat a CPU by 100× at one job? |
| P15 | Integrated system | Large | 143 | W118–130 | 6 | mixed | 8 projects | Your research question — see Final System |
| Total | 1,430 | 130 |
Effort and Size Classification
The size rule
| Class | Weeks | Hours @ 11 h/wk | Count in this journey |
|---|---|---|---|
| Small | 3–5 | 33–55 | 3 (P11-I, P13-II, P10) |
| Medium | 6–10 | 66–110 | 9 |
| Large | 10–16 | 110–176 | 3 (P05, P12, P15) — plus P11 and P13 which are Large in total but split |
No project may grow beyond its class ceiling. A Medium project at week 11 is not a diligent Medium project, it is an undeclared Large one, and it is stealing weeks from a project later in the plan that you have not yet met and therefore cannot defend. The enforcement is mechanical: at the class ceiling you stop, ship whatever passes the exit criteria, and write the rest into the extension section of the report. See the two-week stall rule.
How the hour estimates were built
Each figure is the sum of its project's milestones, each estimated independently, then sanity-checked three ways:
- Against the allocation. A project of H hours should show ~0.15H reading, ~0.45H implementation, ~0.20H experiments, ~0.10H writing, ~0.10H debugging. If a project's milestone list implies 0.8H of implementation, the estimate is wrong or the scope is.
- Against known reference implementations. P01 is calibrated against nanoGPT (~300 lines of model code); P04 against the LevelDB paper's component list; P05 against the MIT 6.5840 lab sequence, which takes competent graduate students most of a semester at higher intensity than 11 h/week.
- Against the 4× rule. Boehm's cone of uncertainty says early estimates are routinely off by 4× in either direction. These are not off by 4× because they are milestone-level rather than project-level, but assume ±30% on any individual project and expect the errors to partially cancel across fifteen.
Where these estimates are most likely wrong, stated in advance so you can check:
- P05 (143 h) is the most likely to overrun. Distributed debugging has no floor. The fault injector is scheduled first specifically to bound it.
- P12 (121 h) is the second. If you choose the bootable option and hit a toolchain problem, you can lose two weeks to something that teaches you nothing. Budget a hard "switch to the user-space option" decision at week 3 of the project.
- P08–P10 (176 h combined) are the most likely to underrun, because they are closest to your existing expertise. If you finish early, do not add scope — advance the schedule and bank the weeks against P12.
The Three Scope Tiers
Every project defines three versions. This is the single most important structural device in the plan, because it converts "when am I done?" from a judgement call into a lookup.
Minimum viable implementation (MVI). The smallest artifact that still exhibits the mechanism being studied. Must be genuinely educational on its own: an LSM engine without compaction is an MVI (it still teaches WAL, memtable, SSTable, recovery); an LSM engine that keeps everything in RAM is not, because the mechanism under study is precisely what happens when it does not.
Standard implementation. MVI plus the features that make the project's core experiments possible. This is the target. Exit criteria are written against this tier.
Optional research extension. The interesting thing you will want to do. It is locked until the standard tier passes its exit criteria, and it may be skipped entirely without affecting any later project. Nothing downstream ever depends on an extension — that is what makes it optional in a way that survives contact with a busy quarter.
Worked example — Project 4
| Tier | Contents | Hours |
|---|---|---|
| MVI | WAL + memtable + SSTable write + point read + crash recovery. No compaction, no Bloom filters, no range queries. Teaches durability, the memtable flush, and the read path across immutable files. | 40 |
| Standard | + sparse index, Bloom filters, tombstones, range queries via merged iterators, size-tiered and leveled compaction, checksums. Enables every experiment on the project page. | 99 |
| Extension | Learned index blocks replacing the sparse index; or a compaction scheduler that adapts to measured read/write ratio. Publishable if it works. | +40–60 |
If week 9 arrives and leveled compaction is half-finished, you ship size-tiered only, you write "leveled compaction not implemented; the read-amplification comparison is therefore against published figures rather than my own" in the report, and you move on. That is a completed project with a stated limitation. It is worth far more than a twelfth week.
What Each Project Teaches That No Other Project Teaches
If you ever need to cut, cut by asking which unique lesson you are willing to lose.
| Project | The lesson available nowhere else in this journey |
|---|---|
| P01 | That an architecture is a set of arbitrary-looking choices, each of which is defensible only by measurement |
| P02 | That "approximate" is a quantified contract, and that a better algorithm can lose to a worse one on constant factors |
| P03 | That an index and a database differ by everything that happens after a crash |
| P04 | That you cannot optimise read, write, and space amplification simultaneously — the RUM conjecture, felt rather than read |
| P05 | That the hard part of distributed systems is not consensus, it is that failure is indistinguishable from slowness |
| P06 | That restricting what a programmer may express is what makes automatic fault tolerance possible |
| P07 | That correctness over an unbounded input requires you to define correctness first — watermarks are an admission, not a feature |
| P08 | That an accuracy metric can improve while the product gets worse |
| P09 | That a simulator's value is in its relative rankings, and validating that claim is harder than building the simulator |
| P10 | That statistics is an engineering constraint: your MDE decides whether a feature is even measurable |
| P11 | That every abstraction you use daily is a dispatch loop, a stack frame, and a decision about who frees memory |
| P12 | That the numbers you have been treating as free — a syscall, a page fault, a context switch — have prices you can measure |
| P13 | That autodiff is bookkeeping over a graph, and that framework overhead can exceed the arithmetic it dispatches |
| P14 | That performance is data movement, and that specialised hardware wins by changing the movement, not the arithmetic |
| P15 | That integrating working parts is a different and harder skill than building them |
Language Assignment
Rationale and the full argument in Languages. The summary:
| Language | Projects | The mechanism it exposes |
|---|---|---|
| Python | P01, P13, P08, P09, P10, parts of P02/P03 | Nothing — and that is the point. Python makes the algorithm visible by making everything else uniform, and its slowness makes constant factors impossible to ignore (see the worked notebook entry) |
| Rust | P04, P11, P12, P02's hot loop | Ownership makes lifetime and aliasing explicit, which is precisely what a storage engine, a GC, and a kernel are about. No GC pauses to confound your latency measurements |
| Go | P05, P06, P07 | Goroutines and channels make concurrency cheap enough that you build the real topology instead of a simplified one; the runtime's scheduler and race detector are genuinely good distributed-systems tools |
| C / CUDA | P14, optionally P12 | Explicit memory hierarchy, explicit vectorisation, explicit kernel launch. Nothing between you and the machine |
Four languages across 34 months, each assigned once and not revisited on a whim. You already know Python and Go. Rust is the one real learning cost, and it is paid across P11-I (Small, W16–20) where the project is easy enough that the language is the only hard part — that scheduling is deliberate.
The Deliverable Every Project Produces
Not negotiable, and identical across all fifteen:
- A repository that a stranger can clone and run. One command to build, one to test, one to reproduce the headline benchmark. If it needs a paragraph of setup prose, it is not reproducible.
- A technical report (
REPORT.md), 1,500–4,000 words, followingtemplates/report.md, containing a section titled "What I Expected And Did Not Get". That section is scored and may not be empty. - A research-notebook entry per experiment, following
templates/notebook.md. - A benchmark artifact: the raw sample data (not just summaries), the script that produced it, and the environment block.
- A scorecard self-assessment, twelve categories, with evidence named per score.
The first three take about 10 hours of the project's budget combined. That is the 10% writing allocation, and it is the part that converts a build into a portfolio.
Reading This Track's Project Pages
Every project page has the same eighteen sections, in the same order, so you can jump straight to the one you need:
Why This Project Matters Concepts To Study Expected Difficulties
Prerequisites Primary-Source Readings Scope Boundaries
Duration and Size Experiments Deliverables
Central Technical Questions Benchmarks and Metrics Exit Criteria
Architecture Correctness Tests Extension Ideas
Implementation Milestones Failure Tests Connections
Plus, at the top of each, the fourteen-step loop instantiated for that project — what "naive design" and "predicted failure point" concretely mean for this build. Read that section, then close the page and write your own naive design before reading the architecture section. The architecture section is deliberately placed after the loop for that reason.
References
- Boehm, B. W. Software Engineering Economics. Prentice-Hall, 1981. The cone of uncertainty underlying the ±30% claim.
- Brooks, F. P. No Silver Bullet — Essence and Accident in Software Engineering. IEEE Computer 20(4), 1987. The essential/accidental distinction is what the scope tiers are trying to separate: MVI is essence, everything else is negotiable.
- Athanassoulis, M. et al. Designing Access Methods: The RUM Conjecture. EDBT 2016. The read/update/memory trilemma referenced in P04's unique lesson.
- Ousterhout, J. A Philosophy of Software Design, 2nd ed. Yaknyam Press, 2018. Deep modules and the argument that interface simplicity is worth implementation complexity — the standard against which each project's API is judged.
- Karpathy, A. nanoGPT. github.com/karpathy/nanoGPT, 2022. The calibration reference for P01's size estimate. Read the code after you write yours, not before.
- MIT 6.5840 (formerly 6.824) Distributed Systems lab sequence. The calibration reference for P05.
Audit of the Fifteen
You specified the project list. I designed to it and never asked whether it was the right list — which is a question upstream of every other decision in the track.
This page is that audit: what the fifteen cover, what they redundantly cover, what they do not cover, and which of the gaps actually matter for the stated goal.
Conclusion first: the list is good, with one real redundancy and three real gaps, of which only one is worth acting on now.
Table of Contents
- The Coverage Map
- What Is Covered Twice
- What Is Not Covered At All
- The Three Gaps That Matter
- Against the Stated Goal
- What I Would Change
- What I Would Not Change
- References
The Coverage Map
Thirteen fundamental mechanisms in systems work, against the projects that build them. ● = built by hand. ○ = touched but not built.
| Mechanism | P01 | P02 | P03 | P04 | P05 | P06 | P07 | P08 | P09 | P10 | P11 | P12 | P13 | P14 | P15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Cache / memory hierarchy | ○ | ● | ○ | ● | ● | ○ | ● | ○ | |||||||
| Durability & crash recovery | ● | ● | ● | ○ | ● | ○ | ○ | ||||||||
| Concurrency & synchronisation | ● | ○ | ● | ● | ● | ● | ○ | ○ | |||||||
| Partial failure / consensus | ● | ● | ● | ○ | |||||||||||
| Scheduling | ○ | ○ | ● | ● | ● | ||||||||||
| Approximation with a bound | ● | ● | ● | ● | ● | ○ | ● | ● | ○ | ||||||
| Dispatch & interpretation | ○ | ● | ○ | ● | |||||||||||
| Memory management / GC | ○ | ● | ● | ● | ● | ||||||||||
| Compilation / IR | ● | ● | ○ | ||||||||||||
| Data movement & locality | ○ | ● | ● | ● | ○ | ● | ○ | ● | ● | ● | ○ | ||||
| Statistical inference | ○ | ○ | ● | ● | ● | ● | |||||||||
| Measurement & experiment design | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Protocol / wire format | ○ | ○ | ● | ○ | ○ | ○ |
Every row has at least one ●. Measurement is universal, which is the design intent — it is the one skill the whole track exists to build, and it is trained fifteen times.
Best-covered: data movement (7 projects), durability (4), approximation (6). Those are the right three to over-cover for someone building retrieval and storage systems.
Thinnest: protocol/wire format (1 solid), scheduling (2), compilation (2).
What Is Covered Twice
Redundancy is not automatically waste — repetition in different contexts is how a lesson generalises. But two cases are worth naming.
P06 and P07 share more than they should
Both build: a coordinator, a worker pool, task/operator scheduling, a partitioned shuffle or network partitioner, failure detection and retry, and checkpoint/recovery. P07's page even says the scheduler "generalises" from P06.
| P06 | P07 | Genuinely distinct? | |
|---|---|---|---|
| Coordinator + workers | ● | ● | No |
| Partitioning / shuffle | ● | ● | Mostly no |
| Fault tolerance via re-execution | ● | ○ | — |
| Fault tolerance via checkpointing | ○ | ● | Yes |
| Bounded vs unbounded input | ● | ● | Yes — the real difference |
| Event time, watermarks, windowing | ● | Yes, and it is P07's whole point | |
| Stragglers / speculative execution | ● | Yes, and it is P06's |
The honest reading: ~40% of the two projects overlaps, and the distinct 60% is where each project's value lives. 176 hours for 60% novelty is the weakest hours-per-lesson ratio in the portfolio.
The existing scope cut — build P07 as an extension of P06's runtime, saving 2 weeks — addresses this partially. The stronger version is to make it the default rather than a fallback: build one distributed execution runtime, then run it in two modes. You would save ~4 weeks and lose nothing but the experience of building a second coordinator, which you will have just built.
P03 and P04 both build persistence
P03 builds a naive append-only store; P04 builds a proper LSM. This is deliberate redundancy and it is correct — it is the roadmap's most explicit application of "naive design first", and it costs about six hours of rework on purpose. P03's measured recovery time is what makes P04 feel necessary rather than academic.
Not a defect. Leave it.
What Is Not Covered At All
Nine mechanisms a systems engineer might reasonably be expected to have built, absent from the list.
| Absent | What it would teach | Verdict |
|---|---|---|
| Networking below RPC — TCP state machine, congestion control, a userspace stack | Flow control, retransmission, the actual behaviour under loss that P05 only simulates | Real gap. See below |
| Code generation — SSA, register allocation, native emission | The other half of compilation. P11 stops at a bytecode VM by design | Real gap, deliberately deferred |
| Concurrency primitives from scratch — lock-free queue, MCS lock, epoch reclamation | Memory ordering as something you implement rather than use | Real gap. Smallest and cheapest to close |
| Query processing — parsing, planning, optimisation, execution | The layer above P03/P04's storage | Correctly excluded. A whole second journey |
| Transactions / MVCC — isolation levels, conflict detection | Why serializable is expensive | Partly covered (P03 snapshots, P05 linearizability). Acceptable |
| Compression — entropy coding, dictionary methods | A real column in the RUM trade-off | Mentioned in P04, not built. Minor |
| Security & isolation — capabilities, sandboxing, side channels | A different threat model to P12's basic isolation | Out of scope for the stated goal |
| Observability — tracing, sampling, cardinality | Only appears as P15 instrumentation | You do this professionally. Low marginal value |
| Distributed training — data/tensor/pipeline parallelism | Where ML meets distributed systems | Needs P13 and P05. A strong post-journey project |
The Three Gaps That Matter
Ranked by how much they weaken the stated goal.
1. Concurrency primitives from scratch — the cheapest to close
What is missing. You use atomics and mutexes across P05–P07 and P12, and measure them in numbers §4. You never build one. Memory ordering, ABA, and safe reclamation stay theoretical.
Why it matters here. P12 is single-core by design, so the one project that would naturally force this is scoped to avoid it. That is a defensible scoping decision that leaves a hole.
Cost to close: 2–3 weeks. A Small project: a lock-free MPSC queue, a ticket lock and an MCS lock, benchmarked under contention from 1 to N threads, with the crossover found.
Where it fits. As P12's extension, which already names "synchronisation shoot-out" — promote it from optional to standard. Or as a standalone Small project between P12 and P14.
My recommendation: take P12's extension as standard. It is the highest ratio of lesson to weeks on this page.
2. Networking below RPC
What is missing. P05 assumes a network that drops, delays, duplicates and reorders — correctly, and that is the right abstraction for consensus. But you never build the layer that produces those behaviours: no congestion control, no retransmission, no flow control, no head-of-line blocking.
Why it matters. A large fraction of real distributed-systems debugging is network-layer behaviour leaking through the abstraction — Nagle interacting with delayed ACK, TCP incast collapse, a connection pool exhausting under retry storms. Simulating faults at the message level teaches you what to handle; it does not teach you why they occur.
Cost to close: 4–6 weeks for a minimal reliable protocol over UDP with sliding window, retransmission and one congestion-control algorithm.
Verdict: a real gap, and I would not add it. Six weeks against 130 for a mechanism that is one layer below where your stated goals live. Note it as a limitation and consider it post-journey. If you want a cheap partial: in P05, add a fault-injection mode that models bandwidth and queueing rather than only drop and delay — that surfaces congestion-shaped failures for about two days of work.
3. Code generation
What is missing. P11 goes lexer → parser → AST → bytecode → VM → GC, and stops. SSA, register allocation, instruction selection and native emission are absent, so "how does a compiler produce fast machine code" remains a black box — which is a notable hole next to a journey that measures machine code constantly.
Why it is deliberate. A JIT is a project in itself, P11 is already Large at 132 hours, and the dispatch lesson (bytecode's advantage is cheaper operations, not fewer) is fully delivered without codegen.
Cost to close: 6–10 weeks.
Verdict: correctly deferred. It is the single best post-journey project on this page, and it composes: P11 gives the front end, P14 gives the machine model, and a tracing JIT for your own language is a genuinely impressive artifact.
Against the Stated Goal
You named MapReduce, Spanner, TensorFlow, TPUs, programming languages, OS kernels, databases, distributed systems, vector search, recommenders, and Transformers.
| Named system | Covered by | Fidelity |
|---|---|---|
| MapReduce | P06 | High — same architecture, same failure model |
| Spanner | P05 | Partial — consensus and replication yes; TrueTime and distributed transactions read-only |
| TensorFlow | P13 | High for the autodiff and graph-execution core; no distribution |
| TPUs | P14 | Good for the reasoning — you derive the TOPS figure and simulate the array; no silicon |
| Programming languages | P11 | High to bytecode+GC; no codegen |
| OS kernels | P12 | Good — boot, VM, scheduling, syscalls; single-core, no drivers |
| Databases | P03+P04 | Storage engine only. No query layer, no transactions |
| Distributed systems | P05–P07 | High |
| Vector search | P02+P03 | High — arguably the deepest coverage in the list |
| Recommenders | P08–P10 | High, including the evaluation problem most treatments skip |
| Transformers | P01+P13 | High for the mechanism; no scale |
Ten of eleven are covered at high or good fidelity. The weakest is Spanner, and the missing pieces there (bounded clocks, distributed transactions) are precisely the parts that need infrastructure you cannot have.
The list is well matched to the goal. That is worth saying plainly, because the rest of this page is criticism and the headline finding is that the portfolio is sound.
What I Would Change
Three changes, in order of confidence.
1. Merge P06 and P07 into one runtime, two modes. Saves ~4 weeks, loses ~nothing. Build one coordinator, worker pool and partitioner; run it in batch mode (with speculative execution and the straggler study) and in streaming mode (with watermarks, windowing and checkpointing). The distinct 60% of each project is fully preserved and you stop building the same scheduler twice. Confidence: high.
2. Promote P12's synchronisation shoot-out from extension to standard. Costs ~2 weeks, closes the cheapest real gap, and produces directly transferable knowledge — every concurrent system you tune afterwards benefits. Confidence: high.
3. Add bandwidth and queueing to P05's fault injector. Two days. Surfaces congestion-shaped failures without a six-week networking project. Confidence: medium.
Net: −2 weeks and one gap closed. Both of the first two are consistent with the existing scope-cut machinery rather than requiring a replan.
What I Would Not Change
- The fifteen-project scope. It is ambitious and it is coherent. Cutting further would produce breadth without the depth you asked for.
- P03's deliberate redundancy with P04. The rework is the lesson.
- P12's single-core scoping. SMP multiplies the difficulty of every subsequent bug; the extension in change 2 gets the concurrency lesson without it.
- The absence of a query layer. Storage engines and query processing are two journeys. Yours is the storage one.
- P09 and P10 as separate projects, despite the existing cut that merges them. The A/B platform's statistical content is genuinely distinct from the simulator's modelling content, and it is the part most directly useful in your job next month.
References
- Brooks, F. P. No Silver Bullet — Essence and Accident in Software Engineering. IEEE Computer 20(4), 1987. The essence/accident distinction underlying the coverage map: the mechanisms are the essence; the fifteen projects are one accidental arrangement of them.
- Lampson, B. W. Hints for Computer System Design. SOSP 1983. "Do one thing well" — the argument for merging P06 and P07 rather than building two partial runtimes.
- Ousterhout, J. A Philosophy of Software Design, 2nd ed. Yaknyam Press, 2018. On deep modules, and on the cost of building the same abstraction twice.
- Mellor-Crummey, J. M., Scott, M. L. Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors. ACM TOCS 9(1), 1991. MCS locks — the reference for gap 1.
- Herlihy, M., Shavit, N. The Art of Multiprocessor Programming, 2nd ed. Morgan Kaufmann, 2020. The text for the synchronisation shoot-out.
- Alizadeh, M. et al. Data Center TCP (DCTCP). SIGCOMM 2010. What gap 2 would teach, and why incast is the canonical example.
- Aycock, J. A Brief History of Just-In-Time. ACM Computing Surveys 35(2), 2003. The entry point for gap 3 as a post-journey project.
P01 — Transformer From Scratch
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P01 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 88 hours · Weeks 1–8 · Stage 1 · Python
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
Read this section, then close the page and write your naive design before scrolling to Architecture.
| Step | For this project |
|---|---|
| 1. Problem | Map a sequence of tokens to a distribution over the next token, using context from anywhere in the sequence, in a way that parallelises over sequence positions during training |
| 2. Constraints | One machine, no GPU required (CPU is fine at this scale); training set small enough to overfit deliberately; you must be able to hand-check a 3-token forward pass |
| 3. Naive design | Yours. Most people invent one of: a bag-of-embeddings average, an RNN, or a fixed-window MLP. Write down which and why |
| 4. Predicted failure | Where does your design break? At what sequence length, and what specifically degrades — memory, compute, gradient flow, or the ability to distinguish word order? |
| 5. Minimal implementation | Single head, single layer, 3-token sequence, hand-checkable |
| 6. Correctness | Causal mask leaks nothing; attention rows sum to 1; gradients match finite differences |
| 7. Instrumentation | Per-layer timing, activation memory, tokens/sec, attention entropy |
| 8. Baseline | A bigram model. Genuinely. Its loss is the number you must beat, and it is not as easy as you think |
| 9. Bottleneck | At your chosen context length, is time dominated by the quadratic attention terms or the linear feed-forward ones? Predict, then measure |
| 10. Hypothesis | Pick one from Experiments — the pre-norm/post-norm depth interaction is the best one |
| 11. Modification | The smallest change that tests it |
| 12. Experiment | Fixed seed, fixed data, fixed token budget, one variable |
| 13. Failure analysis | Every divergent run gets diagnosed, not just re-run with a lower learning rate |
| 14. Report | Including the ablation where your intuition was wrong |
Why This Project Matters
Two reasons, and the second is the real one.
The stated reason: the Transformer is the most consequential architecture of the last decade and you use it daily through APIs. Building one converts a black box into a set of decisions you can defend.
The real reason: the Transformer is a pile of arbitrary-looking choices. Why scale by \(1/\sqrt{d_k}\)? Why 4× expansion in the feed-forward layer? Why LayerNorm before the sublayer instead of after, when the original paper did the opposite? Why multiple heads instead of one big one? None of these follow from first principles. Each is defensible only by measurement, and almost nobody who uses Transformers has done those measurements.
This project is where you learn that an architecture is an empirical artifact, and that the way to understand any system full of arbitrary constants is to vary them and watch what happens. That habit transfers to every other project here. The Transformer is a convenient place to acquire it because the feedback loop is minutes, not hours, and correctness is checkable against finite differences.
It is also first because it is motivating. Week 8 of this project ends with a model that writes text. Week 8 of an autodiff framework ends with a class that adds arrays correctly. You are 130 weeks from the end and the first two months decide whether there is a month three.
Prerequisites
- Linear algebra: matrix multiplication, transpose, the fact that \(A B\) composes linear maps. You do not need eigenvalues here.
- The chain rule. You will not implement backprop in this project (that is P13), but you must be able to explain why a residual connection helps gradient flow.
- Python and numpy. PyTorch tensors + autograd are permitted as a tensor library only — see Scope Boundaries.
- From
math.md: §Linear Algebra (2 h), §Softmax and Cross-Entropy (1 h). Nothing else. Do not read the optimization section yet.
Duration and Size
Medium, 88 hours, 8 weeks at 11 h/week.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Character-level tokenizer, learned positional embeddings, single-head causal attention, 2 layers, training loop, greedy decode. Overfits a 200-token corpus to near-zero loss. | 38 |
| Standard | + BPE tokenizer, multi-head attention, pre-norm blocks, RoPE and sinusoidal encodings, temperature/top-k/nucleus sampling, the full experiment suite, KV-cache inference. | 88 |
| Extension | Grouped-query attention, or a from-scratch FlashAttention-style tiled kernel with measured memory reduction, or sliding-window attention with a long-context evaluation. | +30–50 |
Central Technical Questions
- What does attention compute that a fixed-window MLP cannot? Answer in terms of the number of parameters required to express a content-dependent, position-invariant lookup.
- Why divide by \(\sqrt{d_k}\)? Derive it. What happens to the softmax gradient if you do not?
- At what context length does attention stop being a rounding error and start being the bottleneck? For your specific \(d_{model}\). Derive first, then measure.
- What does a positional encoding have to provide, minimally? RoPE, sinusoidal and learned embeddings solve the same problem three ways — what is the problem, stated without reference to any of them?
- Why did pre-norm replace post-norm? And at what depth does the difference start to matter?
- What is the memory cost of inference, and why is it dominated by something other than the weights beyond a certain sequence length?
Architecture
Do not read this until you have written your naive design.
tokens ─► [Tokenizer] ─► ids ─► [Embedding] ─┬─► + positional ─► [Block] × L ─► [Norm] ─► [Unembed] ─► logits
│ │
│ ├─ x + Attn(Norm(x)) ← pre-norm residual
│ └─ x + FFN(Norm(x))
│
(RoPE instead applies rotation inside Attn, to q and k only)
The attention mechanism, derived
You have a sequence of \(T\) vectors \(x_1..x_T\), each in \(\mathbb{R}^{d}\). You want each position to gather information from earlier positions, with the choice of which earlier positions determined by content rather than by fixed offset.
Project each \(x_i\) three ways: a query \(q_i = W_q x_i\) (what am I looking for), a key \(k_i = W_k x_i\) (what do I offer), and a value \(v_i = W_v x_i\) (what I will hand over). Score every pair by dot product, normalise into weights, and take the weighted sum:
\[ \text{Attn}(Q,K,V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}} + M\right)V \]
where \(M_{ij} = -\infty\) for \(j > i\) — the causal mask, which must be applied before the softmax so that masked positions receive exactly zero weight rather than a small one.
Why \(\sqrt{d_k}\). Take \(q, k\) with i.i.d. components of mean 0 and variance
- Then \(q \cdot k = \sum_{i=1}^{d_k} q_i k_i\) is a sum of \(d_k\) independent mean-zero unit-variance terms, so \(\mathrm{Var}(q\cdot k) = d_k\) and the typical magnitude of a score is \(\sqrt{d_k}\). At \(d_k = 64\) scores are typically ±8, and the gap between the largest and second-largest is often several units. Feed that to a softmax and you get a near-one-hot distribution whose gradient is nearly zero everywhere — the softmax saturates and the layer stops learning. Dividing by \(\sqrt{d_k}\) restores unit variance and keeps the softmax in its responsive region. This is not a heuristic; it is a variance calculation you should be able to do at a whiteboard.
Why multiple heads. One head produces one attention distribution per position: it can attend to one thing. Splitting \(d\) into \(H\) heads of size \(d/H\) gives \(H\) independent distributions at the same total parameter and FLOP cost (the concatenation-then-projection makes the arithmetic identical). You are buying representational diversity for free. The cost is that each head sees a \(d/H\) dimensional subspace, so there is a floor below which heads become too small to be useful — one of your experiments.
Cost model, derived before you measure
Let \(B\) = batch, \(T\) = sequence length, \(H\) = heads, \(d_h\) = head dim, \(d = H d_h\). Per layer, forward pass, counting 2 FLOPs per multiply-accumulate:
| Component | FLOPs | Scaling |
|---|---|---|
| Q, K, V projections | \(3 \cdot 2 B T d^2\) | linear in \(T\) |
| \(QK^\top\) | \(2 B H T^2 d_h\) | quadratic in \(T\) |
| \(\text{scores} \cdot V\) | \(2 B H T^2 d_h\) | quadratic in \(T\) |
| Output projection | \(2 B T d^2\) | linear |
| Feed-forward (4× expansion) | \(2 \cdot 2 B T d \cdot 4d = 16 B T d^2\) | linear |
Everyone "knows" attention is quadratic. Almost nobody knows where the crossover is. Computed for \(B{=}1, H{=}12, d_h{=}64\) (so \(d{=}768\), GPT-2 small shape):
| T | attention FLOPs | FFN FLOPs | quadratic share of total | score matrix, fp32 |
|---|---|---|---|---|
| 128 | 0.65 G | 1.21 G | 2.7% | 0.8 MB |
| 512 | 3.22 G | 4.83 G | 10.0% | 12.6 MB |
| 1024 | 8.05 G | 9.66 G | 18.2% | 50.3 MB |
| 2048 | 22.55 G | 19.33 G | 30.8% | 201.3 MB |
| 4096 | 70.87 G | 38.65 G | 47.1% | 805.3 MB |
| 8192 | 244.81 G | 77.31 G | 64.0% | 3221.2 MB |
Two things fall out that are worth more than the table itself:
- At GPT-2's original context of 1024, attention is 18% of the compute. The quadratic term does not dominate until ~4096. Every "attention is the bottleneck" claim is context-length-dependent and most of them are wrong for the context they are said about.
- Memory hits the wall long before FLOPs do. The materialised score matrix is 3.2 GB at T=8192 for a single batch element in fp32. This is the entire reason FlashAttention exists: it never materialises that matrix, computing softmax in tiles with an online-normalisation trick. The compute is unchanged; the memory traffic is what is optimised.
Reproduce this table for your own \(d_{model}\) as milestone 6. Predict the crossover before you compute it.
RoPE, and why it is different in kind
Learned and sinusoidal encodings add a position-dependent vector to the token embedding. RoPE rotates the query and key vectors by an angle proportional to position, in \(d/2\) independent 2-D planes:
\[ \theta_i = \frac{m}{\text{base}^{2i/d}}, \qquad \begin{pmatrix} x^{\prime}_{2i} \\ x^{\prime}_{2i+1} \end{pmatrix} = \begin{pmatrix} \cos\theta_i & -\sin\theta_i \\ \sin\theta_i & \cos\theta_i \end{pmatrix} \begin{pmatrix} x_{2i} \\ x_{2i+1} \end{pmatrix} \]
The property that makes it work: because a rotation by \(m\) followed by the inverse of a rotation by \(n\) is a rotation by \(m-n\), the dot product of a rotated query at position \(m\) with a rotated key at position \(n\) depends only on \(m-n\). Attention becomes relative-position-aware without any explicit relative-position term.
Verify this numerically before you trust it — measured with a 64-dimensional random vector pair:
m n m-n dot
5 3 2 -8.115791933
105 103 2 -8.115791933
7 5 2 -8.115791933
1000 998 2 -8.115791933
5 4 1 -8.519139825
50 49 1 -8.519139825
Identical to nine decimal places across a 200× range of absolute positions. And because rotations are orthogonal, norms are preserved exactly: \(|q| = 7.315343549\), \(|\text{RoPE}(q,17)| = 7.315343549\). Write this test before you write the implementation. It catches the two overwhelmingly common RoPE bugs — pairing dimensions as \((i, i+d/2)\) versus \((2i, 2i+1)\) without adjusting the frequency indexing, and applying the rotation to values as well as to queries and keys.
Showcase — Do This Before You Start
W1 · walkthroughs/w1_attention.py · ~45 minutes
A working miniature of this project: the leak test, what the \(1/\sqrt{d_k}\) scale buys measured as attention entropy, and the sequence length where the quadratic term actually starts to matter.
cd walkthroughs && python3 w1_attention.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo, bench.py adapted, character tokenizer, data loader with train/val split | 6 | pytest runs; a batch of shape (B,T) comes out with correct dtypes |
| 2 | Bigram baseline: embedding → logits, trained | 5 | Val loss recorded. This is the number to beat |
| 3 | Single attention head, 3-token hand-checked forward pass | 10 | Your hand-computed softmax weights match the code to 1e-6 |
| 4 | Causal masking + the leak test | 4 | Changing token \(t{+}1\) provably cannot change the logits at \(t\) |
| 5 | Multi-head + output projection + FFN + residual + pre-norm block | 12 | Overfits 200 tokens to loss < 0.1 |
| 6 | Cost model: reproduce the FLOP/memory table for your config | 4 | Your predicted crossover is written down before the measurement |
| 7 | Full training loop: AdamW, warmup + cosine schedule, gradient clipping, checkpointing | 10 | Beats bigram on val loss by a stated margin |
| 8 | BPE tokenizer (byte-level, trained on your corpus) | 8 | Round-trips arbitrary UTF-8; compression ratio vs characters recorded |
| 9 | Three positional encodings behind one interface: learned, sinusoidal, RoPE | 10 | RoPE relative-position test passes to 1e-6 |
| 10 | Sampling: greedy, temperature, top-k, nucleus | 5 | Distribution shifts measurably and in the predicted direction |
| 11 | KV-cache inference + latency instrumentation | 8 | Per-token latency flat in generated length; cache memory measured |
| 12 | Experiment suite + report | 6 | All rows in Experiments have numbers |
Concepts To Study
Limited to what the milestones need. Anything not on this list is not yet.
- Tokenization: byte-level BPE, the merge algorithm, why byte-level avoids OOV entirely, vocabulary size vs sequence length as a direct trade
- Embeddings: as a lookup table that is also a linear layer; weight tying between embedding and unembedding and what it saves
- Softmax: numerical stability via max-subtraction, the Jacobian, saturation
- Cross-entropy: as negative log-likelihood, and why loss in nats converts to perplexity by \(e^{\text{loss}}\)
- LayerNorm vs RMSNorm: what each normalises, and why RMSNorm dropping the mean subtraction costs almost nothing
- Residual connections: as an identity path for gradients; why the residual stream is better thought of as a shared bus than as a shortcut
- Pre-norm vs post-norm: where the norm sits relative to the residual add, and the effect on gradient magnitude at depth
- AdamW: first and second moment estimates, bias correction, why decoupled weight decay differs from L2
- Learning-rate schedules: warmup as a remedy for early-training instability in adaptive optimizers
- KV caching: what is recomputed without it, and the memory it costs
- Sampling: temperature as logit scaling, top-k and nucleus as truncation strategies
Primary-Source Readings
Total budget: 13 hours. Read section 3 of Vaswani before milestone 3, RoFormer before milestone 9, and nothing else until the milestone that needs it.
| Reading | Why | Hours |
|---|---|---|
| Vaswani, A. et al. Attention Is All You Need. NeurIPS 2017 | The source. Read §3 closely; note that it is post-norm and that this was later reversed | 3 |
| Su, J. et al. RoFormer: Enhanced Transformer with Rotary Position Embedding. arXiv:2104.09864, 2021 | RoPE. §3.2 for the derivation of the relative-position property | 2 |
| Xiong, R. et al. On Layer Normalization in the Transformer Architecture. ICML 2020 | Why pre-norm won: gradient magnitude analysis at initialisation | 2 |
| Sennrich, R. et al. Neural Machine Translation of Rare Words with Subword Units. ACL 2016 | BPE | 1 |
| Radford, A. et al. Language Models are Unsupervised Multitask Learners. OpenAI, 2019 | GPT-2. Read for the architecture table and the scaling choices | 1.5 |
| Dao, T. et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022 | Read §2–3 only. The point is IO-awareness, which is the same lesson as P14 | 2 |
| Loshchilov, I., Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019 | AdamW, and why L2 and weight decay are not the same under Adam | 1.5 |
Deliberately not on this list: scaling laws, MoE, RLHF, quantization, distributed training. See Not Yet.
Experiments
Every row: fixed seed, fixed data, fixed token budget, one variable. Record the prediction before the run.
| # | Experiment | Variable | Hold fixed | Predict before running |
|---|---|---|---|---|
| E1 | Head count | H ∈ {1,2,4,8,12} | \(d\), params, tokens | Where does the small-head floor appear? |
| E2 | Embedding dim | \(d\) ∈ {64,128,256,512} | H=4, depth, tokens | Loss vs \(d\): what shape, and why? |
| E3 | Context length | T ∈ {32,128,512,1024} | params, tokens | Where does val loss stop improving? |
| E4 | Positional encoding | learned / sinusoidal / RoPE / none | everything | The "none" arm is the control and it is essential |
| E5 | Length extrapolation | train at T=256, eval at T ∈ {256,512,1024} | encoding type | Which encoding degrades least? |
| E6 | Pre-norm vs post-norm | norm placement × depth ∈ {2,4,8,16} | everything | An interaction: predict the depth at which they diverge |
| E7 | Optimizer / LR | AdamW vs SGD+momentum; LR ∈ {1e-4,3e-4,1e-3,3e-3} | everything | Where does the LR sweep diverge? |
| E8 | Warmup | 0 / 100 / 500 steps | LR at 3e-3 | Warmup should matter more at high LR |
| E9 | Depth scaling | L ∈ {1,2,4,8} at fixed total params | tokens | Deep-narrow vs shallow-wide |
| E10 | Deliberate overfit | 200-token corpus, no regularisation | — | Loss → ~0. If it does not, you have a bug, not a research finding |
| E11 | Inference latency | with/without KV cache, gen length ∈ {16..512} | model | Without cache: quadratic. With: flat |
| E12 | Inference memory | KV cache bytes vs T and batch | model | Derive first: \(2 \cdot L \cdot T \cdot d \cdot \text{bytes}\) per sequence |
| E13 | Attention entropy by layer | — | trained model | Do early layers attend broadly and late layers sharply? |
E10 is not optional and it is not a formality. A model that cannot overfit 200 tokens has a bug — usually a mask applied after the softmax, a detached gradient, or a data loader that reshuffles the target. Run it at milestone 5 and again any time anything looks strange. It is the cheapest bug detector in the project.
E6 is the best hypothesis in this project. Pre-norm and post-norm perform similarly at depth 2 and diverge sharply somewhere deeper. Predicting where — and explaining it via gradient magnitude at initialisation — is a genuine result you derived.
Benchmarks and Metrics
| Metric | How | Why it is here |
|---|---|---|
| Val loss (nats/token) and perplexity | held-out split, fixed token budget | The primary quality number |
| Tokens/second, training | bench.py, p50/p95 | Throughput |
| Time per training step, by component | manual timers around attention / FFN / optimizer | Tells you where the 88 hours of compute went |
| Peak activation memory | tracemalloc or torch.cuda.max_memory_allocated | The number that actually limits your context length |
| Inference latency per token, p50/p95/p99 | bench.py | Tails matter even at batch 1 |
| KV cache bytes | derived, then measured | Derivation and measurement must agree within 5% |
| FLOPs/token | derived from the cost model | Lets you compute MFU and compare across configs |
| Attention entropy per head per layer | \(-\sum p \log p\) over each attention row | Diagnostic: near-zero entropy means a collapsed head |
| Tokenizer compression | bytes per token on held-out text | Directly trades against context length |
Report every latency as p50/p95/p99 with a bootstrap CI on the median, using
tools/bench.py. A mean latency in this project's report is a
scorecard deduction.
Correctness Tests
These are properties, not examples. Property tests catch the bugs example tests miss.
- Hand-computed forward pass. 3 tokens, \(d{=}4\), one head, weights set by hand. Your attention weights match your arithmetic to 1e-6. Do this on paper first.
- Causal mask leak test. Run the model on a sequence. Perturb token \(t{+}1\). Assert the logits at positions \(\le t\) are bit-identical. This catches masking after softmax, off-by-one in the mask, and any accidental bidirectionality.
- Attention rows sum to 1. For every head, every position, every batch element, \(|\sum_j a_{ij} - 1| < 10^{-5}\).
- Gradient check. Finite differences against autograd on a tiny model: \(|\nabla_{\text{analytic}} - \nabla_{\text{numeric}}| / (|\nabla| + \epsilon) < 10^{-4}\) for every parameter tensor. (In P13 you will implement the analytic side yourself and run this same test against PyTorch.)
- RoPE relative-position invariance. As measured above: for all \((m,n)\) with the same \(m-n\), the rotated dot product is equal to 1e-6.
- RoPE norm preservation. \(||\text{RoPE}(x,m)|| = ||x||\) to 1e-6, all \(m\).
- Tokenizer round-trip.
decode(encode(s)) == sfor a corpus including emoji, CJK, and lone surrogate byte sequences. - Softmax stability. Logits of \(10^4\) produce no NaN.
- Shape and dtype invariants on every tensor crossing a module boundary.
- Determinism. Same seed → bit-identical loss curve. If not, find the source before running any experiment; nondeterminism silently invalidates every ablation.
Failure Tests
Deliberately break it and confirm it breaks the way you predict.
| Injection | Predicted symptom | What it teaches |
|---|---|---|
| Remove the \(1/\sqrt{d_k}\) scale | Attention entropy collapses; loss plateaus high | The variance argument, felt |
| Remove the causal mask | Val loss drops below what should be achievable | What label leakage looks like from the outside |
| Remove positional encoding entirely | Model learns unigram statistics only | Attention alone is permutation-equivariant |
| Remove residual connections at depth 8 | Gradient norm at layer 1 collapses; no learning | Why residuals are about gradients, not capacity |
| LR 100× too high | Loss → NaN. Find the first NaN's layer | How to debug divergence rather than lower the LR |
| Train on shuffled targets | Loss plateaus at \(\ln(\text{vocab})\) | The entropy floor — memorise this number |
| fp16 without loss scaling | Gradient underflow, silent stall | Why mixed precision needs scaling |
The shuffled-target test deserves emphasis: a model trained on random labels should converge to exactly \(\ln(V)\) nats. If it goes below, you have leakage. If it goes far above, you have an optimizer problem. It is a two-line experiment that calibrates your entire loss intuition.
Expected Difficulties
Named in advance so that hitting one is a checkpoint, not a crisis.
- Shape bugs will consume more time than concepts.
(B,T,H,d_h)vs(B,H,T,d_h)transposes are the single biggest time sink. Mitigation: annotate every tensor's shape in a comment at creation, and assert shapes at every module boundary. This feels excessive for about two days and then saves a week. - A subtly wrong mask trains fine and scores impossibly well. Mitigation: test 2 above, written before the mask.
- You will want to add features instead of running experiments. Adding grouped-query attention is more fun than running a 5-point head-count sweep. The sweep is the project.
- CPU training is slow enough to discourage sweeps. Mitigation: size the model so one training run is under 10 minutes. Every experiment here is about relative comparison; absolute quality is irrelevant.
- RoPE has two incompatible conventions in the wild (interleaved pairs vs split halves). Both are correct if applied consistently; mixing them within one model produces a model that trains but extrapolates badly. Test 5 catches it.
- The bigram baseline is better than you expect and beating it by a little feels like failure. It is not — write down the bigram loss and the entropy floor first so you know what the achievable range even is.
Scope Boundaries
In scope: everything in the milestone list, on CPU, at a scale where a training run takes under 10 minutes.
Explicitly out of scope — if you find yourself doing one of these, stop:
- Multi-GPU or distributed training of any kind
- Training on a large corpus, or chasing an absolute quality number
- Implementing your own autograd — that is P13, and doing it here means doing two Large projects at once
- Fine-tuning, instruction tuning, RLHF, LoRA
- Serving infrastructure, batching servers, or an API
- Model architectures other than a decoder-only Transformer
- Writing CUDA kernels — that is P14
The permitted-library line. PyTorch is allowed for: tensor storage, elementwise
ops, matmul, autograd, and the optimizer. PyTorch is forbidden for:
nn.Transformer, nn.TransformerEncoderLayer, nn.MultiheadAttention,
F.scaled_dot_product_attention, and any positional-encoding utility. The mechanism
under study is attention; you may not import it. See
AI Policy Rule 7.
Deliverables
transformer/repository — one command to train, one to sample, one to reproduce every experimentREPORT.md, 2,000–3,000 words, with the E4/E6 ablation tables and a section titled "What I Expected And Did Not Get"notebook/entries for E4, E6, and E11 at minimumcost_model.pyreproducing the FLOP/memory table for arbitrary configs- A generated-text sample at three temperatures, with the tokenizer's compression ratio stated
- Raw benchmark JSON for every experiment, not just the summary tables
Exit Criteria
All eight, no exceptions.
- Model overfits a 200-token corpus to val loss < 0.1 (E10)
- Beats the bigram baseline on held-out val loss by a stated, measured margin
- All ten correctness tests pass, including the causal-leak and RoPE invariance tests
- E4 complete: all four positional encodings including the none control, with a table
- E6 complete: pre-norm vs post-norm across four depths, with the divergence depth identified and an explanation offered
- E11/E12 complete: KV-cache latency is flat in generated length, and derived cache memory matches measured within 5%
- At least one hypothesis tested with a stated falsifier, and the outcome recorded whichever way it went
-
REPORT.mdwritten, including at least one result that contradicted your prediction
Extension Ideas
Locked until every box above is ticked.
- Grouped-query attention: share K/V across head groups. Measure the KV-cache reduction and the quality cost. Directly relevant to your day job's serving costs.
- Tiled attention (FlashAttention-style): implement online softmax normalisation and never materialise the score matrix. Measure peak memory vs T against the table above. This is the best bridge to P14.
- Sliding-window attention with a long-context eval, measuring the quality/compute frontier.
- Speculative decoding with a small draft model. Measure acceptance rate and end-to-end speedup; note that acceptance rate is the interesting quantity.
- RoPE base sweep (\(\text{base} \in \{10^3, 10^4, 10^5\}\)) and its effect on length extrapolation — a small, real, publishable-adjacent experiment.
Connections
Backward: none. This is where the journey starts.
Forward:
- → P13 (Autodiff): the exit criterion for P13 Phase I is reproducing this model's gradients to 1e-5. Keep the model code stable and tagged so P13 has a fixed target.
- → P14 (Hardware-aware): the FLOP/memory table here is the input to P14's roofline analysis. Your inference latency measurements become the "before" number.
- → P08 (Recommender): you will need embeddings, and having built the thing that produces them changes how you reason about their geometry.
- → P02 (ANN): the embeddings from this model are a legitimate test dataset for your index — and unlike random vectors, they have realistic relative contrast.
- → P15: dynamic embedding generation is one of the candidate research questions, and it depends on your being able to reason about the cost of a forward pass.
References
- Vaswani, A., Shazeer, N., Parmar, N., Uszkoreit, J., Jones, L., Gomez, A. N., Kaiser, Ł., Polosukhin, I. Attention Is All You Need. NeurIPS 2017.
- Su, J., Lu, Y., Pan, S., Murtadha, A., Wen, B., Liu, Y. RoFormer: Enhanced Transformer with Rotary Position Embedding. arXiv:2104.09864, 2021.
- Xiong, R. et al. On Layer Normalization in the Transformer Architecture. ICML 2020.
- Dao, T., Fu, D. Y., Ermon, S., Rudra, A., Ré, C. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022.
- Sennrich, R., Haddow, B., Birch, A. Neural Machine Translation of Rare Words with Subword Units. ACL 2016.
- Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., Sutskever, I. Language Models are Unsupervised Multitask Learners. OpenAI technical report, 2019.
- Loshchilov, I., Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019.
- Zhang, B., Sennrich, R. Root Mean Square Layer Normalization. NeurIPS 2019.
- Ba, J. L., Kiros, J. R., Hinton, G. E. Layer Normalization. arXiv:1607.06450, 2016.
- Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150, 2019. Multi-query attention; the ancestor of GQA.
- Elhage, N. et al. A Mathematical Framework for Transformer Circuits. Anthropic, 2021. The residual-stream view referenced under Concepts.
- Karpathy, A. nanoGPT and Let's build GPT: from scratch, in code, spelled out. 2022–2023. Read after your milestone 7, as a check on your choices rather than a source for them.
P01 hands-on — Transformer, block by block
Attention from a dot product, then a language model that trains.
Source:
handson/h01_transformer.py--- run it withpython3 handson/h01_transformer.py
Full project spec: P01 — Transformer From Scratch
Every line of this page runs. The file builds a decoder-only transformer out of nine independent pieces, each of which proves one mechanism on its own before anything is stacked, and then trains the assembled model on a real corpus until the loss falls to 0.11 nats.
The order is deliberate. Attention is introduced as a weighted average whose weights are computed from the data, which is all it is, and only then do the scaling factor, the causal mask, and multiple heads get added --- each one motivated by a failure you can see in the numbers of the block before it. The same discipline applies to the training loop: the overfit test in the assembly exists because a language model that cannot memorise sixty tokens has a bug that no amount of hyperparameter tuning will fix.
Read the outputs, not just the code. Block 3 shows what the sqrt(d_k)
denominator is actually protecting you from, and it is not obvious from the
formula.
Contents
- Block 1 — Tokenizer
- Block 2 — Batching
- Block 3 — Softmax + cross-entropy
- Block 4 — One attention head
- Block 5 — The leak test
- Block 6 — Multi-head
- Block 7 — The pre-norm block
- Block 8 — The model
- Block 9 — The same attention, in torch
- The assembly
- The design space
- Latency, bandwidth and the memory hierarchy
- Hardware: CPU, GPU, TPU
- Advanced algorithms and alternatives
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Tokenizer
Teaches: a vocabulary is a bijection, and it must round-trip
The problem. Before a model can learn anything, text has to become integers, and the mapping has to be exactly invertible. A tokenizer that does not round-trip introduces a silent error floor that no amount of training can cross, and it will look like a modelling problem.
@block(1, "Tokenizer", "a vocabulary is a bijection, and it must round-trip")
def b1(s, show):
class CharTokenizer:
def __init__(self, text):
self.vocab = sorted(set(text))
self.stoi = {c: i for i, c in enumerate(self.vocab)}
self.itos = {i: c for c, i in self.stoi.items()}
@property
def size(self): return len(self.vocab)
def encode(self, t): return [self.stoi[c] for c in t]
def decode(self, ids): return "".join(self.itos[i] for i in ids)
tok = CharTokenizer(CORPUS)
assert tok.decode(tok.encode(CORPUS)) == CORPUS, "round-trip failed"
if show:
print(f" vocab size {tok.size}: {''.join(tok.vocab)!r}")
print(f" round-trip on {len(CORPUS)} chars: OK")
print(f" compression: 1.00 bytes/token (the baseline BPE must beat)")
return {"tok": tok, "data": np.array(tok.encode(CORPUS), dtype=np.int64)}
# ─────────────────────────────────────────────────────────── block 2
Reading the implementation
Character-level tokenisation is chosen here because it is a bijection by
construction — every character maps to exactly one id and back — which removes
an entire class of bug from the rest of the project. The assertion that
decode(encode(text)) == text is not ceremony; it is the property everything
downstream assumes.
The cost is sequence length. Character-level means ~4--5× more tokens than subword for English, and since attention is \(O(T^2)\) in memory that is a 16--25× increase in the score matrix. This is the trade the tokenizer makes and the reason production models use BPE.
What the numbers say
Output:
vocab size 28: ' .abcdefghijklmnopqrstuvwxyz'
round-trip on 3720 chars: OK
compression: 1.00 bytes/token (the baseline BPE must beat)
Beyond the toy
Byte-pair encoding builds a vocabulary by repeatedly merging the most frequent adjacent pair, starting from bytes. The consequences are worth knowing precisely because they leak into model behaviour:
- Byte-level BPE (GPT-2 onward) never has an out-of-vocabulary token, because the base alphabet is all 256 bytes. That is why these models handle emoji, arbitrary Unicode, and binary garbage without a special case.
- Tokenisation is not language-neutral. The same sentence costs ~1.5--3× more tokens in Hindi or Thai than in English with a typical English-dominant vocabulary, which is a direct cost and context-length penalty for those users.
- Digit and whitespace handling explains a startling amount of model behaviour. If "1234" tokenises as "12"+"34", arithmetic becomes positional string manipulation, which is why many models are erratic at multi-digit arithmetic and why later models force digit-by-digit splits.
- The SolidGoldMagikarp class of bug: tokens present in the tokenizer's training corpus but effectively absent from the model's produce untrained embeddings and bizarre generations. A vocabulary is a joint artefact of the tokenizer and the training data, and a mismatch between them is a real failure mode.
Block 2 — Batching
Teaches: a language model predicts the NEXT token: y is x shifted by one
The problem. "Predict the next token" has to become a tensor operation. The shift-by-one construction is three lines and is the entire supervision signal — and an off-by-one here produces a model that either cheats or learns nothing.
@block(2, "Batching", "a language model predicts the NEXT token: y is x shifted by one")
def b2(s, show):
def get_batch(data, B, T, rng):
ix = rng.integers(0, len(data) - T - 1, size=B)
x = np.stack([data[i:i + T] for i in ix])
y = np.stack([data[i + 1:i + T + 1] for i in ix])
return x, y
x, y = get_batch(s["data"], 4, 8, rng)
assert (x[:, 1:] == y[:, :-1]).all(), "y must be x shifted by one"
if show:
print(f" x {x.shape} y {y.shape}")
print(f" x[0] = {s['tok'].decode(x[0])!r}")
print(f" y[0] = {s['tok'].decode(y[0])!r} <- shifted by one")
return {"get_batch": get_batch}
# ─────────────────────────────────────────────────────────── block 3
Reading the implementation
y is x shifted left by one, so position \(t\) in the input predicts position
\(t+1\). Every position in the sequence is a training example, which is what
makes language modelling so sample-efficient: a batch of 16 sequences of 64 tokens
yields 1024 supervised predictions, not 16.
This is also where the causal mask becomes non-negotiable. Because the targets are inside the same sequence as the inputs, an attention pattern that can see forward gives the model the answer. Block 5 exists specifically to test that it cannot.
What the numbers say
Output:
x (4, 8) y (4, 8)
x[0] = 'uns . th'
y[0] = 'ns . the' <- shifted by one
Beyond the toy
- Packing. Real training concatenates documents and cuts fixed-length windows, so no compute is wasted on padding. The subtlety is that a window then spans a document boundary, and the model learns spurious continuations unless the attention mask is reset at boundaries — a detail many implementations get wrong and few notice.
- Sequence length is a curriculum variable. Training at short context and extending later (position interpolation, YaRN, NTK-aware scaling) is far cheaper than training long from scratch, because of the \(T^2\) memory term.
- Batch shape drives hardware efficiency. Ragged batches waste compute proportional to the length variance; length-bucketed sampling recovers it. At scale this is a double-digit percentage of total training cost.
Block 3 — Softmax + cross-entropy
Teaches: the entropy floor tells you what 'learning nothing' looks like
The problem. You cannot tell whether a loss of 3.3 is good without knowing what "learning nothing" scores. The entropy floor is that reference, and computing it takes one line — after which every training curve is interpretable.
@block(3, "Softmax + cross-entropy", "the entropy floor tells you what 'learning nothing' looks like")
def b3(s, show):
def softmax(z, axis=-1):
z = z - z.max(axis=axis, keepdims=True) # stability: never exp a big number
e = np.exp(z)
return e / e.sum(axis=axis, keepdims=True)
def cross_entropy(logits, targets):
p = softmax(logits)
n = np.prod(targets.shape)
flat = p.reshape(-1, p.shape[-1])[np.arange(n), targets.reshape(-1)]
return float(-np.log(flat + 1e-12).mean())
V = s["tok"].size
uniform = np.zeros((2, 5, V))
floor = cross_entropy(uniform, rng.integers(0, V, (2, 5)))
if show:
print(f" vocab {V}, uniform logits -> loss {floor:.4f} nats")
print(f" ln({V}) = {np.log(V):.4f} <- the ENTROPY FLOOR")
print(f" a model below this on shuffled targets has label leakage")
print(f" huge logits (1e4) -> {cross_entropy(np.full((1,1,V),1e4), np.zeros((1,1),int)):.4f}, no NaN")
return {"softmax": softmax, "cross_entropy": cross_entropy, "floor": float(np.log(V))}
# ─────────────────────────────────────────────────────────── block 4
Reading the implementation
Cross-entropy in nats is \(-\frac{1}{n}\sum \log p(y_i)\), and the two reference points that make it readable are:
- Uniform guessing over \(V\) tokens: \(\log V\) nats.
- The unigram entropy of the corpus: what a model that learns only token frequencies achieves.
The subtraction \(\log V - H(\text{unigram})\) is exactly the information a frequency table carries, and it is usually a large fraction of the first improvement any language model shows.
The numerical detail: logsumexp with the max subtracted. Computing
\(\log \sum e^{x_i}\) naively overflows for \(x > 709\) in float64 and \(x >
88\) in float32. Subtracting \(\max_i x_i\) first is mathematically identity and
numerically essential — and it is the same trick FlashAttention's online softmax
generalises to a streaming setting.
What the numbers say
Output:
vocab 28, uniform logits -> loss 3.3322 nats
ln(28) = 3.3322 <- the ENTROPY FLOOR
a model below this on shuffled targets has label leakage
huge logits (1e4) -> 3.3322, no NaN
Beyond the toy
Perplexity is \(e^{\text{loss}}\) and is the more interpretable unit: "the model is as uncertain as if choosing uniformly among \(e^{\text{loss}}\) tokens". Three cautions that make published perplexities incomparable:
- Perplexity depends on the tokenizer. A character-level model and a BPE model on the same text have entirely different perplexities. Only bits-per-byte is comparable across tokenizations.
- A loss below the entropy floor is a bug, not a triumph — it means the model saw the answer. That is the leak test in block 5, and having the floor computed here is what makes the test possible.
- Loss is averaged over positions, so a model that is excellent at position 1 and useless at position 64 looks mediocre everywhere. Per-position loss curves are the diagnostic, and they are how you discover that a model has learned nothing beyond its first few tokens of context.
Block 4 — One attention head
Teaches: content-addressed lookup, and why we divide by sqrt(dk)
The problem. Attention is usually introduced as a formula. It is more usefully introduced as a weighted average whose weights are computed from the data — and once framed that way, every part of the formula has a job you can see.
@block(4, "One attention head", "content-addressed lookup, and why we divide by sqrt(dk)")
def b4(s, show):
softmax = s["softmax"]
def head(X, Wq, Wk, Wv, causal=True):
Q, K, V = X @ Wq, X @ Wk, X @ Wv
scores = Q @ K.transpose(0, 2, 1) / np.sqrt(Q.shape[-1])
if causal:
T = X.shape[1]
mask = np.tril(np.ones((T, T), bool))
scores = np.where(mask, scores, -np.inf) # BEFORE the softmax
A = softmax(scores)
return A @ V, A
T, d = 6, 16
X = rng.standard_normal((1, T, d))
W = [rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(3)]
Y, A = head(X, *W)
assert np.allclose(A.sum(-1), 1.0), "rows must be a distribution"
assert np.triu(A[0], 1).max() == 0.0, "no attention to the future"
if show:
print(f" X {X.shape} -> Y {Y.shape}, attention {A.shape}")
print(f" rows sum to 1: max err {abs(A.sum(-1)-1).max():.2e}")
print(f" upper triangle exactly zero: {np.triu(A[0],1).max():.1f}")
ent = lambda a: float(-(a*np.log(a+1e-30)).sum(-1).mean())
_, Au = head(X, *W); su = softmax((X@W[0]) @ (X@W[1]).transpose(0,2,1))
print(f" entropy scaled {ent(A):.3f} vs unscaled {ent(su):.3f} (max {np.log(T):.3f})")
return {"head": head}
# ─────────────────────────────────────────────────────────── block 5
Reading the implementation
\[ \text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V \]
Read it right to left. The output is \(\text{weights} \times V\) — an average of
value vectors. The weights come from softmax(scores), so they are non-negative
and sum to one. The scores are dot products between queries and keys, so the
weights are large where the query and key agree. That is content-addressed
lookup: a soft dictionary where the match is a similarity rather than an equality.
The scaling factor is the part worth deriving rather than memorising. If \(q\) and \(k\) have independent components with unit variance, then
\[ \mathrm{Var}(q!\cdot!k) = \sum_{i=1}^{d_k}\mathrm{Var}(q_ik_i) = d_k \]
so the scores have standard deviation \(\sqrt{d_k}\) — at \(d_k=64\), scores spread over ±8 before any training. Softmax of values that spread saturates: one weight goes to ~1, the rest to ~0, and the gradient through softmax (\(\text{diag}(p) - pp^{\top}\)) goes to zero. The model cannot learn because its attention is already maximally confident and wrong. Dividing by \(\sqrt{d_k}\) restores unit variance and keeps the distribution in the region where gradients exist (proofs.md P1).
What the numbers say
Output:
X (1, 6, 16) -> Y (1, 6, 16), attention (1, 6, 6)
rows sum to 1: max err 2.22e-16
upper triangle exactly zero: 0.0
entropy scaled 0.878 vs unscaled 0.124 (max 1.792)
The entropy column is the thing to watch: unscaled attention starts near- deterministic, scaled attention starts near-uniform. That difference is the difference between a model that trains and one that does not.
Beyond the toy
- Q, K, V are three linear projections of the same input in self-attention. Nothing requires that — cross-attention takes K and V from a different sequence, which is how encoder-decoder models and retrieval-augmented architectures work.
- The score matrix is the memory problem. \(T \times T\) per head; at \(T\)=8192 and 32 heads in fp16 that is 4 GB for one layer's scores. Never materialising it is FlashAttention's entire contribution, achieved by tiling into SRAM and using an online softmax that keeps a running max and normaliser.
- Attention is permutation-equivariant — it has no notion of order at all. Every positional scheme (learned, sinusoidal, RoPE, ALiBi) exists to repair that, and their differences are entirely about how they extrapolate beyond trained lengths.
- The softmax bottleneck. Because weights are non-negative and sum to one, attention can only produce outputs in the convex hull of the value vectors. That constraint is a real limit on expressiveness and motivates variants with gating or negative weights.
Block 5 — The leak test
Teaches: the single most valuable test in the project
The problem. The most valuable test in this project takes four lines and catches the failure that looks most like success. A causal model that can see the future achieves a loss below the entropy floor, produces beautiful training curves, and generates nothing.
@block(5, "The leak test", "the single most valuable test in the project")
def b5(s, show):
head = s["head"]
T, d = 10, 16
X = rng.standard_normal((1, T, d))
W = [rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(3)]
Y1, _ = head(X, *W)
X2 = X.copy(); X2[:, 5:] = rng.standard_normal((1, T - 5, d))
Y2, _ = head(X2, *W)
leaked = not np.array_equal(Y1[:, :5], Y2[:, :5])
assert not leaked, "CAUSAL LEAK"
if show:
print(" scrambled positions 5..9, then compared positions 0..4")
print(f" bit-identical: {np.array_equal(Y1[:,:5], Y2[:,:5])} (leak: {leaked})")
print(" catches: mask after softmax, off-by-one, accidental bidirectionality")
return {}
# ─────────────────────────────────────────────────────────── block 6
Reading the implementation
The test perturbs a token at position \(j\) and asserts that outputs at positions \(< j\) are bit-identical. That is the precise operational meaning of causality: information may not flow backwards.
Why this beats inspecting the mask visually:
- It tests the composition of every layer, not one mask. A model can have a correct mask in layer 1 and a bug in a residual path, a cached key, or a normalisation that mixes across positions.
- It catches bugs in the inference path as well as training — a KV cache that recomputes positions differently is the classic one, and it only manifests when cached and uncached generation disagree.
- It requires no ground truth and no training, so it can run as a unit test in milliseconds on every commit.
-inf before the softmax rather than zeroing after is the correct
implementation, because \(e^{-\infty}=0\) before normalisation. Zeroing
afterwards leaves the denominator including masked positions, so the surviving
weights no longer sum to one — a subtle scale error that trains, badly.
What the numbers say
Output:
scrambled positions 5..9, then compared positions 0..4
bit-identical: True (leak: False)
catches: mask after softmax, off-by-one, accidental bidirectionality
Beyond the toy
The general principle is worth more than the specific test: for every property your architecture is supposed to have, write the test that fails when it does not. Equivariances, invariances and information-flow constraints are all testable this way, cheaply, and they catch the class of bug that produces plausible-but-wrong models.
The production version of this bug is data leakage rather than mask leakage — a validation set that overlaps the training corpus, or a feature computed with future information (P08 measures exactly that). Same shape, same seductive symptom: results that are too good.
Block 6 — Multi-head
Teaches: H heads cost the same as one big head, and buy H views
The problem. One attention head computes one kind of similarity. Multi-head attention gets \(H\) of them for the same parameter count and the same FLOPs — which sounds like something for nothing and is worth understanding precisely.
@block(6, "Multi-head", "H heads cost the same as one big head, and buy H views")
def b6(s, show):
softmax = s["softmax"]
def mha(X, Wq, Wk, Wv, Wo, H):
B, T, d = X.shape; dh = d // H
def split(M): return M.reshape(B, T, H, dh).transpose(0, 2, 1, 3)
Q, K, V = split(X @ Wq), split(X @ Wk), split(X @ Wv)
sc = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(dh)
sc = np.where(np.tril(np.ones((T, T), bool)), sc, -np.inf)
out = softmax(sc) @ V # (B,H,T,dh)
return out.transpose(0, 2, 1, 3).reshape(B, T, d) @ Wo
d, H = 32, 4
params1 = 4 * d * d # one head of width d + output proj
paramsH = 4 * d * d # H heads of width d/H + output proj
if show:
print(f" d={d}, H={H}: params 1-head {params1:,} vs {H}-head {paramsH:,}")
print(f" identical. You get {H} attention distributions for free.")
print(f" each head sees a {d//H}-dim subspace -- below ~16 they get too small")
return {"mha": mha}
# ─────────────────────────────────────────────────────────── block 7
Reading the implementation
Split \(d\) into \(H\) heads of width \(d/H\), attend independently, then concatenate and project. The parameter count is identical to a single head of width \(d\) — \(4d^2\) either way — because the projections are just reshaped. The FLOPs are the same too, except that the score matrices are \(H\) matrices of \(T\times T\) rather than one.
What you buy is subspaces. Each head computes similarity in a \(d/H\)- dimensional projection of the space, so different heads can attend on different relations — syntactic dependency, coreference, positional offset. One large head must express all relations in one similarity function and cannot separate them.
The cost is per-head dimension: at \(d\)=512 and \(H\)=64, each head has 8 dimensions, which is too few for a meaningful similarity. There is an interior optimum and it is empirical, typically \(d/H\) in the 64--128 range.
- The
transpose(1, 2)/reshapepair is where implementations go wrong: the head dimension must be adjacent to the batch dimension for the batched matmul, and the inverse reshape must restore the exact original layout. Areshapethat silently interleaves head outputs still trains — worse, and for no visible reason.
What the numbers say
Output:
d=32, H=4: params 1-head 4,096 vs 4-head 4,096
identical. You get 4 attention distributions for free.
each head sees a 8-dim subspace -- below ~16 they get too small
Beyond the toy
- MQA and GQA break the symmetry deliberately: keep \(H\) query heads but share one (MQA) or a few (GQA) key/value heads. Quality is nearly unchanged and the KV cache shrinks by \(H\) or \(H/g\), which is the dominant memory cost at inference. This is the clearest case in the architecture of a change motivated purely by the serving cost model (P14).
- Head redundancy is measurable. Michel et al. found most heads can be pruned at little cost, and specialised heads (induction heads, previous-token heads) are identifiable and mechanistically interpretable. \(H\) is over-provisioned because which heads matter is not known in advance.
- Attention sinks. Trained models place large attention mass on the first token, apparently as a no-op destination when a head has nothing to attend to. Deleting that token during streaming inference breaks the model, which is why StreamingLLM keeps it pinned.
Block 7 — The pre-norm block
Teaches: residual as a gradient highway; norm before the sublayer
The problem. Two mechanisms let a deep stack train at all — the residual connection and the normalisation — and where the normalisation goes changes whether the model needs a warmup schedule to converge.
@block(7, "The pre-norm block", "residual as a gradient highway; norm before the sublayer")
def b7(s, show):
mha = s["mha"]
def layernorm(x, g, b, eps=1e-5):
mu = x.mean(-1, keepdims=True); var = x.var(-1, keepdims=True)
return g * (x - mu) / np.sqrt(var + eps) + b
def ffn(x, W1, b1, W2, b2):
h = x @ W1 + b1
return np.maximum(h, 0) @ W2 + b2 # ReLU, 4x expansion
def tblock(x, P, H):
x = x + mha(layernorm(x, P["g1"], P["b1"]), P["Wq"], P["Wk"], P["Wv"], P["Wo"], H)
x = x + ffn(layernorm(x, P["g2"], P["b2"]), P["W1"], P["bb1"], P["W2"], P["bb2"])
return x
if show:
print(" x = x + Attn(LN(x)); x = x + FFN(LN(x))")
print(" PRE-norm: the residual path from output to input is unnormalised,")
print(" so gradients reach layer 1 undiminished. Post-norm rescales on every")
print(" layer and needs warmup to train deep.")
return {"layernorm": layernorm, "ffn": ffn, "tblock": tblock}
# ─────────────────────────────────────────────────────────── block 8
Reading the implementation
x = x + sublayer(norm(x)) is pre-norm. The alternative,
x = norm(x + sublayer(x)), is post-norm, and the difference is the gradient
path.
In pre-norm, the residual stream is an unbroken identity path from input to output: the gradient reaches every layer undiminished, because \(\partial(x + f(x))/\partial x = I + \partial f/\partial x\). In post-norm, every layer's output passes through a normalisation, so gradients are rescaled at each of \(L\) steps and the product either shrinks or grows with depth. That is why the original Transformer needed a learning-rate warmup and why post-norm models past ~12 layers were unstable without one.
Pre-norm's cost is a slight quality reduction at matched compute, and a residual stream whose magnitude grows with depth — which is why pre-norm models add a final normalisation before the output projection.
What the numbers say
Output:
x = x + Attn(LN(x)); x = x + FFN(LN(x))
PRE-norm: the residual path from output to input is unnormalised,
so gradients reach layer 1 undiminished. Post-norm rescales on every
layer and needs warmup to train deep.
Beyond the toy
- RMSNorm drops the mean subtraction and the bias: \(x / \sqrt{\overline{x^2} + \epsilon}\). It is 10--15% cheaper in a memory-bound kernel with no measurable quality cost, which is why most models after ~2022 use it.
- The FFN is where the parameters are. With a 4× expansion, the two FFN matrices are \(8d^2\) against attention's \(4d^2\) — two thirds of every layer. Optimisation effort aimed at "attention" is usually aimed at a third of the model.
- SwiGLU replaces the ReLU FFN with a gated variant, using ~2.7× expansion to match parameter count. It costs a third matmul and consistently wins per parameter, which is a compute-for-quality trade rather than a free lunch.
- Normalisation placement interacts with precision. In bf16, post-norm's accumulated rescaling can overflow; the numerics and the architecture are not separable concerns.
Block 8 — The model
Teaches: stack the block; tie the unembedding; count the parameters
The problem. Assemble the pieces into something with a parameter count you can defend, and make the two decisions that dominate that count: how many layers, and whether to tie the unembedding.
@block(8, "The model", "stack the block; tie the unembedding; count the parameters")
def b8(s, show):
tblock, layernorm = s["tblock"], s["layernorm"]
V, d, H, L, T = s["tok"].size, 32, 4, 2, 16
def init():
P = {"emb": rng.standard_normal((V, d)) * 0.02,
"pos": rng.standard_normal((T, d)) * 0.02,
"gf": np.ones(d), "bf": np.zeros(d)}
for l in range(L):
for k, shape in (("Wq",(d,d)),("Wk",(d,d)),("Wv",(d,d)),("Wo",(d,d)),
("W1",(d,4*d)),("W2",(4*d,d))):
P[f"{l}.{k}"] = rng.standard_normal(shape) / np.sqrt(shape[0])
P[f"{l}.bb1"] = np.zeros(4*d); P[f"{l}.bb2"] = np.zeros(d)
P[f"{l}.g1"] = np.ones(d); P[f"{l}.b1"] = np.zeros(d)
P[f"{l}.g2"] = np.ones(d); P[f"{l}.b2"] = np.zeros(d)
return P
def forward(P, idx):
B, t = idx.shape
x = P["emb"][idx] + P["pos"][:t]
for l in range(L):
sub = {k.split(".",1)[1]: v for k, v in P.items() if k.startswith(f"{l}.")}
x = tblock(x, sub, H)
x = layernorm(x, P["gf"], P["bf"])
return x @ P["emb"].T # weight tying: unembed = emb^T
P = init()
n = sum(v.size for v in P.values())
if show:
print(f" V={V} d={d} heads={H} layers={L} ctx={T}")
print(f" parameters: {n:,} (embedding tied with unembedding)")
lg = forward(P, s["data"][:16][None, :])
print(f" forward: idx (1,16) -> logits {lg.shape}")
print(f" initial loss {s['cross_entropy'](lg, s['data'][1:17][None,:]):.4f} "
f"vs floor {s['floor']:.4f} (untrained ~= floor, as it should be)")
return {"init": init, "forward": forward, "V": V, "d": d, "H": H, "L": L, "T": T}
# ─────────────────────────────────────────────────────────── block 9
Reading the implementation
Per layer the parameter count is \(4d^2\) (attention projections) + \(8d^2\) (FFN) = \(12d^2\), so a model is approximately \(12Ld^2\) parameters plus embeddings. That formula is worth memorising: it lets you size a model, estimate its training FLOPs as \(6N\) per token, and price its inference at \(2N\), all without a framework.
Weight tying — using the embedding matrix as the output projection — saves \(Vd\) parameters. At \(V\)=32000 and \(d\)=4096 that is 131M, which at small scale is most of the model and at large scale is a rounding error. The justification is more than parameter economy: the input embedding and the output projection are both maps between token space and hidden space, and tying imposes that they be transposes of each other, which acts as a regulariser at small scale.
Initialisation scale is the other decision hiding here, and it is the one that silently ruins training runs. Weights scaled \(1/\sqrt{d}\) keep activation variance stable through a layer; too large and the residual stream explodes with depth, too small and the signal vanishes. The first version of this project's walkthrough omitted the scale and the demo disproved its own point — kept as a comment in the source because it is the most instructive kind of bug.
What the numbers say
Output:
V=28 d=32 heads=4 layers=2 ctx=16
parameters: 26,624 (embedding tied with unembedding)
forward: idx (1,16) -> logits (1, 16, 28)
initial loss 3.3313 vs floor 3.3322 (untrained ~= floor, as it should be)
Beyond the toy
- Depth versus width. At fixed parameter count, deeper models generally perform better up to a point, but depth is serial — it cannot be parallelised across devices as cleanly as width, and it increases the pipeline bubble in pipeline parallelism. The choice is as much a systems decision as a modelling one.
- Chinchilla scaling says compute-optimal training uses ~20 tokens per parameter, which repriced the entire field: most models of the GPT-3 era were badly under-trained for their size, and a smaller model trained longer wins at equal compute and is cheaper to serve forever after.
- Parameter count is the wrong metric for serving cost. What matters at inference is bytes read per token (memory-bound decode) and KV cache per sequence — both of which MoE and GQA decouple from parameter count entirely.
Block 9 — The same attention, in torch
Teaches: P01 permits torch as a TENSOR library -- not as an attention library
The problem. Everything above is numpy, which cannot compute a gradient. This block ports the same attention to torch as a tensor library — explicitly not using
nn.MultiheadAttentionorscaled_dot_product_attention— and proves the port is exact before trusting it.
@block(9, "The same attention, in torch", "P01 permits torch as a TENSOR library -- not as an attention library")
def b9(s, show):
try:
import torch, torch.nn.functional as F
except ImportError:
if show:
print(" torch is not installed, so this block and the assembly are")
print(" skipped. Blocks 1-8 above are pure numpy and told the whole")
print(" story; this block only re-expresses block 6's attention in a")
print(" framework that can differentiate it. Install with:")
print(" pip install torch")
return {}
torch.manual_seed(0)
def mha_t(x, Wq, Wk, Wv, Wo, H):
"""Byte-for-byte the same maths as block 6. Hand-written -- the whole point
is that nn.MultiheadAttention is forbidden. torch supplies tensors and
autograd; the mechanism is ours."""
B, T, d = x.shape; dh = d // H
sp = lambda M: (x @ M).view(B, T, H, dh).transpose(1, 2)
q, k, v = sp(Wq), sp(Wk), sp(Wv)
sc = q @ k.transpose(-2, -1) / (dh ** 0.5)
sc = sc.masked_fill(~torch.tril(torch.ones(T, T, dtype=torch.bool)), float("-inf"))
out = torch.softmax(sc, -1) @ v
return (out.transpose(1, 2).reshape(B, T, d)) @ Wo
# equivalence check against the numpy head from block 6
B, T, d, H = 1, 6, 32, 4
xs = rng.standard_normal((B, T, d))
Ws = [rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(4)]
np_out = s["mha"](xs, *Ws, H)
t_out = mha_t(torch.tensor(xs), *[torch.tensor(w) for w in Ws], H).detach().numpy()
err = np.abs(np_out - t_out).max()
assert err < 1e-10, f"torch and numpy disagree: {err}"
if show:
print(f" numpy vs torch, same weights: max |diff| = {err:.2e}")
print(" identical maths, and now differentiable. Forbidden here and in P01:")
print(" nn.Transformer, nn.MultiheadAttention, F.scaled_dot_product_attention")
return {"torch": torch, "mha_t": mha_t}
# ─────────────────────────────────────────────────────────── assembly
Reading the implementation
The port is line-for-line the same mathematics, and the assertion against the numpy version to \(<10^{-10}\) is what makes it safe to build on. That check is the same discipline as P13's reference comparison: a re-implementation is not correct because it looks correct, it is correct because it agrees with something already trusted.
masked_fill(~tril(...), -inf) is the causal mask again, now differentiable. Note
that -inf interacts with autograd correctly here because softmax's gradient at a
zero-probability position is zero — but a -inf that reaches a sum or a log
elsewhere produces nan, which is the most common way masking breaks a backward
pass.
What the numbers say
Output:
numpy vs torch, same weights: max |diff| = 4.44e-16
identical maths, and now differentiable. Forbidden here and in P01:
nn.Transformer, nn.MultiheadAttention, F.scaled_dot_product_attention
Beyond the toy
The restriction — torch as tensors, not as transformers — is the pedagogical point
of the whole project, and it maps onto a real engineering distinction. Using
F.scaled_dot_product_attention in production is correct: it dispatches to
FlashAttention, handles the memory layout, and is faster than anything hand-
written. But it also means the \(T^2\) memory problem, the online softmax, and
the numerical care around masking are invisible to you — and those are precisely
the things you need to understand when the fused kernel does not support your
variant, when a mask produces nan, or when you have to decide whether MQA is
worth the quality risk.
Build it once to know what the library is doing; then use the library.
The assembly
Every block above, wired together into one working system:
def assembly(s):
if "torch" not in s:
print("\n Skipped: the assembly trains the model, which needs torch.")
print(" Run `pip install torch` and re-run to see it reach 0.106 nats.")
return
torch, mha_t, tok = s["torch"], s["mha_t"], s["tok"]
V, d, H, L, T = s["V"], s["d"], s["H"], s["L"], s["T"]
floor = s["floor"]
data = torch.tensor(s["data"])
print("\nEvery block, wired into a model that actually trains.\n")
g = torch.Generator().manual_seed(0)
def par(*shape, scale=None):
t = torch.randn(*shape, generator=g) * (scale if scale else (shape[0] ** -0.5))
return t.requires_grad_(True)
P = {"emb": par(V, d, scale=0.02), "pos": par(T, d, scale=0.02)}
for l in range(L):
for k in ("Wq", "Wk", "Wv", "Wo"): P[f"{l}.{k}"] = par(d, d)
P[f"{l}.W1"] = par(d, 4 * d); P[f"{l}.W2"] = par(4 * d, d)
params = list(P.values())
def ln(x):
return (x - x.mean(-1, keepdim=True)) / (x.var(-1, keepdim=True, unbiased=False) + 1e-5).sqrt()
def model(idx):
x = P["emb"][idx] + P["pos"][: idx.shape[1]]
for l in range(L):
x = x + mha_t(ln(x), P[f"{l}.Wq"], P[f"{l}.Wk"], P[f"{l}.Wv"], P[f"{l}.Wo"], H)
x = x + torch.relu(ln(x) @ P[f"{l}.W1"]) @ P[f"{l}.W2"]
return ln(x) @ P["emb"].T # tied unembedding
def batch(n, bs=16):
ix = torch.randint(0, len(data) - T - 1, (bs,), generator=g)
return (torch.stack([data[i:i+T] for i in ix]),
torch.stack([data[i+1:i+T+1] for i in ix]))
opt = torch.optim.AdamW(params, lr=3e-3)
print(f" {'step':>6}{'loss':>10}{'floor':>9} {'note':<28}")
for step in range(801):
x, y = batch(step)
loss = torch.nn.functional.cross_entropy(model(x).reshape(-1, V), y.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
if step % 200 == 0 or step == 800:
note = "at the floor -- untrained" if step == 0 else ""
print(f" {step:>6}{loss.item():>10.4f}{floor:>9.4f} {note:<28}")
final = loss.item()
print(f"\n final {final:.4f} nats, {floor - final:.4f} BELOW the entropy floor "
f"({floor:.4f}).")
print(f" perplexity {np.exp(final):.2f} against a random-guess perplexity of {V}.")
print("\n E10 (deliberate overfit) -- the gate before any real training:")
tiny_x, tiny_y = data[:T][None, :], data[1:T+1][None, :]
for step in range(400):
loss = torch.nn.functional.cross_entropy(model(tiny_x).reshape(-1, V), tiny_y.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
print(f" 200-token slice driven to loss {loss.item():.4f} "
f"({'PASS' if loss.item() < 0.1 else 'still above 0.1'})")
print(" a model that cannot do this has a bug, not a hard problem.")
print("\n Generation (greedy):")
ctx = data[:6].tolist()
for _ in range(40):
ctx.append(int(model(torch.tensor(ctx[-T:])[None, :])[0, -1].argmax()))
print(f" {tok.decode(ctx)!r}")
print("\n" + "─" * 74)
print(" THE FULL PICTURE")
print("─" * 74)
rows = [(1,"tokenizer","chars <-> ids, round-trip tested"),
(2,"batching","y is x shifted by one"),
(3,"loss","cross-entropy, and the ln(V) entropy floor"),
(4,"attention","QK^T/sqrt(dk), softmax, weighted V"),
(5,"leak test","future tokens provably cannot influence the past"),
(6,"multi-head","H views for the price of one"),
(7,"pre-norm block","residual highway + LN before each sublayer"),
(8,"the model","embedding -> L blocks -> tied unembedding"),
(9,"torch port","same maths, now differentiable")]
for n, name, what in rows:
print(f" block {n} {name:<16} {what}")
print(f"\n assembly {sum(p.numel() for p in params):,} parameters, "
f"trained to {final:.3f} nats")
print("\n Next, on the project page: RoPE (m9), BPE (m8), KV cache (m11), and")
print(" the ablations that turn this from a working model into a measured one.")
Output:
Every block, wired into a model that actually trains.
step loss floor note
0 3.3408 3.3322 at the floor -- untrained
200 0.1810 3.3322
400 0.1187 3.3322
600 0.1169 3.3322
800 0.1056 3.3322
final 0.1056 nats, 3.2266 BELOW the entropy floor (3.3322).
perplexity 1.11 against a random-guess perplexity of 28.
E10 (deliberate overfit) -- the gate before any real training:
200-token slice driven to loss 0.0017 (PASS)
a model that cannot do this has a bug, not a hard problem.
Generation (greedy):
'the quick brown fox fox fox fox runs fons fox '
THE FULL PICTURE
block 1 tokenizer chars <-> ids, round-trip tested
block 2 batching y is x shifted by one
block 3 loss cross-entropy, and the ln(V) entropy floor
block 4 attention QK^T/sqrt(dk), softmax, weighted V
block 5 leak test future tokens provably cannot influence the past
block 6 multi-head H views for the price of one
block 7 pre-norm block residual highway + LN before each sublayer
block 8 the model embedding -> L blocks -> tied unembedding
block 9 torch port same maths, now differentiable
assembly 25,984 parameters, trained to 0.106 nats
Next, on the project page: RoPE (m9), BPE (m8), KV cache (m11), and
the ablations that turn this from a working model into a measured one.
The design space
The blocks above implement one point in a large space. Every choice below is a real fork taken by a real production model, and the reason for each is a cost, not a preference.
| Axis | This build | Alternatives | What actually decides it |
|---|---|---|---|
| Attention | MHA, H heads, full d_k per head | MQA (one KV head), GQA (g KV groups), MLA (latent-compressed KV) | KV-cache bytes at decode, not quality. GQA-8 on a 64-head model cuts the cache 8× for ~0 quality loss |
| Positional | learned absolute | sinusoidal, RoPE, ALiBi, NoPE | Extrapolation beyond trained context. RoPE rotates Q/K so the dot product depends on relative offset; ALiBi biases logits linearly with distance |
| Norm placement | pre-LN | post-LN, sandwich, DeepNorm | Gradient scale at depth. Post-LN needs warmup and dies past ~12 layers without it; pre-LN trains stably but slightly underperforms at matched compute |
| Norm type | LayerNorm | RMSNorm | RMSNorm drops the mean subtraction: ~10--15% fewer ops in a memory-bound kernel, no measurable quality cost |
| FFN | ReLU, 4× | GELU, SwiGLU (≈2.7× to match params) | SwiGLU wins per-parameter; it costs a third matmul, so it is a compute-for-quality trade |
| Unembedding | tied to embedding | untied | V·d parameters. At V=32k, d=4096 that is 134M parameters — worth tying at small scale, usually untied at large |
The arithmetic that drives all of it
For one layer, hidden size \(d\), sequence \(T\), the forward cost splits in two:
\[ \text{attention projections} = 4Td^2, \quad \text{attention scores+values} = 2T^2 d, \quad \text{FFN} = 8Td^2 \ (\text{ratio }4) \]
So attention's quadratic term overtakes the linear ones when \(2T^2 d > 12Td^2\), that is \(T > 6d\). For \(d\) = 4096 that is \(T\) ≈ 24{,}000 tokens. Below that, a transformer is a stack of matmuls and attention is a rounding error in FLOPs — which is why "attention is quadratic" is misleading advice at ordinary context lengths. What attention does dominate long before that is memory: the score matrix is \(T^2\) per head, and materialising it is why FlashAttention exists.
Training cost per token is ≈\(6N\) FLOPs for \(N\) parameters (2 forward, 4 backward); inference is ≈\(2N\). Those two constants let you price a training run on the back of an envelope, and they are the arithmetic behind the Chinchilla result that most models of that era were badly under-trained for their size.
Latency, bandwidth and the memory hierarchy
Decode is the case that matters and it is memory-bound, not compute-bound. Generating one token requires reading every weight once:
| Model | Weights (fp16) | At 3.35 TB/s (H100) | Implied ceiling |
|---|---|---|---|
| 7B | 14 GB | 4.2 ms | ~240 tok/s |
| 70B | 140 GB | 42 ms | ~24 tok/s |
| 70B, 8-way tensor parallel | 17.5 GB/GPU | 5.2 ms | ~190 tok/s |
No arithmetic optimisation moves those numbers, because the arithmetic intensity of a batch-1 decode step is ≈2 FLOP/byte against a ridge point near 295. This is the single most important fact about LLM serving and it falls straight out of P14's roofline. The levers are all bytes: quantise the weights (int8/int4/fp8), share KV heads (GQA/MQA), or amortise the read over more sequences (batching, which is why continuous batching and paged attention exist).
The KV cache is the other memory consumer, and it grows with traffic rather than model size:
\[ \text{KV bytes} = 2 \times L \times T \times d_{kv} \times \text{batch} \times \text{bytes/elem} \]
For a 70B-class model (80 layers, \(d_{kv}\)=8192 with MHA) at 4k context, that is ~10 GB per sequence. GQA with 8 KV heads out of 64 divides it by 8. This is why vLLM's paged attention — allocating KV in fixed blocks like OS pages, from P12 — was such a large practical win: it removed the internal fragmentation from over-provisioning contiguous per-sequence buffers.
FlashAttention, and why it is an IO algorithm
The naive attention kernel writes the \(T \times T\) score matrix to HBM, reads it back for the softmax, writes it again, reads it for the value multiply. That is \(O(T^2)\) HBM traffic for \(O(T^2 d)\) work — intensity \(O(d)\), but with a constant that puts it below the ridge. FlashAttention tiles Q, K and V into SRAM (192 KB per SM on A100) and never materialises the full matrix, using the online softmax trick to keep a running max and normaliser so a streaming computation is numerically identical to the batched one. Same FLOPs, an order of magnitude less HBM traffic, 2--4× faster in practice. It is the clearest example in modern ML of an algorithm that is faster without doing less arithmetic — exactly the lesson P14 block 3 measures on a smaller scale.
Hardware: CPU, GPU, TPU
| CPU | GPU (H100 class) | TPU (v4/v5 class) | |
|---|---|---|---|
| Matmul unit | SIMD FMA, 8--16 fp32 lanes | Tensor cores, warp-level mma on 16×8×16 tiles | Systolic MXU, 128×128 |
| Peak (dense bf16) | ~1--3 TFLOP/s | ~990 TFLOP/s (published) | ~275--400 TFLOP/s (published) |
| Memory | 50--400 GB/s DDR | ~3.35 TB/s HBM3 | ~1.2--1.6 TB/s HBM |
| Ridge (FLOP/byte) | ~10--40 | ~295 | ~230 |
| Scheduling | out-of-order, cache-managed | warps, occupancy, programmer-managed shared memory | compiler-scheduled, no cache hierarchy to speak of |
The systolic array is worth understanding because it explains TPU's shape preferences. Data flows through a 128×128 grid of MACs; each value read from memory is reused 128 times inside the array before leaving. That is the hardware expression of the same reuse argument as cache tiling (P14 block 4) — see proofs.md P15. It also means a matmul whose dimensions are not multiples of 128 wastes the array, which is why TPU-targeted models pad aggressively and why "make the hidden size a nice number" is real advice.
Note the ridge points: every accelerator generation has made the memory wall worse, because FLOP/s has grown faster than bandwidth. A kernel that was compute-bound on a V100 can be memory-bound on an H100 without a line changing.
Advanced algorithms and alternatives
- Sub-quadratic attention. Linear attention (Katharopoulos et al.) rewrites softmax attention as a kernel feature map so the computation associates as \((\phi(Q)(\phi(K)^\top V))\), turning \(O(T^2d)\) into \(O(Td^2)\). Performer approximates the softmax kernel with random features. Both lose quality on recall-heavy tasks, which is the empirical finding that keeps full attention alive.
- State-space models. S4/Mamba replace attention with a linear recurrence that has a convolutional parallel form for training and a constant-state recurrent form for inference — so decode needs no KV cache at all. The trade is a fixed-size state versus attention's perfect recall over the context.
- Sparse attention. Longformer/BigBird use local windows plus a few global tokens; the theory is that the resulting attention graph is an expander, so information still mixes in \(O(\log T)\) hops. Hardware efficiency is the practical problem: unstructured sparsity maps badly to tensor cores.
- Speculative decoding. A small draft model proposes \(k\) tokens, the large model verifies them in one forward pass. It converts \(k\) memory-bound decode steps into one compute-bound one — a pure exploitation of the intensity gap in the table above, with an acceptance-rate-dependent speedup and identical output distribution.
- MoE. Mixture-of-experts decouples parameters from FLOPs per token: only the top-\(k\) experts run. It trades a much larger memory footprint and an all-to-all communication pattern (P06's shuffle, at NVLink speed) for constant compute.
How this connects to the rest of the track
- P13 implements the autodiff this model trains under; block 9 here is the same attention expressed in a framework that can differentiate it.
- P14 explains why decode is memory-bound and prefill is not — the same intensity arithmetic, generalised.
- P02 uses the embeddings a model like this produces; attention itself is a soft nearest-neighbour lookup with learned keys, and both hit the same contrast problem in high dimensions.
- P12's paging is the direct ancestor of paged attention.
- P06's all-to-all shuffle is the same communication pattern as expert-parallel MoE routing and tensor-parallel all-reduce.
Failure modes at scale
- Loss spikes from a single bad batch or fp16 overflow in attention logits; the standard mitigations are z-loss, query/key normalisation, and skipping batches whose gradient norm exceeds a threshold.
- Silent leakage from an off-by-one in the causal mask. Loss falls below the data's entropy floor, which looks like a triumph. The assembly's floor comparison is the cheap detector.
- Divergence between train and inference paths — a KV cache that recomputes positions differently from training. Assert that cached and uncached generation produce identical logits.
- Throughput collapse from ragged batches: padding to the longest sequence in the batch wastes compute proportional to the length variance, which is why serving systems sort by length or use continuous batching.
Primary sources
- Vaswani et al., Attention Is All You Need (2017) — the architecture.
- Dao et al., FlashAttention (2022) and FlashAttention-2 (2023) — the IO argument in full.
- Shazeer, Fast Transformer Decoding: One Write-Head Is All You Need (2019) — MQA, and the KV-cache reasoning above.
- Ainslie et al., GQA (2023) — the interpolation that most models now use.
- Hoffmann et al., Training Compute-Optimal Large Language Models (2022) — the \(6N\) arithmetic applied to a scaling law.
- Kwon et al., Efficient Memory Management for LLM Serving with PagedAttention (2023) — P12's idea, in a serving stack.
- Su et al., RoFormer (2021) — RoPE.
Running it
python3 handson/h01_transformer.py # every block, then the assembly
python3 handson/h01_transformer.py --block 3 # just block 3 and its prerequisites
python3 handson/h01_transformer.py --quiet # the assembly only
What to do with this
Run it, then break it. Delete the / sqrt(dk) and watch block 3's entropy
collapse. Remove the causal mask and watch the loss drop below the entropy of
the data --- the classic leakage bug, which looks like brilliant training. Change
the initialisation scale and see how far it moves the trainable range. Each of
those is a two-character edit with a visible, measurable consequence, which is
the fastest way to build the intuition that reading the paper alone will not.
Milestones, experiments, readings and exit criteria for this project: P01 — Transformer From Scratch.
P02 — Approximate Nearest-Neighbour Index
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P02 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 77 hours · Weeks 9–15 · Stage 1 · Python, with a compiled inner loop
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Find the k most similar vectors to a query among n, in sub-linear time, at a recall you choose and can prove |
| 2. Constraints | Single machine, in-RAM, one distance metric fixed for the whole project, recall measured against exact brute force every time |
| 3. Naive design | Yours. Most people invent one of: k-d tree, LSH, clustering + probe nearest centroids, or a random graph walk |
| 4. Predicted failure | At what dimensionality does your structure stop helping? Predict the number before you measure it — for k-d trees it is far lower than people expect |
| 5. Minimal implementation | Brute force. It is the baseline and it is also the ground-truth oracle |
| 6. Correctness | Brute force agrees with a naive triple loop; recall against it is computed the same way every time |
| 7. Instrumentation | Distance computations per query. This is the single most important counter in the project |
| 8. Baseline | Brute force with BLAS. It is much harder to beat than you think |
| 9. Bottleneck | Decompose speedup into algorithmic (distance count) and constant factor (ns per distance). They are different bugs |
| 10. Hypothesis | The clustered-data recall ceiling is the best one available — see the worked notebook entry |
| 11. Modification | HNSW's neighbour-selection heuristic (Algorithm 4) |
| 12. Experiment | Recall/QPS curve at fixed n, d, M, efConstruction |
| 13. Failure analysis | Where does the missing recall physically go? Name the nodes it went to |
| 14. Report | The recall/QPS Pareto curve, plus a comparison against hnswlib you did not win |
Why This Project Matters
You use HNSW in production through OpenSearch. That means you already have opinions
about ef_search and m that you cannot currently defend from first principles, and
you have never seen what recall does when the parameters are wrong in a way that
doesn't show up as an error.
More generally: this is the project where "approximate" stops being a hand-wave and becomes a contract with a number attached. Almost every system in the rest of this journey trades exactness for speed somewhere — a Bloom filter, a watermark, a sampled metric, a quantized weight. This is the cleanest possible place to learn how to quantify what you gave up, because the exact answer is cheaply computable and the approximation error is a single scalar.
It is also the project that teaches the most transferable debugging technique in the whole track: when a better algorithm loses to a worse one, decompose the ratio into operation count and cost per operation before touching a parameter.
Prerequisites
- P01 is not required but supplies realistic test data (see Connections)
- Linear algebra: dot products, norms, the cosine/L2 equivalence under normalisation
- Basic graph algorithms: BFS, priority queues, greedy search
- From
math.md: §Concentration of Measure in High Dimensions (2 h). Read it before milestone 3 — it is what makes the curse of dimensionality quantitative rather than folkloric.
Duration and Size
Medium, 77 hours, 7 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Brute force + exact ground truth + recall harness + a single-layer NSW graph with insertion and beam search. Produces a recall/latency curve. | 35 |
| Standard | + HNSW hierarchy, the neighbour-selection heuristic, deletion via tombstones, persistence and load, a compiled inner loop, full parameter sweeps, hnswlib comparison. | 77 |
| Extension | Product quantization on top of the graph; or an adaptive efSearch policy that predicts per-query difficulty. The latter is a research direction. | +30–45 |
Central Technical Questions
- Why does exact search get hard in high dimensions, and what is the right way to measure "hard"? (Not \(d\). See relative contrast.)
- What does a greedy graph walk assume, and where exactly is that assumption false? The missing recall is not diffuse; it goes to specific nodes for a specific reason.
- Why a hierarchy? What does it buy that a single well-connected graph does not?
- Why does neighbour selection matter more than it looks? This is the question most implementations get wrong.
- What is the actual crossover point where your index beats brute force, and which of the two factors — algorithmic or constant — decides it?
- What does recall@10 = 0.95 mean for a downstream recommender? Is the missing 5% uniformly distributed across queries, or concentrated on the hard ones? (It is the latter. Measure it.)
Architecture
Write your naive design first.
┌──────────────────────────────────┐
query vector ────────► │ Layer L (sparse, long edges) │ greedy, ef=1
│ │ descend at local min │
│ Layer 1 │ greedy, ef=1
│ │ │
│ Layer 0 (all points, dense) │ beam search, ef=efSearch
└──────────────┬───────────────────┘
▼
top-k by distance, exact within the visited set
Each point is inserted into layers \(0..\ell\) where \(\ell = \lfloor -\ln(U(0,1)) \cdot m_L \rfloor\) — a geometric distribution, so layer \(i\) holds roughly \(1/e^i\) of the points. The upper layers are a coarse skeleton for navigation; layer 0 holds everything and is where recall is decided.
The difficulty metric
Ambient dimension \(d\) does not predict ANN difficulty. Relative contrast does (He, Kumar & Chang 2012):
\[ \mathrm{RC} = \frac{\mathbb{E}_q[\text{mean distance from } q \text{ to all points}]}{\mathbb{E}_q[\text{distance from } q \text{ to its nearest neighbour}]} \]
As RC → 1, every point is about as far away as every other, greedy descent has no
gradient to follow, and any distance-based method degenerates. Measured with
tools/annlab.py on uniform unit-sphere data at n=10,000:
| d | RC | \(d_{10}/d_1\) |
|---|---|---|
| 16 | 2.220 | 1.2303 |
| 64 | 1.356 | 1.0717 |
| 128 | 1.224 | 1.0465 |
| 512 | 1.097 | 1.0196 |
At \(d=512\) the tenth-nearest neighbour is 2% further away than the first. No index can reliably distinguish them, and an ANN benchmark on uniform high-dimensional data is measuring the dataset, not the index.
Real embeddings are not uniform — they concentrate near a low-dimensional manifold. The same tool with 100 Gaussian clusters at σ=0.05 gives RC = 3.363 at d=64, 2.5× the uniform value at the same ambient dimension. Which dataset you benchmark on decides your conclusion, so state it every time.
One trap, measured: a Gaussian perturbation with per-axis σ in \(d\) dimensions has expected norm \(\sigma\sqrt{d}\). If the cluster centres are unit vectors and \(\sigma\sqrt{d} \gtrsim 1\), the noise exceeds the signal and your "clustered" dataset is uniform data wearing a hat. At d=64: σ=0.25 gives RC=1.393 against uniform's 1.356 — no difference. σ=0.05 gives 3.371. Verify your independent variable varies before you spend a run measuring its effect.
The two-factor speedup model
The most useful thing in this project. Decompose any comparison against brute force:
\[ \text{speedup} = \underbrace{\frac{n}{\text{dists/query}}}_{\text{algorithmic}} \Big/ \underbrace{\frac{\text{ns/dist}_{\text{graph}}}{\text{ns/dist}_{\text{brute}}}}_{\text{constant factor}} \]
Measured at n=10,000, d=64, M=16, efSearch=64, pure Python:
| dataset | algorithmic win | constant factor | predicted | measured |
|---|---|---|---|---|
| uniform (RC 1.36) | 10,000/1,459 = 6.9× | 899 vs 13.3 ns = 67.5× | 0.10× | 0.10× |
| clustered (RC 3.36) | 10,000/321 = 31.1× | 1317 vs 12.8 ns = 102.9× | 0.30× | 0.30× |
The model closes to two significant figures in both cases. It says something specific and actionable: the algorithm is right and the implementation is wrong, which is a different bug from "the algorithm is wrong" and has a different fix. Without the distance counter you would conclude "HNSW doesn't work at n=10k" and spend a week tuning M.
Break-even needs the algorithmic win to exceed the constant factor. Distances/query grows roughly logarithmically while brute force grows linearly, so in pure Python the crossover lands near n ≈ 1.5×10⁵ (a run at n=100,000, d=128 measured 0.80× at ef=64 — just short of parity). In compiled code the constant factor is ~1–2× and the crossover falls to a few thousand. This is why every serious ANN index is written in C++, and milestone 8 is where you find that out for yourself.
Showcase — Do This Before You Start
W2 and the tools · walkthroughs/annlab.py · ~30 minutes
A working miniature of this project: the full recall/QPS curve, the distance counter, and the two-factor decomposition —
run python3 tools/annlab.py and --clusters 100 to see both datasets.
cd walkthroughs && python3 annlab.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo, dataset generators (uniform, clustered, and P01 embeddings), RC measurement | 4 | RC table above reproduced on your machine |
| 2 | Brute force + exact ground truth + recall/precision/NDCG harness | 5 | Agrees with a naive triple loop on n=100; metrics.py imported not rewritten |
| 3 | Distance functions: cosine, dot, L2 — and the normalisation equivalence test | 4 | Test proves all three give identical rankings on normalised data |
| 4 | Random-graph greedy search (no construction heuristic at all) | 6 | Works, is terrible, and you have the recall curve proving it |
| 5 | NSW: insertion, reciprocal edges, degree cap, beam search | 9 | Recall/QPS curve; distance counter instrumented |
| 6 | HNSW hierarchy: layer assignment, descent, layer-0 beam search | 10 | Curve dominates milestone 5's at equal distance count |
| 7 | Neighbour-selection heuristic (Algorithm 4) | 8 | Clustered-data recall ceiling measurably rises |
| 8 | Compiled inner loop (Rust via PyO3, or numpy batch-vectorised candidates) | 8 | ns/dist drops ≥10×; re-measure the crossover against the model |
| 9 | Persistence: save/load, format versioning, checksum | 5 | Round-trip preserves recall exactly; corrupt byte is detected |
| 10 | Deletion via tombstones + a re-insertion path | 4 | Deleted points never returned; recall after 20% churn measured |
| 11 | Parameter sweeps + hnswlib comparison | 8 | All rows in Experiments filled |
| 12 | Report | 6 | Written, including the comparison you lost |
Concepts To Study
- Metric spaces and the triangle inequality — and which pruning techniques it enables (and why cosine similarity is not a metric, but its induced distance is)
- Curse of dimensionality, stated quantitatively: concentration of pairwise distances, and why \(d_{10}/d_1 \to 1\)
- Intrinsic vs ambient dimensionality; why real embeddings are easier than their \(d\) suggests
- Small-world graphs: Kleinberg's navigability result, and why long-range links with the right distribution give \(O(\log^2 n)\) greedy routing
- Greedy search and local minima in proximity graphs; the beam as a remedy
- Skip lists — HNSW's hierarchy is a skip list in a metric space, and seeing that makes the layer-assignment distribution obvious
- Neighbour-selection heuristics: why "keep the M nearest" destroys connectivity on clustered data
- Product quantization: codebooks, asymmetric distance computation (extension only)
- SIMD and memory layout: why contiguous float32 and cache-line alignment matter more than instruction count
Primary-Source Readings
Budget: 11 hours. Read Malkov 2016 §3 after your milestone 5, not before — milestone 4 and 5 are your naive design and it must be yours.
| Reading | Why | Hours |
|---|---|---|
| Malkov & Yashunin, Efficient and robust ANN search using HNSW graphs, TPAMI 2020 (arXiv:1603.09320) | The source. Algorithm 4 is the part that matters most and is skipped most | 3 |
| Malkov et al., ANN algorithm based on navigable small world graphs, Information Systems 45, 2014 | The single-layer NSW you will have reinvented | 1.5 |
| He, Kumar & Chang, On the Difficulty of Nearest Neighbor Search, ICML 2012 | Relative contrast; makes "hard dataset" measurable | 1.5 |
| Beyer et al., When Is "Nearest Neighbor" Meaningful?, ICDT 1999 | The concentration result underneath everything above | 1.5 |
| Aumüller, Bernhardsson & Faithfull, ANN-Benchmarks, Information Systems 87, 2020 | The evaluation protocol you should imitate exactly | 1.5 |
| Jégou, Douze & Schmid, Product Quantization for Nearest Neighbor Search, TPAMI 33(1), 2011 | The other major family; read even if you skip the extension | 2 |
Experiments
| # | Experiment | Sweep | Fixed | Predict first |
|---|---|---|---|---|
| E1 | efSearch | {10,16,24,32,48,64,96,128,192,256} | n,d,M,efC | Shape of recall(ef). Where is the knee? |
| E2 | efConstruction | {50,100,200,400} | efSearch=64 | Build time vs query quality — which does it buy? |
| E3 | M (max connections) | {4,8,16,32,48} | efC=100 | Index size is linear in M; is recall? |
| E4 | Dimensionality | d ∈ {16,64,128,256,512} | n=50k | Report RC alongside; recall should track RC, not d |
| E5 | Dataset size | n ∈ {10³,10⁴,10⁵,10⁶} | d=128 | Where is the brute-force crossover? Derive from the two-factor model first |
| E6 | Normalised vs not | on/off | everything | Predict the recall drop for unnormalised cosine |
| E7 | Clustered vs uniform | RC ∈ {1.36, 3.36} | n,d,M,efC | The best hypothesis here. See below |
| E8 | Neighbour heuristic | naive M-nearest vs Algorithm 4 | clustered data | Predict which metric moves: recall ceiling, or latency? |
| E9 | Distance counter vs wall clock | all of the above | — | Do the two agree via the two-factor model? |
| E10 | Recall distribution | per-query recall histogram at ef=64 | — | Is the missing recall uniform or concentrated? |
| E11 | Churn | delete+reinsert 20%, 50% of points | — | Does recall degrade, and does compaction restore it? |
| E12 | hnswlib comparison | same data, same recall target | — | Predict your factor behind. Then measure it |
E7 is the experiment to build the project around. The naive prediction — clustered data is easier, so recall is higher everywhere — is false, and finding out why teaches you the thing the paper's Algorithm 4 exists for. Measured at n=10k, d=64:
| efSearch | uniform recall@10 | clustered recall@10 | uniform dists/q | clustered dists/q |
|---|---|---|---|---|
| 10 | 0.3605 | 0.4840 | 397 | 193 |
| 64 | 0.8160 | 0.8345 | 1459 | 321 |
| 128 | 0.9480 | 0.9030 | 2484 | 501 |
| 256 | 0.9930 | 0.9670 | 4059 | 840 |
Clustered is faster everywhere and worse above ef≈96. The clustered search cannot spend its budget: 840 distances at ef=256 versus uniform's 4,059. It runs out of reachable candidates. The full failure analysis — the degree cap deletes every inter-cluster bridge deterministically, because intra-cluster distance is 0.521 and inter-cluster is 1.413 — is in the worked notebook entry.
E12 you will lose. hnswlib is years of C++ tuning. Losing by 5–20× while matching
its recall curve at equal distance count is a good result and you should report it
that way: same algorithm, different constant factor, and you can prove the split.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| recall@k | Against exact brute force on the same data and metric. k stated always |
| Distance computations per query | The algorithmic currency; transfers across languages and machines |
| QPS (single-thread) | From the median, and say it is single-thread |
| Latency p50 / p95 / p99 | p95/p50 ratio is itself a reportable number |
| Build time | And whether it is parallelised |
| Index size | Vectors and graph, reported separately. Measured: 1.40× raw at M=16 |
| Peak build memory | Usually 2–3× the final index; the thing that OOMs you in production |
| Recall variance across queries | E10. A mean recall of 0.95 with 20% of queries at 0.5 is a different system from one with all queries at 0.95 |
| RC of the dataset | Report with every recall number, always |
Correctness Tests
- Brute force vs naive triple loop on n=100, d=8. Exact agreement.
- Metric equivalence: on normalised vectors, top-k by cosine, dot, and L2 are the identical index list. Proves \(||a-b||^2 = 2 - 2\langle a,b \rangle\).
- Recall ≤ 1.0 and = 1.0 when efSearch ≥ n. Exhaustive beam must find everything.
- Graph invariants: no self-loops; no duplicate edges; degree ≤ cap at every node; every node in layer \(i\) also in layers \(<i\); layer 0 contains all points.
- Reachability: every node reachable from the entry point in layer 0. This test catches the connectivity bug in E7 directly, and you should add it because of E7.
- Persistence round-trip: save, load, and get bit-identical results for a fixed query set.
- Deletion: a deleted id is never returned, at any efSearch.
- Determinism: fixed seed → identical graph. Verify by hashing the adjacency lists.
- Empty and degenerate cases: n=0, n=1, k>n, all-identical vectors, zero vectors (which have undefined cosine — decide the policy and test it).
Failure Tests
| Injection | Predicted symptom | Lesson |
|---|---|---|
| Skip normalisation, use cosine | Silent recall drop, no error | Silent quality failures are the ANN failure mode |
| Entry point in a disconnected component | Recall collapses for a subset of queries | Why E10's distribution matters more than the mean |
| Degree cap = M instead of 2M | Over-pruning; recall ceiling drops | The connectivity/size trade in the raw |
| Corrupt one byte in the persisted index | Must be detected, not silently mis-ranked | Checksums exist for this |
| Insert 10⁶ duplicate vectors | Degenerate graph; probe the failure mode | Real corpora contain duplicates |
| Query far outside the data distribution | Recall drops; the walk starts badly | Distribution shift, the ANN version |
| Concurrent insert during search (extension) | Undefined; document what you observe | Sets up P03's concurrency section |
Expected Difficulties
- Your first graph will be slower than brute force and you will assume you failed. You did not. Instrument the distance counter at milestone 5 — before you look at wall-clock — and the two-factor model will tell you which half is the problem.
- The beam-search stopping condition is subtle. Stopping when the nearest unexplored candidate is worse than the worst held result is correct only under a local-metric assumption. Getting it slightly wrong changes recall by tens of percent, silently.
- Algorithm 4 reads like an optimisation and is a correctness property. Budget time for milestone 7; do not fold it into 6.
- Recall measurement bugs are invisible. If ground truth and index disagree on tie-breaking among equidistant points, recall looks slightly low forever. Test with deliberate ties.
- Building at n=10⁶ in Python is a multi-hour job. Do milestone 8 before E5, or E5 will eat a week of wall-clock.
Scope Boundaries
In scope: in-memory, single-threaded, one metric, graph-based indexes.
Out of scope: disk-resident indexes (P03); distributed sharding (P05); GPU search; filtering (P03); IVF and tree-based families beyond a paragraph in the report; multi-vector or late-interaction retrieval; learned indexes.
Permitted-library line: numpy for array storage and BLAS distance batches, yes.
hnswlib/faiss/scann only as an external comparison in E12 — never as a
component. heapq is fine; it is a priority queue, not the mechanism under study.
Deliverables
annindex/— brute force, NSW, HNSW, persistence, CLI sweep runnerREPORT.mdwith the recall/QPS Pareto curve, the two-factor decomposition, and thehnswlibcomparison including the gap- Notebook entries for E5, E7, E8
- A reusable ANN benchmark harness — this is the artifact with independent value.
It should accept any index exposing
add/searchand emit the standard curve - Raw sweep data as JSON
Exit Criteria
- recall@10 ≥ 0.95 at some efSearch on a stated dataset, verified against exact brute force
- The recall/QPS curve dominates brute force at some n you measured, and you can state that n and explain it via the two-factor model
- E7 complete: clustered vs uniform, with the ceiling effect measured and explained
- E8 complete: Algorithm 4 implemented and its effect on the E7 ceiling measured
- All nine correctness tests pass, including reachability
-
hnswlibcomparison run, gap reported honestly with the algorithmic/constant split - Persistence round-trips and corruption is detected
-
REPORT.mdwritten with at least one falsified prediction
Extension Ideas
- Adaptive efSearch: predict per-query difficulty from the first few hops and set the beam width per query. If it holds recall at lower mean latency, that is a real result — see Research Directions.
- Product quantization on the graph: measure the memory/recall frontier.
- Filtered search preview: implement one filtering strategy here to feel the problem before P03 formalises it.
- Multi-threaded build with a measured scaling curve; the graph is a shared mutable structure and this is genuinely hard.
Connections
Backward: P01 gives you real embeddings with realistic RC — far better test data than random vectors, and the contrast between them is E4/E7.
Forward:
- → P03 (Vector DB): this index is P03's core. Keep the API narrow:
add,search,delete,save,load. - → P08 (Recommender): candidate retrieval calls this, not a library. Recall@k here becomes an upper bound on the recommender's recall, and quantifying that propagation is one of P08's experiments.
- → P14: the distance kernel is a memory-bound inner loop and a natural target for the tiling and SIMD work.
- → P15: an adaptive ANN policy is one of the candidate research questions.
References
- Malkov, Y. A., Yashunin, D. A. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. IEEE TPAMI 42(4), 2020.
- Malkov, Y., Ponomarenko, A., Logvinov, A., Krylov, V. Approximate nearest neighbor algorithm based on navigable small world graphs. Information Systems 45, 2014.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012.
- Beyer, K., Goldstein, J., Ramakrishnan, R., Shaft, U. When Is "Nearest Neighbor" Meaningful? ICDT 1999.
- Indyk, P., Motwani, R. Approximate Nearest Neighbors: Towards Removing the Curse of Dimensionality. STOC 1998. The LSH origin.
- Jégou, H., Douze, M., Schmid, C. Product Quantization for Nearest Neighbor Search. IEEE TPAMI 33(1), 2011.
- Kleinberg, J. Navigation in a Small World. Nature 406, 2000. Why greedy routing works when long-range links follow the right distribution.
- Aumüller, M., Bernhardsson, E., Faithfull, A. ANN-Benchmarks. Information Systems 87, 2020.
- Johnson, J., Douze, M., Jégou, H. Billion-scale similarity search with GPUs. IEEE Transactions on Big Data 7(3), 2021. The FAISS paper.
- Subramanya, S. J. et al. DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node. NeurIPS 2019. Read before P03 — it is the disk-resident answer.
P02 hands-on — Approximate nearest neighbours, block by block
Why a random graph fails, why a navigable one works, and what recall costs.
Source:
handson/h02_ann.py--- run it withpython3 handson/h02_ann.py
Full project spec: P02 — Approximate Nearest-Neighbour Index
This is the project where the baseline teaches more than the algorithm. Brute force is trivially correct and its cost is knowable in advance; everything an index does is buy latency by giving up recall, so the only interesting question is the exchange rate.
The file builds that comparison honestly. It measures relative contrast on the data before touching an index, because a dataset with low contrast has no nearest neighbour worth finding and no graph will rescue it. It then builds a deliberately random graph to show that connectivity alone is worthless, and only then the navigable small-world construction that makes greedy descent work.
The two-factor model in the assembly is the point of the whole exercise: it predicts the speedup from first principles and lands on the measured number.
Contents
- Block 1 — Vectors and the metric decision
- Block 2 — Relative contrast
- Block 3 — Brute force = the oracle
- Block 4 — recall@k
- Block 5 — A random graph, and why it fails
- Block 6 — NSW: edges that mean something
- Block 7 — The efSearch knob
- The assembly
- The design space
- Latency, bandwidth and the memory hierarchy
- Hardware: CPU, GPU, SSD
- Advanced algorithms and data structures
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Vectors and the metric decision
Teaches: normalise once, and three metrics collapse into one
The problem. Before any index exists there is a decision that silently constrains everything after it: which metric, and in what representation. Get it wrong and every recall number you produce afterwards measures the wrong question.
@block(1, "Vectors and the metric decision", "normalise once, and three metrics collapse into one")
def b1(s, show):
def norm(x): return x / np.linalg.norm(x, axis=-1, keepdims=True)
n, d = 8000, 48
data = norm(rng.standard_normal((n, d), dtype=np.float32))
q = norm(rng.standard_normal((64, d), dtype=np.float32))
if show:
dot = data @ q[0]
l2 = np.linalg.norm(data - q[0], axis=1)
ident = np.abs(l2**2 - (2 - 2*dot)).max()
print(f" {n} vectors, d={d}, unit norm")
print(f" ||a-b||^2 == 2-2<a,b>: max deviation {ident:.2e}")
print(f" argsort by -dot == argsort by L2: "
f"{np.array_equal(np.argsort(-dot)[:20], np.argsort(l2)[:20])}")
print(" so ONE distance function serves cosine, dot and L2 -- but only")
print(" because we normalised. Skip it and rankings silently diverge.")
return {"data": data, "q": q, "norm": norm, "n": n, "d": d}
Reading the implementation
The normalisation is not a preprocessing convenience — it is what collapses three different search problems into one. For unit vectors \(|a|=|b|=1\):
\[ |a-b|^2 = |a|^2 + |b|^2 - 2a!\cdot!b = 2 - 2a!\cdot!b \]
so Euclidean distance is a strictly decreasing function of inner product, and cosine similarity is the inner product. Ranking by any of the three gives an identical order (proofs.md P17). That is why the index can store one thing and answer all three questions.
The trap is that this equivalence holds only under normalisation. Maximum inner product search (MIPS) on un-normalised vectors is a genuinely different problem: it has no triangle inequality, so tree and graph methods lose their correctness arguments, and the standard fix is an asymmetric transformation that lifts vectors into \(d+1\) dimensions to restore a metric. If your embeddings carry magnitude information you actually want (document length, confidence), normalising discards it and you must use MIPS properly rather than pretending.
What the numbers say
Output:
8000 vectors, d=48, unit norm
||a-b||^2 == 2-2<a,b>: max deviation 7.15e-07
argsort by -dot == argsort by L2: True
so ONE distance function serves cosine, dot and L2 -- but only
because we normalised. Skip it and rankings silently diverge.
Beyond the toy
Float32 is the default and is usually wasteful. Distance rankings are robust to
substantial quantisation — that is the entire premise of PQ (P02 deep
dive) — so production systems
store float16 (2× saving, negligible recall change) or 8-bit scalar
quantisation (4×) for the resident copy and keep float32 only for re-ranking.
Since this workload is memory-bound at ~0.25 FLOP/byte, halving the bytes very
nearly halves the latency, which makes precision the highest-leverage knob in
the system before any algorithmic change.
Block 2 — Relative contrast
Teaches: measure how hard your dataset is BEFORE benchmarking an index
The problem. Every ANN paper reports recall on a benchmark dataset. Almost none report whether the dataset had a findable nearest neighbour in the first place. Relative contrast is the diagnostic that tells you, and it costs one pass over the data.
@block(2, "Relative contrast", "measure how hard your dataset is BEFORE benchmarking an index")
def b2(s, show):
data, q = s["data"], s["q"]
def rc(data, q, k=10):
dist = np.sqrt(np.maximum(0, 2 - 2 * (data @ q.T)))
srt = np.sort(dist, axis=0)
return float(np.mean(dist.mean(axis=0) / srt[0]))
r = rc(data, q)
if show:
print(f" RC = mean distance / nearest distance = {r:.3f}")
for dd in (8, 48, 256):
sub = s["norm"](rng.standard_normal((3000, dd), dtype=np.float32))
sq = s["norm"](rng.standard_normal((32, dd), dtype=np.float32))
print(f" d={dd:>4}: RC={rc(sub, sq):.3f}")
print(" RC falls toward 1 with dimension: every point equidistant, no")
print(" gradient for greedy search. RC predicts difficulty; d does not.")
return {"rc": r}
Reading the implementation
Relative contrast is the ratio of the mean distance to the nearest distance:
\[ C = \frac{\bar{d}}{d_{\min}} \]
As \(C \to 1\), every point is roughly equidistant from the query and there is no nearest neighbour to find — not because the algorithm is weak but because the data carries no signal at that dimensionality. He, Kumar and Chang showed that the difficulty of nearest-neighbour search is essentially a function of this number, and that it degrades with dimension for any distribution whose components are independent.
The implementation detail that matters: compute this on the full data with the actual metric. An earlier version of this file computed contrast on a 2000-point subsample using similarity ratios and returned ≈1.30 for every configuration — a broken metric that looked plausible. The fix was to use proper Euclidean distances over the whole set, and it changed the conclusions.
What the numbers say
Output:
RC = mean distance / nearest distance = 1.439
d= 8: RC=3.693
d= 48: RC=1.410
d= 256: RC=1.130
RC falls toward 1 with dimension: every point equidistant, no
gradient for greedy search. RC predicts difficulty; d does not.
Beyond the toy
- Intrinsic dimension is the companion diagnostic. Real embeddings from a trained model occupy a manifold of far lower dimension than their ambient size — a 768-dim CLIP embedding might have intrinsic dimension 20--40 — which is precisely why ANN works at all on real data and fails on uniform random data of the same width. Estimate it with the two-NN or MLE estimators before concluding an index is broken.
- The synthetic-data trap. The clustered generator in this file originally produced uniform data because the cluster spread \(\sigma\sqrt{d} = 2.0\) swamped unit-norm centres, giving contrast 1.393 against uniform's 1.356. Any synthetic benchmark needs this check, and the file now warns when \(\sigma\sqrt{d} > 0.7\).
- Practical use. If contrast is near 1 on your production queries, the
correct action is not to tune
ef— it is to fix the embedding, add a re-ranking stage with a richer model, or accept that top-k is meaningless and return a diversified set instead.
Block 3 — Brute force = the oracle
Teaches: you cannot measure recall without exact ground truth
The problem. Recall is defined against exact ground truth. Without an oracle there is no number, and "it returns plausible results" is not a measurement. This block builds the oracle and, incidentally, the baseline that the index must beat.
@block(3, "Brute force = the oracle", "you cannot measure recall without exact ground truth")
def b3(s, show):
data, q = s["data"], s["q"]
def brute(qv, k=10):
sims = data @ qv
idx = np.argpartition(-sims, k)[:k]
return idx[np.argsort(-sims[idx])].tolist()
t0 = time.perf_counter()
truth = [brute(v) for v in q]
ms = (time.perf_counter() - t0) / len(q) * 1e3
if show:
naive = sorted(range(s["n"]), key=lambda i: -float(data[i] @ q[0]))[:10]
print(f" agrees with a naive sort: {naive == truth[0]}")
print(f" {ms:.3f} ms/query, {1000/ms:>6.0f} qps, recall 1.0 BY CONSTRUCTION")
print(f" cost model: {s['n']}x{s['d']} = {s['n']*s['d']:,} MACs, "
f"{s['n']*s['d']*4/1e6:.1f} MB streamed -- one BLAS call, prefetchable")
return {"brute": brute, "truth": truth, "brute_ms": ms}
Reading the implementation
Brute force is \(O(Nd)\) per query with perfect recall and, crucially, a known cost — you can compute it exactly in advance from the corpus size and dimension, which no approximate method allows. It is also embarrassingly parallel, cache- friendly (a sequential scan, unlike the graph's pointer chasing), and trivially correct.
That combination is why brute force is not a strawman. On a GPU, scanning 1M × 768 fp16 vectors is 1.5 GB at ~3 TB/s ≈ 0.5 ms — faster than most CPU ANN implementations, and exact. The regime where an index is genuinely necessary starts higher than most people assume, and the honest version of "we need a vector database" is usually "we need one above N vectors, and here is N".
What the numbers say
Output:
agrees with a naive sort: True
0.129 ms/query, 7776 qps, recall 1.0 BY CONSTRUCTION
cost model: 8000x48 = 384,000 MACs, 1.5 MB streamed -- one BLAS call, prefetchable
Beyond the toy
The ground truth itself becomes a cost at scale: computing exact top-k for 10k queries against 100M vectors is \(10^{12}\) distance computations. Standard practice is to compute it once on a GPU and ship it with the benchmark, which is what ANN-Benchmarks and the BigANN challenge datasets do. A benchmark that recomputes ground truth with an approximate method — and it happens — measures agreement between two approximations, not recall.
Block 4 — recall@k
Teaches: the contract: what exactly did approximation cost?
The problem. "Recall" is used loosely enough to be meaningless. This block pins it down, because every later number on this page is denominated in it.
@block(4, "recall@k", "the contract: what exactly did approximation cost?")
def b4(s, show):
def recall_at_k(got, want, k):
return len(set(got[:k]) & set(want[:k])) / k
if show:
print(f" perfect: {recall_at_k([1,2,3],[1,2,3],3):.2f} "
f"two of three: {recall_at_k([1,2,9],[1,2,3],3):.2f} "
f"none: {recall_at_k([7,8,9],[1,2,3],3):.2f}")
print(" always against EXACT ground truth, same metric, same data, stated k")
return {"recall_at_k": recall_at_k}
Reading the implementation
\[ \text{recall@}k = \frac{|,\text{returned}_k \cap \text{true}_k,|}{k} \]
Three details that separate a usable metric from a misleading one:
- Set intersection, not order. Recall@10 does not care whether the true nearest neighbour came back at rank 1 or rank 10. If order matters for your product, measure NDCG or MRR instead — they are different questions and a system can be excellent at one and poor at the other.
- Ties. With duplicate or near-duplicate vectors, "the true top-k" is not unique and naive recall under-reports. Benchmarks handle this by comparing against a distance threshold rather than an ID set.
- The denominator is \(k\), not the number returned. A method that returns 3 results of which all 3 are correct has recall@10 of 0.3, not 1.0. This is exactly the post-filter failure mode in P03, and defining recall this way is what makes it visible.
What the numbers say
Output:
perfect: 1.00 two of three: 0.67 none: 0.00
always against EXACT ground truth, same metric, same data, stated k
Beyond the toy
Recall@k against exact neighbours is an intrinsic metric — it measures the index against itself, not against the task. A retrieval system with recall@10 = 0.85 may be indistinguishable from one at 0.99 in end-to-end product terms, because the ranker downstream reorders anyway (P08) and because the embedding's own error dwarfs the index's. Always pair the intrinsic number with an extrinsic one before spending weeks moving it.
Block 5 — A random graph, and why it fails
Teaches: the naive design, measured, so the fix is motivated
The problem. It is tempting to think a graph search works because the graph is connected. This block builds a connected graph with the right degree and the right number of edges, and shows it produces almost nothing — which is what makes the next block's construction motivated rather than arbitrary.
@block(5, "A random graph, and why it fails", "the naive design, measured, so the fix is motivated")
def b5(s, show):
data, n = s["data"], s["n"]
deg = 16
g = [rng.choice(n, deg, replace=False).tolist() for _ in range(n)]
def greedy(qv, graph, entry=0, ef=32):
seen = {entry}; d0 = 1 - float(data[entry] @ qv)
cand = [(d0, entry)]; res = [(-d0, entry)]; nd = 1
while cand:
d, node = heapq.heappop(cand)
if -res[0][0] < d and len(res) >= ef: break
for nb in graph[node]:
if nb in seen: continue
seen.add(nb); dn = 1 - float(data[nb] @ qv); nd += 1
if len(res) < ef or dn < -res[0][0]:
heapq.heappush(cand, (dn, nb)); heapq.heappush(res, (-dn, nb))
if len(res) > ef: heapq.heappop(res)
return [i for _, i in heapq.nsmallest(10, [(-a, b) for a, b in res])], nd
rec = np.mean([s["recall_at_k"](greedy(v, g)[0], t, 10)
for v, t in zip(s["q"], s["truth"])])
if show:
print(f" {deg} RANDOM edges per node, greedy beam ef=32")
print(f" recall@10 = {rec:.4f} <- near useless, and that is the point")
print(" a random graph has no locality, so greedy descent has nothing to")
print(" descend. The fix is not a bigger beam; it is better EDGES.")
return {"greedy": greedy, "random_recall": float(rec)}
Reading the implementation
greedy is a best-first search with a bounded candidate set: pop the closest
unexpanded node, evaluate its neighbours, keep the best ef seen. Two lines carry
the algorithm:
if -res[0][0] < d and len(res) >= ef: break— the termination rule. Stop when the closest unexplored candidate is farther than the worst result already held. This is the standard best-first bound, and it is what makes the search adaptive: easy queries terminate early, hard ones explore more.nd += 1counts distance computations. That counter is the real cost metric — wall-clock time varies with implementation and machine, but distance computations are the invariant that lets you compare an index against brute force's \(N\) honestly. Reporting speedup in terms ofndis what makes P15's capacity model possible.
What the numbers say
Output:
16 RANDOM edges per node, greedy beam ef=32
recall@10 = 0.0609 <- near useless, and that is the point
a random graph has no locality, so greedy descent has nothing to
descend. The fix is not a bigger beam; it is better EDGES.
Beyond the toy
Greedy descent on a graph is a local algorithm: it only ever moves downhill in
distance, so it terminates at a local minimum of the distance function restricted
to the graph. A random graph has no correlation between edge structure and
geometry, so almost every node is a local minimum and the search stops
immediately. The fix is therefore not more search (a bigger ef barely helps —
worth measuring) but better edges.
The theoretical frame is Kleinberg's small-world result: a lattice augmented with long-range links whose length distribution follows \(P(u\to v) \propto d(u,v)^{-r}\) is navigable by a greedy decentralised algorithm in \(O(\log^2 n)\) steps only when \(r\) equals the lattice dimension. Too few long links and you cannot cross the space; too many and you cannot home in. NSW's insertion procedure produces that distribution approximately and for free, which is the elegance of the next block.
Block 6 — NSW: edges that mean something
Teaches: insert by searching what you have built so far
The problem. Build a graph whose edges encode geometry, using nothing but the search procedure you already have. The construction is three lines and the reason it works is a genuinely deep result.
@block(6, "NSW: edges that mean something", "insert by searching what you have built so far")
def b6(s, show):
data, n = s["data"], s["n"]
M, efC = 12, 60
graph = [[] for _ in range(n)]
order = rng.permutation(n)
entry = int(order[0])
for count, node in enumerate(order[1:], 1):
node = int(node)
found, _ = s["greedy"](data[node], graph, entry, efC)
cands = sorted(found, key=lambda i: 1 - float(data[i] @ data[node]))[:M]
graph[node] = cands
for c in cands:
graph[c].append(node)
if len(graph[c]) > 2 * M: # degree cap
dd = data[graph[c]] @ data[c]
graph[c] = [graph[c][i] for i in np.argsort(-dd)[:2 * M]]
if show:
edges = sum(len(x) for x in graph)
print(f" M={M} efConstruction={efC}, {edges/n:.1f} edges/node")
print(" early insertions land in a near-empty graph, so their edges are")
print(" LONG. Those accidental long links are the small-world property.")
return {"graph": graph, "entry": entry, "M": M}
Reading the implementation
The insertion rule is: to insert a point, search the graph you have built so far, and link to what you find. That single recursive idea produces navigability without any global structure:
- Points inserted early, when the graph is sparse and the search is inaccurate, get long-range links — they connect regions that are far apart.
- Points inserted late, when the graph is dense and the search is accurate, get short-range links — they refine the local neighbourhood.
The result is a length distribution across scales, which is exactly Kleinberg's navigability condition arrived at by accident of construction. Nobody computes it; it falls out of using the search to build the index.
Mis the degree bound and the memory knob:M × 4 bytesper node for the edge list. It is also the recall knob that most benchmarks under-report — sweepingMmoves the memory/recall curve far more than sweepingefmoves the latency/recall one.- Edges are added bidirectionally, and this matters: a directed graph built this way has poor in-degree for early nodes and the search cannot get back out of a region it descends into.
- HNSW's addition on top of this is the layer hierarchy — long links in upper layers, taken first — which reduces the number of distance computations to reach the right region, not the number of hops.
What the numbers say
Output:
M=12 efConstruction=60, 17.1 edges/node
early insertions land in a near-empty graph, so their edges are
LONG. Those accidental long links are the small-world property.
Beyond the toy
- Pruning is the missing piece. Real HNSW does not keep the
Mnearest neighbours; it applies a heuristic that keeps an edge \(u\to v\) only if no already-selected \(w\) is closer to \(v\) than \(u\) is. That is a relative-neighbourhood-graph relaxation, and it produces diverse edges — one per direction rather thanMedges into the same dense cluster. Without it, recall on clustered data is substantially worse. - Construction cost is \(O(N \log N \cdot M)\) distance computations, which for 100M vectors is hours on many cores. This makes index build a batch job (P06) and makes incremental insertion valuable, which is why the NSW-style construction — which is naturally incremental — beat the tree methods that require a global build.
- The entry point matters more than it looks. A fixed entry point means every search starts in the same place and the nodes near it are traversed on every query — hot in cache, but also a concentration of load. HNSW's top layer solves this; some implementations use multiple random entry points.
Block 7 — The efSearch knob
Teaches: recall is concave in ef; pick the recall FIRST
The problem.
efis the dial between latency and recall, and its shape decides how you configure the system. This block measures the curve rather than assuming it, and the shape has a direct operational consequence.
@block(7, "The efSearch knob", "recall is concave in ef; pick the recall FIRST")
def b7(s, show):
if show:
print(f" {'ef':>5}{'recall@10':>11}{'ms':>9}{'dists/q':>10}{'vs brute':>10}")
for ef in (10, 32, 64, 128):
t0 = time.perf_counter(); recs = []; nds = 0
for v, t in zip(s["q"], s["truth"]):
got, nd = s["greedy"](v, s["graph"], s["entry"], ef)
recs.append(s["recall_at_k"](got, t, 10)); nds += nd
ms = (time.perf_counter() - t0) / len(s["q"]) * 1e3
print(f" {ef:>5}{np.mean(recs):>11.4f}{ms:>9.3f}"
f"{nds/len(s['q']):>10.0f}{s['brute_ms']/ms:>9.2f}x")
return {}
Reading the implementation
ef (the search-time beam width) bounds the candidate set: the search keeps the
ef best nodes seen and terminates when no unexplored candidate can improve them.
Larger ef means more distance computations and a lower chance of getting stuck
in a local minimum.
The critical property is that recall is concave in ef — the first doubling
buys a lot, the fourth buys almost nothing, while cost grows roughly linearly. The
operational consequence is the reverse of how people usually configure it:
Pick the recall you need first, then find the smallest
efthat reaches it. Configuringefto a latency budget and reporting whatever recall results is how systems end up paying 4× the cost for 1% of recall.
What the numbers say
Output:
ef recall@10 ms dists/q vs brute
10 0.3344 0.223 263 0.58x
32 0.6219 0.540 595 0.24x
64 0.7891 0.875 978 0.15x
128 0.9328 1.540 1672 0.08x
Beyond the toy
efis a per-query dial, not a global one. Since the search terminates adaptively, you can raiseeffor queries the system judges hard (low top-1 similarity, high tail-index) and lower it for easy ones. That is real, deployed practice and it buys a better recall-per-millisecond than any global setting.- The two-factor speedup model in the assembly is what makes this predictable rather than empirical: speedup is (fraction of vectors examined) × (per-vector cost ratio), and predicting 0.15× against a measured 0.15× is what turns a benchmark into a model you can extrapolate with.
- Recall is not the tail. The mean recall at
ef=64 hides that some queries return nothing useful. Report the distribution — recall@10 at p10 across queries — because a system with mean recall 0.9 and 5% of queries at recall 0.0 is a very different product from one with uniform 0.9.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nThe seven blocks are an ANN index. Now the measurement that matters.\n")
ef = 64
t0 = time.perf_counter(); recs = []; nds = 0
for v, t in zip(s["q"], s["truth"]):
got, nd = s["greedy"](v, s["graph"], s["entry"], ef)
recs.append(s["recall_at_k"](got, t, 10)); nds += nd
ms = (time.perf_counter() - t0) / len(s["q"]) * 1e3
dpq = nds / len(s["q"])
algo = s["n"] / dpq
const = (ms * 1e6 / dpq) / (s["brute_ms"] * 1e6 / s["n"])
print(f" at efSearch={ef}: recall {np.mean(recs):.4f}, {ms:.3f} ms/query")
print(f" random-graph recall was {s['random_recall']:.4f} -> NSW is "
f"{np.mean(recs)/max(s['random_recall'],1e-9):.0f}x better on the SAME search code.\n")
print(" THE TWO-FACTOR MODEL -- decompose before you tune anything:")
print(f" algorithmic win : {s['n']:,} / {dpq:.0f} distances = {algo:>7.1f}x fewer")
print(f" constant factor : {ms*1e6/dpq:>7.1f} ns/dist (python) vs "
f"{s['brute_ms']*1e6/s['n']:.1f} ns (BLAS) = {const:>6.1f}x slower each")
print(f" predicted speedup: {algo:.1f} / {const:.1f} = {algo/const:.2f}x")
print(f" measured speedup : {s['brute_ms']/ms:.2f}x")
print("\n The algorithm is RIGHT and the implementation is WRONG. Those are")
print(" different bugs. Only the distance counter can tell you which you have.")
print(f"\n Dataset difficulty: RC = {s['rc']:.3f}. Report it with every recall")
print(" number, or the result does not transfer to anyone else's corpus.")
print("\n Built: metric choice -> RC -> oracle -> recall -> random graph ->")
print(" NSW -> ef sweep -> the decomposition.")
print(" Missing, and on the project page: HNSW layers (m6), Algorithm 4 (m7),")
print(" a compiled inner loop (m8), persistence (m9), hnswlib comparison (E12).")
Output:
The seven blocks are an ANN index. Now the measurement that matters.
at efSearch=64: recall 0.7891, 0.895 ms/query
random-graph recall was 0.0609 -> NSW is 13x better on the SAME search code.
THE TWO-FACTOR MODEL -- decompose before you tune anything:
algorithmic win : 8,000 / 978 distances = 8.2x fewer
constant factor : 915.3 ns/dist (python) vs 16.1 ns (BLAS) = 56.9x slower each
predicted speedup: 8.2 / 56.9 = 0.14x
measured speedup : 0.14x
The algorithm is RIGHT and the implementation is WRONG. Those are
different bugs. Only the distance counter can tell you which you have.
Dataset difficulty: RC = 1.439. Report it with every recall
number, or the result does not transfer to anyone else's corpus.
Built: metric choice -> RC -> oracle -> recall -> random graph ->
NSW -> ef sweep -> the decomposition.
Missing, and on the project page: HNSW layers (m6), Algorithm 4 (m7),
a compiled inner loop (m8), persistence (m9), hnswlib comparison (E12).
The design space
Every ANN index is a bet about where you can afford to lose information. The four families make different bets, and they fail differently.
| Family | Representative | Build | Query | Memory / vector | Fails when |
|---|---|---|---|---|---|
| Graph | HNSW, NSW, Vamana | \(O(N \log N \cdot M)\) | \(O(\log N)\) hops, ef candidates | M×4--8 B edges + raw vector | Random access is expensive (disk, cold cache) |
| Inverted list | IVF-Flat, IVF-PQ | k-means over \(N\) | scan nprobe of nlist cells | codebook + code bytes | Clusters unbalanced, or the query lands on a boundary |
| Quantisation | PQ, OPQ, ScaNN, RaBitQ | train codebooks | asymmetric distance from a LUT | 8--64 B/vector | Recall ceiling set by quantisation error, not by search |
| Hashing / trees | LSH, Annoy | \(O(NL)\) | probe buckets | tables × \(N\) | High intrinsic dimension; needs many tables |
In practice the production answer is a composite: IVF to narrow, PQ to
compress, a graph over centroids to route, exact re-ranking on the raw vectors of
the top few hundred. FAISS's IVF65536_HNSW32,PQ64 is that sentence as a
factory string.
Why the graph works at all
Greedy descent on a proximity graph is a decentralised search: at each step, move
to the neighbour closest to the query. It terminates at a local minimum, so the
entire design problem is making local minima rare. NSW achieves that by
inserting nodes in random order and linking each to its M nearest
already-inserted nodes — long-range links form early while the graph is sparse,
short-range links late, producing a small-world graph of \(O(\log N)\) diameter.
HNSW adds explicit layers so long hops are taken first, cutting the number of
distance computations rather than the number of hops.
Block 3's result — a random graph gives 0.06 recall — is the control that proves connectivity is not the active ingredient. Navigability is.
Latency, bandwidth and the memory hierarchy
Graph search is pointer chasing, the worst access pattern the memory hierarchy has. Each hop is a dependent load: you cannot prefetch hop \(k+1\) until hop \(k\) resolves.
Using this machine's measured constants (numbers.md):
| Access | Measured here | Relative | Consequence for one hop |
|---|---|---|---|
| L1 hit | 0.91 ns | 1× | already cached — free |
| L2 hit | 5.94 ns | 6.5× | small, hot index |
| DRAM random | 121.10 ns | 133× | the normal case above a few hundred MB |
| NVMe read | ~20--100 µs | ~10⁵× | DiskANN's regime |
| SATA SSD | ~100--200 µs | ||
| HDD seek | ~10 ms | ~10⁸× | graph search is simply impossible |
A search touching 600 distinct vectors of 128 dims × 4 B reads ~300 KB scattered across memory: ~600 line-missing loads at 121 ns ≈ 73 µs of pure latency against perhaps 5 µs of arithmetic. The search is latency-bound — not bandwidth-bound and nowhere near compute-bound — which is why the roofline efficiency in P15 comes out below 1%.
Three consequences follow directly:
- Layout dominates. Storing vectors in visit order, or inlining a compressed code beside each edge list, converts random reads into sequential ones. This makes quantisation a latency optimisation, not merely a memory one.
- TLB reach matters. A 100 GB index with 4 KiB pages needs 25M page-table entries; a 1536-entry TLB covers 6 MB. Huge pages (2 MiB) extend reach 512× and are routinely worth 10--30% — P12 block 2's mechanism applied to a data structure.
- Batching queries helps for a non-obvious reason. It barely raises arithmetic intensity; what it creates is memory-level parallelism, many independent dependent-load chains in flight at once, which is the only way to hide 121 ns.
Hardware: CPU, GPU, SSD
- CPU is the natural home for graph search: branchy, pointer-heavy, latency-sensitive, helped by a large out-of-order window and hardware prefetchers — which the Sattolo-cycle trap in numbers.md §14 shows are easily fooled into flattering a benchmark.
- GPU is the natural home for brute force. A 1M × 768 fp16 matrix is 1.5 GB; scanning it at ~3 TB/s takes 0.5 ms. Below a few million vectors, GPU brute force beats CPU ANN on latency and is exact — which is most of why FAISS-GPU exists. Graph traversal maps badly to SIMT because each query diverges.
- SSD changes the algorithm, not the parameters. DiskANN/Vamana prune with an α-RNG rule to get a small diameter, keep PQ codes of everything in RAM to guide the walk, and read full vectors for only a few hundred candidates. That design exists because NVMe random reads are ~50 µs and parallel (deep queues), whereas HDD random reads are 10 ms and effectively serial.
Advanced algorithms and data structures
- Product quantisation splits a \(d\)-dim vector into \(m\) subvectors, k-means each subspace to 256 centroids, stores \(m\) bytes. Distance becomes a sum of \(m\) lookups in an L1-resident table. OPQ learns a rotation first, because PQ assumes subspace independence and raw embedding dimensions are not independent.
- ScaNN's anisotropic loss notes that for maximum-inner-product search, quantisation error parallel to the query matters far more than orthogonal error, and weights training accordingly — a rare case where changing the loss, not the structure, buys large recall.
- Relative contrast (He, Kumar & Chang) is what block 2 computes: \(C = \bar{d}/d_{\min}\). As \(C \to 1\) no algorithm can separate the nearest neighbour from the crowd, because the data has no signal. Measure it before blaming an index.
- α-RNG pruning — keep edge \(u \to v\) unless some \(w\) is closer to both — is a relative-neighbourhood-graph relaxation that bounds out-degree while preserving navigability. It is the theoretical spine under both Vamana and HNSW's pruning heuristic.
How this connects to the rest of the track
- P03 wraps this index in a database: filters, persistence, planning.
- P08 uses it as a candidate generator; its block 7 shows the index's recall@C is a hard ceiling on the recommender's recall@k.
- P01's attention is a soft version of the same lookup and meets the same high-dimensional contrast problem.
- P14 explains the ≈0.25 FLOP/byte intensity that makes this memory-bound.
- P04's Bloom filter is the same idea as a PQ code: a cheap approximate test in fast memory that avoids an expensive exact one in slow memory.
Failure modes at scale
- Deletions break connectivity. Tombstoning without repair slowly disconnects regions; most systems rebuild segments instead (P03, P04 compaction).
- Distribution shift after an embedding upgrade invalidates centroids and graph simultaneously. There is no incremental fix, only a rebuild.
- Recall measured on indexed vectors overstates production recall, because real queries are out-of-distribution relative to the corpus.
- The p99 is not p50 × constant. Hop counts have a long tail; a query landing in a sparse region takes 5--10× the median.
Primary sources
- Malkov & Yashunin, Efficient and Robust ANN Search Using HNSW (2016).
- Jégou, Douze & Schmid, Product Quantization for NN Search (2011).
- Subramanya et al., DiskANN (NeurIPS 2019) — the SSD design.
- Guo et al., Anisotropic Vector Quantization (ScaNN, ICML 2020).
- He, Kumar & Chang, On the Difficulty of Nearest Neighbor Search (ICML 2012).
- Johnson, Douze & Jégou, Billion-scale Similarity Search with GPUs (2017).
Running it
python3 handson/h02_ann.py # every block, then the assembly
python3 handson/h02_ann.py --block 3 # just block 3 and its prerequisites
python3 handson/h02_ann.py --quiet # the assembly only
What to do with this
The parameter that matters most is the one this file leaves at a default. Sweep
M (edges per node) rather than ef and you will find the memory/recall curve
that most benchmark tables omit entirely. Then re-run the contrast measurement
on a real embedding set --- CLIP or a sentence encoder --- and compare it with the
synthetic number here. If the real contrast is lower, every recall figure you
have read about that dataset is easier than it looks.
Milestones, experiments, readings and exit criteria for this project: P02 — Approximate Nearest-Neighbour Index.
P03 — Small Vector Database
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P03 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 88 hours · Weeks 27–34 · Stage 2 · Python with a Rust storage layer
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — The Query Planner, In Three Lines
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | An index answers queries. A database survives a power cut, accepts updates while serving, filters by metadata, and tells you what it guarantees |
| 2. Constraints | Single node. Vectors may exceed RAM. Crash at any instruction must leave a recoverable state |
| 3. Naive design | Yours. Almost everyone designs: pickle the index to disk on a timer, keep metadata in a dict, filter after search |
| 4. Predicted failure | Three failures are waiting: recovery time at scale, the filtered-search recall cliff, and the fact that a graph index cannot be updated in place cheaply. Predict all three magnitudes |
| 5. Minimal implementation | Append-only log + full in-memory index + rebuild on start |
| 6. Correctness | Crash at any point → recovered state equals the last acknowledged write |
| 7. Instrumentation | Recovery time, storage amplification, per-op latency, filtered recall |
| 8. Baseline | The naive version from step 5. Measure it before you improve it |
| 9. Bottleneck | Is recovery dominated by I/O or by index reconstruction? Predict, then measure |
| 10. Hypothesis | Pre-filter beats post-filter below a selectivity threshold. Predict the threshold |
| 11. Modification | Implement the other filtering strategy |
| 12. Experiment | Selectivity sweep, both strategies, recall and latency |
| 13. Failure analysis | Where the recall goes when the filter fights the graph |
| 14. Report | Including the recovery-time number that motivates P04 |
Why This Project Matters
The gap between "I have an HNSW index" and "I have a vector database" is where every real engineering problem lives, and it is a gap most people never cross because the index is the interesting part.
This project's real job in the journey is to make you want Project 4. You will build the naive persistence layer — append-only file, full index rebuild on start — and measure its recovery time at one million vectors. It will be minutes. That number is what makes sparse indexes, Bloom filters, and compaction feel like solutions to a problem you have rather than features in a paper you read. This is the roadmap's most deliberate application of "naive design first", and it costs about six hours of rework on purpose.
The second reason: filtering is where vector search actually breaks in production, and you already ship filtered vector search on OpenSearch. After this project you will know exactly why your p99 spikes when a filter is narrow.
Prerequisites
- P02 complete — this is a hard dependency; the index is the core of the database
- File I/O:
read/write/fsync, what the page cache is, whatmmapactually does - Basic concurrency: mutexes, reader-writer locks, and why a graph index is hostile to both
Duration and Size
Medium, 88 hours, 8 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Append-only vector + metadata log, in-memory HNSW rebuilt at startup, insert/search/delete with tombstones, post-filtering. Recovery measured. | 40 |
| Standard | + segment-based storage, mmap, WAL with fsync policy, snapshots, compaction, pre-filtering with a filter-aware graph walk, versioning, a reader-writer concurrency model, batching, a two-strategy query planner. | 88 |
| Extension | Disk-resident graph in the DiskANN style, with a measured RAM/recall/latency frontier at a size that does not fit in memory. | +35–50 |
Central Technical Questions
- What does "durable" mean, precisely? After which system call is a write
recoverable, and what exactly does
fsyncguarantee on your filesystem? - Why can't you update a graph index in place? Deletion in HNSW is not a matter of removing a node — trace why.
- Pre-filter or post-filter? There is a selectivity threshold. Derive it, then measure where the derivation is wrong.
- What does the filter do to graph connectivity? A filtered graph walk is a walk on a subgraph you did not build, and that subgraph may be disconnected.
- What is your consistency model? Can a search see a half-inserted vector? Write it down as a sentence before you write the code.
- What is your storage amplification, and where did it go — tombstones, alignment, the graph, or the metadata?
Architecture
Write your naive design first.
┌──────────────── write path ────────────────┐
insert(id,vec,md) ─► WAL (append + fsync policy) ─► memtable ──┼─► flush ─► segment N
(vectors + │ ├ vectors.bin (mmap, fixed stride)
metadata) │ ├ meta.bin (id → offset, attrs)
│ ├ graph.bin (HNSW adjacency)
│ └ tomb.bin (deleted ids)
┌──────────────── read path ─────────────────┘
search(q,k,filter) ─► planner ─┬─ pre-filter: build allowed-set, walk graph restricted to it
└─ post-filter: search top-K, filter, K = k/selectivity
│
merge across segments ─► apply tombstones ─► top-k
The filtering problem, derived
This is the section worth the most.
Post-filtering runs an unfiltered ANN search for the top \(K\), then discards non-matching results. If the filter is independent of similarity and selects a fraction \(s\) of the corpus, the expected number of surviving results is \(Ks\). To return \(k\) results you need
\[ K \ge k/s \]
Computed for \(k = 10\) over a 1M-vector corpus:
| selectivity \(s\) | \(K\) needed | fraction of corpus scanned |
|---|---|---|
| 0.5 | 20 | 0.00% |
| 0.1 | 100 | 0.01% |
| 0.05 | 200 | 0.02% |
| 0.01 | 1,000 | 0.10% |
| 0.001 | 10,000 | 1.00% |
| 0.0001 | 100,000 | 10.00% |
At \(s = 10^{-4}\) you are searching with efSearch ≥ 100,000, which is not a graph
walk any more — it is brute force with a worse constant factor. This is the p99 cliff
you have seen in production. And the table understates it: the independence
assumption fails badly when the filter correlates with similarity (e.g. filtering to
one publisher when that publisher's articles cluster in embedding space), and in the
adversarial case no \(K\) suffices because all matching items lie outside the
unfiltered top-\(K\) entirely.
Pre-filtering restricts the graph walk to matching nodes. It has no over-fetch problem, but it introduces a worse one: the induced subgraph may be disconnected. Your carefully built navigable graph guaranteed connectivity over all \(n\) nodes; it guarantees nothing about the subgraph induced by an arbitrary predicate. Greedy search on a disconnected subgraph reaches one component and stops — the same failure mode you diagnosed in P02's clustered-data ceiling, now caused by a query rather than by the data.
Brute force over the filtered set costs \(O(sn)\) and is exact. At \(s = 10^{-4}\) on 1M vectors that is 100 distance computations — faster than either alternative and perfectly accurate.
So the planner has three strategies and the crossovers are empirical. Deriving the crossover and then measuring where the derivation is wrong is the central experiment of this project.
Why deletion is hard
An HNSW node is referenced by the adjacency lists of its neighbours. Removing it requires either patching every in-edge (you do not have a reverse index, and building one doubles memory) or leaving a dangling reference. So every practical implementation uses tombstones: mark deleted, keep the node in the graph as a routing waypoint, filter at result time.
The consequences you must measure:
- Recall drifts down with churn, because tombstoned nodes occupy beam slots without
producing results. Effective
efSearchfalls to roughly \(ef \cdot (1 - \text{tombstone fraction})\). - Space amplification grows monotonically until compaction.
- Compaction means rebuilding the graph for that segment, which is the most expensive operation in the system. Its cost is why segment size is a real design decision and not an arbitrary constant.
Showcase — The Query Planner, In Three Lines
Before eight weeks of vector database, spend ten minutes on the decision the planner exists to make. Costs are the measured ones from numbers: 13.3 ns per BLAS distance, 899 ns per interpreted graph hop.
# P03 -- the filtering planner, decided by arithmetic rather than by taste.
import math
N, k = 1_000_000, 10
COST_PER_DIST_NS, GRAPH_HOP_NS = 13.3, 899.0 # measured, numbers.md
for s in (0.5, 0.1, 0.01, 0.001, 0.0001):
post_K = math.ceil(k/s) # over-fetch, then discard
post = post_K * GRAPH_HOP_NS # graph walk cost ~ ef
brute = s*N * COST_PER_DIST_NS # exact scan of the matching set
winner = "post-filter" if post < brute else "BRUTE FORCE (exact!)"
print(f"s={s:<8} post-filter needs K={post_K:>7,} ({post/1e6:>7.2f} ms) "
f"brute over {s*N:>8,.0f} items ({brute/1e6:>6.2f} ms) -> {winner}")
# solve for the crossover: (k/s)*HOP == s*N*DIST => s = sqrt(k*HOP/(N*DIST))
xover = math.sqrt(k*GRAPH_HOP_NS/(N*COST_PER_DIST_NS))
print(f"\\nCrossover, solved: s = sqrt(k*HOP/(N*DIST)) = {xover:.4f}")
print("Below ~2.6% selectivity, EXACT brute force over the filtered set beats the")
print("approximate index. The planner is three lines of arithmetic, and a system")
print("without one falls off a cliff exactly here.")
s=0.5 post-filter needs K= 20 ( 0.02 ms) brute over 500,000 items ( 6.65 ms) -> post-filter
s=0.1 post-filter needs K= 100 ( 0.09 ms) brute over 100,000 items ( 1.33 ms) -> post-filter
s=0.01 post-filter needs K= 1,000 ( 0.90 ms) brute over 10,000 items ( 0.13 ms) -> BRUTE FORCE (exact!)
s=0.001 post-filter needs K= 10,000 ( 8.99 ms) brute over 1,000 items ( 0.01 ms) -> BRUTE FORCE (exact!)
s=0.0001 post-filter needs K=100,000 ( 89.90 ms) brute over 100 items ( 0.00 ms) -> BRUTE FORCE (exact!)
\nCrossover, solved: s = sqrt(k*HOP/(N*DIST)) = 0.0260
Below ~2.6% selectivity, EXACT brute force over the filtered set beats the
approximate index. The planner is three lines of arithmetic, and a system
without one falls off a cliff exactly here.
The third strategy is the one people forget. Below ~2.6% selectivity, an exact scan of the filtered set beats the approximate index — faster and correct. A planner that only knows pre- and post-filtering is missing the option that wins the hardest case. This is E3 in miniature.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo, API design (insert/get/search/delete/flush), storage format doc written first | 5 | Format documented with byte offsets before any code |
| 2 | Naive: append-only log, full rebuild on start | 7 | Recovery time measured at 10⁴, 10⁵, 10⁶ vectors. Record these; they justify P04 |
| 3 | Metadata store: typed attributes, and an inverted index for equality predicates | 7 | Filter predicates evaluate correctly and in measured time |
| 4 | Post-filtering with adaptive over-fetch | 6 | Selectivity sweep run; the cliff reproduced |
| 5 | Pre-filtering with a filter-aware walk | 9 | Works, and the disconnection failure is observed and measured, not just anticipated |
| 6 | Query planner choosing among three strategies by estimated selectivity | 6 | Picks correctly on ≥90% of a test workload; mispredictions logged |
| 7 | Segment-based storage + mmap + fixed-stride vector file | 9 | Vectors read without a full load; RSS measured against file size |
| 8 | WAL with a configurable fsync policy (never / interval / every write) | 7 | Durability/throughput trade measured across all three |
| 9 | Snapshots + recovery from snapshot + WAL replay | 7 | Recovery time drops by a stated factor vs milestone 2 |
| 10 | Tombstones + compaction + segment merge | 8 | Space amplification returns to ~1.0 after compaction |
| 11 | Concurrency: reader-writer model, snapshot isolation for readers | 8 | No torn reads under a concurrent write workload; documented consistency model |
| 12 | Experiments + report | 9 | All rows filled |
Concepts To Study
- Durability primitives:
writevsfsyncvsfdatasync; the page cache; write barriers; why a successfulwrite()guarantees nothing - Write-ahead logging: the log-before-data rule and why it is sufficient for crash-atomicity
- mmap: page faults as the loading mechanism, the OS as your buffer pool, and the reasons mmap is a contested choice for databases
- Append-only and immutability: why immutable segments make concurrency easy and space accounting hard
- Tombstones and compaction: logical vs physical deletion
- Segment/LSM-adjacent layout: why databases split into many immutable files
- Snapshot isolation: readers see a consistent version; how versioning implements it
- Query planning: cardinality estimation, and why a wrong estimate is worse than no plan
- Storage amplification: bytes on disk / bytes of live data, and every place it hides
- Checksums: CRC32C vs xxHash; per-block vs per-file
Primary-Source Readings
Budget: 11 hours.
| Reading | Why | Hours |
|---|---|---|
| Subramanya, S. J. et al. DiskANN. NeurIPS 2019 | The disk-resident answer; read before designing your segment layout | 2 |
| Crotty, A., Leis, V., Pavlo, A. Are You Sure You Want to Use MMAP in Your Database Management System? CIDR 2022 | Read after milestone 7 and re-examine your choice honestly | 1.5 |
| Mohan, C. et al. ARIES. ACM TODS 17(1), 1992 | Read §1–3 only. WAL, LSN, redo/undo — the vocabulary of crash recovery | 2.5 |
| Gollapudi, S. et al. Filtered-DiskANN. WWW 2023 | Filtered vector search done properly; read after your own E3 | 1.5 |
| Wang, J. et al. Milvus: A Purpose-Built Vector Data Management System. SIGMOD 2021 | A real system's segment/compaction architecture | 1.5 |
| Pinecone / Weaviate / Qdrant engineering docs on filtering | Practitioner accounts of the same cliff you measured | 1 |
| Kleppmann, M. Designing Data-Intensive Applications, ch. 3 | Storage engines; the clearest available overview | 1 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Recovery time | n ∈ {10⁴,10⁵,10⁶}, naive vs snapshot+WAL | Naive is linear in n with a large constant. State the constant |
| E2 | fsync policy | never / 100 ms / every write | Throughput ratio between the extremes — predict the order of magnitude |
| E3 | Filtering strategy × selectivity | s ∈ {0.5,0.1,0.05,0.01,10⁻³,10⁻⁴} × {pre, post, brute} | Two crossover points. Predict both |
| E4 | Filtered recall | recall@10 vs s, per strategy | Pre-filter recall should fall as s falls — predict where |
| E5 | Write throughput | batch size ∈ {1,10,100,1000} | Where does batching stop helping? |
| E6 | Update cost | in-place vs delete+insert | Predict the ratio |
| E7 | Churn and compaction | 0–50% churn, before/after compaction | Recall vs tombstone fraction: linear? |
| E8 | Compaction cost | segment sizes {10⁴,10⁵,10⁶} | Cost is superlinear in segment size — predict the exponent |
| E9 | Storage amplification | over the churn workload | Decompose: tombstones / alignment / graph / metadata |
| E10 | mmap vs explicit reads | random access, working set > RAM | Predict which wins and by how much. This one surprises people |
| E11 | Concurrency scaling | 1–16 reader threads, 1 writer | Where does the writer lock start to bite? |
| E12 | Query latency under compaction | p50/p99 during vs outside compaction | The p99 during compaction is the real number. Predict the inflation |
E3 is the project's centrepiece. You have a derivation predicting the post-filter over-fetch; you have a mechanism predicting pre-filter degradation; the crossovers are where derivation meets reality.
E12 is the most production-relevant. Steady-state p99 with no compaction running is a number that does not exist in production.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Write throughput (vectors/s) | By batch size; state durability setting — a number without its fsync policy is meaningless |
| Query throughput and p50/p95/p99 | Separately for filtered and unfiltered |
| Recovery time | Cold start to first-query-served, by n and by strategy |
| Storage amplification | bytes on disk / bytes of live vector data, decomposed by cause |
| Memory: RSS vs mapped | The mmap distinction matters; report both |
| Filtered recall@k | By selectivity and strategy |
| Update cost | µs per update, and the induced recall change |
| Compaction cost | Seconds and bytes rewritten, per GB of live data |
| p99 during compaction | Reported separately, always |
Correctness Tests
- Crash consistency.
kill -9at ≥20 randomised points during a write workload; after recovery, every acknowledged write is present and no unacknowledged write appears. Automate this — it is the most valuable test in the project. - Read-your-writes within a session, at the stated consistency level.
- Deleted vectors never returned, at any efSearch, before or after compaction.
- Filter correctness: results satisfy the predicate, 100%, all three strategies.
- Brute-force agreement: for small n, filtered search matches filtered brute force exactly at high efSearch.
- Snapshot isolation: a reader holding a snapshot sees a stable view while a writer inserts and deletes underneath it.
- Checksum detection: flip a bit in every file type; each is detected, not silently served.
- Idempotent recovery: recover twice, get identical state.
- Format versioning: an old-version file is rejected with a clear error, not misparsed.
- Compaction preserves semantics: full query-result equality before and after, modulo tombstoned ids.
Failure Tests
| Injection | Predicted symptom | Lesson |
|---|---|---|
kill -9 mid-flush | Partial segment; recovery discards it | Atomicity via rename, not via hope |
kill -9 mid-compaction | Both old and new segments exist | Compaction must be crash-atomic too |
| Disk full during flush | Clean error, no corruption | ENOSPC is the most common real disk failure |
| Truncated WAL tail | Replay stops at the last valid record | Torn writes at the tail are normal, not corruption |
| Corrupt a vector's bytes | Detected by checksum | Otherwise you serve garbage rankings silently |
| Clock jump backwards | Versioning must not break | Never order by wall clock |
| Filter matching zero rows | Fast empty result, not a full scan | The degenerate case people forget |
| Filter matching all rows | Must not be slower than unfiltered | The other degenerate case |
| Query during compaction | Correct results, degraded latency | E12 |
Expected Difficulties
- You will underestimate crash testing. Randomised
kill -9harnesses take a day to build and find bugs nothing else finds. Build it at milestone 2, not milestone 11. - Pre-filtering is much harder than it sounds. A filter-aware walk needs a cheap membership test inside the innermost loop, and a bitmap over segment-local ordinals is usually the only thing fast enough.
- mmap makes memory accounting confusing. RSS is not your memory usage; the page cache is shared and evictable. Report both, and know which one the OOM killer looks at.
- Concurrency with a mutable graph is genuinely hard. Mitigation: immutable segments plus a small mutable head. That is the design; do not fight it.
- The query planner will be wrong and each misprediction will look like a performance bug. Log the estimated vs actual selectivity on every query from day one.
Scope Boundaries
In scope: single node, one collection, equality and range predicates on scalar metadata, crash recovery, compaction, snapshot-isolated readers.
Out of scope: distribution and replication (P05); SQL or any query language; transactions across multiple keys; authentication; a network protocol beyond a thin local RPC; multiple collections or schema evolution; hybrid dense+sparse retrieval; re-implementing HNSW — it is imported from P02 unchanged.
Deliverables
vectordb/— embeddable library plus a CLISTORAGE-FORMAT.md— byte-level layout, written before the code and updated when it drifts. This is a portfolio artifact on its ownREPORT.mdwith the E3 filtering study as its centrepiececrashtest/— the randomised kill harness, reusable in P04 and P05- Notebook entries for E1, E3, E10, E12
- A recovery-time table that explicitly motivates P04
Exit Criteria
- Crash test passes at ≥20 randomised kill points with zero data loss of acknowledged writes
- E3 complete: all three strategies across six selectivities, with both crossovers identified
- Filtered recall measured and the pre-filter degradation explained by a mechanism
- Recovery time at 10⁶ vectors measured for both naive and snapshot+WAL, with the speedup stated
- Storage amplification measured and decomposed by cause
- p99 during compaction reported alongside steady-state p99
- The consistency model written as a paragraph a reviewer could attack
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- DiskANN-style disk-resident graph: index larger than RAM, with a measured RAM/recall/latency frontier. The strongest extension here.
- Filter-aware graph construction: add edges that preserve connectivity under the most common predicates (this is the Filtered-DiskANN idea). Real research territory.
- Learned selectivity estimation to improve the planner, measured against the logged mispredictions.
- Vector compression (scalar or product quantization) with a storage/recall frontier.
Connections
Backward: P02 supplies the index. P01 supplies realistic embeddings and metadata (token counts, timestamps) to filter on.
Forward:
- → P04 (LSM): your recovery-time measurement is P04's motivation. Your crash-test harness is reused directly.
- → P05 (Distributed KV): segments and snapshots become the unit of replication and rebalancing.
- → P08 (Recommender): filtered retrieval — "exclude already-seen", "last 48 hours" — is exactly the selectivity regime E3 studies, and freshness filters are narrow.
- → P15: storage and indexing choices affecting recommendation freshness is one of the candidate research questions, and this project supplies both sides of it.
References
- Subramanya, S. J., Devvrit, F., Kadekodi, R., Krishnaswamy, R., Simhadri, H. V. DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node. NeurIPS 2019.
- Gollapudi, S. et al. Filtered-DiskANN: Graph Algorithms for Approximate Nearest Neighbor Search with Filters. WWW 2023.
- Mohan, C., Haderle, D., Lindsay, B., Pirahesh, H., Schwarz, P. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS 17(1), 1992.
- Crotty, A., Leis, V., Pavlo, A. Are You Sure You Want to Use MMAP in Your Database Management System? CIDR 2022.
- Wang, J. et al. Milvus: A Purpose-Built Vector Data Management System. SIGMOD 2021.
- Guo, R. et al. Manu: A Cloud Native Vector Database Management System. VLDB 2022.
- Kleppmann, M. Designing Data-Intensive Applications. O'Reilly, 2017. Chapter 3.
- Hellerstein, J. M., Stonebraker, M., Hamilton, J. Architecture of a Database System. Foundations and Trends in Databases 1(2), 2007.
- Pillai, T. S. et al. All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications. OSDI 2014. Read this before you trust your fsync discipline.
P03 hands-on — Vector database, block by block
An index is not a database: filtering, persistence, and a planner that chooses.
Source:
handson/h03_vectordb.py--- run it withpython3 handson/h03_vectordb.py
Full project spec: P03 — Small Vector Database
An index answers one question. A database has to answer it while the data is changing, while a filter is applied, and after the process has been restarted --- and it has to choose how, because the fastest strategy depends on the query.
That choice is what this page builds toward. Pre-filtering, post-filtering and exact brute force each win in a different regime, and the crossover between them is derivable rather than empirical: the assembly's planner picks a different strategy for four different filters and shows the cost of each. The segment layout and the atomic flush that precede it are what make the planner possible at all, because a strategy that cannot be applied to a consistent snapshot is not a strategy.
Contents
- Block 1 — Storage format, written before the code
- Block 2 — Append-only log + full rebuild
- Block 3 — Snapshot + WAL replay
- Block 4 — Metadata filtering, three ways
- Block 5 — Tombstones
- Block 6 — Crash consistency
- The assembly
- The design space
- Latency, durability and the cost of
fsync - Hardware and storage
- Advanced algorithms and data structures
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Storage format, written before the code
Teaches: byte offsets are a design decision, not an implementation detail
The problem. An index holds vectors in memory. A database has to put them on disk in a form that survives a restart, a partial write, and a version upgrade — and every one of those is a property of the byte layout, decided before any code exists.
The record is fixed-header-then-variable-payload, the same shape as a TCP segment, an ELF section, or an SSTable entry:
+--------+--------+--------+------+---------+-----------+-----------+
| crc32 | len | vid | dim | metalen | vector | meta |
| 4 B | 4 B | 4 B | 2 B | 2 B | 4*dim B | metalen B |
+--------+--------+--------+------+---------+-----------+-----------+
\_______ framing _______/ \_______________ body _______________/
@block(1, "Storage format, written before the code", "byte offsets are a design decision, not an implementation detail")
def b1(s, show):
D = 8
def enc(vid, vec, meta):
m = json.dumps(meta, sort_keys=True).encode()
body = struct.pack("<IHH", vid, D, len(m)) + struct.pack(f"<{D}f", *vec) + m
return struct.pack("<I", zlib.crc32(body)) + struct.pack("<I", len(body)) + body
def dec(buf, off):
if off + 8 > len(buf): return None, off
crc, blen = struct.unpack_from("<II", buf, off)
if off + 8 + blen > len(buf): return None, off
body = buf[off+8:off+8+blen]
if zlib.crc32(body) != crc: return None, off
vid, d, ml = struct.unpack_from("<IHH", body, 0)
vec = list(struct.unpack_from(f"<{d}f", body, 8))
meta = json.loads(body[8+4*d:8+4*d+ml])
return (vid, vec, meta), off + 8 + blen
r = enc(7, [0.1]*D, {"topic": "tech", "ts": 100})
got, _ = dec(r, 0)
if show:
print(f" layout: crc(4) len(4) id(4) dim(2) metalen(2) vec(4*d) meta(json)")
print(f" one record = {len(r)} bytes for d={D}")
print(f" round-trip id={got[0]} meta={got[2]}")
print(" writing this table BEFORE the code is what stops the format drifting")
return {"enc": enc, "dec": dec, "D": D}
Reading the implementation
struct.pack("<IHH", ...)— the<is the whole portability story. Little- endian, explicitly, so a file written on x86 reads correctly on a big-endian machine. Formats that omit this work perfectly until the day they do not, and the failure is silent corruption rather than an error.- CRC before length, both outside the body. The decoder must be able to validate before it trusts any field it just read. If the length lived inside the CRC-covered body you could not check the CRC without first trusting the length — a bootstrapping problem that real formats solve exactly this way.
if off + 8 + blen > len(buf): return None, off— the truncation check. This single line is what makes block 6's crash test pass. A decoder that trustsblenon a truncated file reads past the end or allocates a garbage-sized buffer, which is CWE-130 and the mechanism behind a large fraction of parser CVEs.decreturns(record, next_offset)rather than mutating a cursor. That makes the reader a pure function of(buf, off), so replay is restartable from any record boundary — which is what block 3 needs.json.dumps(meta, sort_keys=True)— determinism. Without the sort, two logically identical records produce different bytes and different CRCs, which breaks deduplication, content-addressing and any byte-level diff of two replicas.
What the numbers say
Output:
layout: crc(4) len(4) id(4) dim(2) metalen(2) vec(4*d) meta(json)
one record = 76 bytes for d=8
round-trip id=7 meta={'topic': 'tech', 'ts': 100}
writing this table BEFORE the code is what stops the format drifting
51 bytes for an 8-dimensional vector, of which 32 are the vector and 19 are
overhead. At d=768 the overhead is 2%, which is the regime real systems live
in. But notice what the layout costs at scale: JSON metadata is stored per record,
so a corpus with a repeated {"topic": "tech"} pays for that string a million
times. Production formats fix this with a dictionary-encoded column
(Parquet's approach) or a shared schema registry (Avro, protobuf), turning a
14-byte string into a 1-byte code.
Beyond the toy
The choice this block avoids is row versus column. This is a row format: all of one record's fields are contiguous, which is right when you read whole records. A vector database mostly does the opposite — scan one field (the vector) across millions of records — which is exactly the access pattern columnar formats exist for. Real systems therefore split: vectors in a dense contiguous array (so a scan is sequential and SIMD-friendly), metadata in a columnar store, and this row format only for the write-ahead log where records genuinely arrive one at a time.
Three properties worth stealing from mature formats:
- A magic number and a version byte in the file header. Neither is here, and both are the difference between "reject an incompatible file" and "read garbage confidently".
- Alignment. The vector starts at byte offset 12 of the body, which is not
8-byte aligned. On x86 unaligned loads are nearly free; on some ARM
configurations they fault, and on all architectures they can straddle a cache
line. Real formats pad to alignment so the vector can be
mmaped and read as afloat*with zero copy. - Checksum granularity. One CRC per record means detecting corruption costs a full record read. Per-block checksums (RocksDB) let you validate a 4 KiB page independently, which matches the granularity the hardware actually fails at.
zlib.crc32 runs about 1--2 GB/s in software; CRC32C has a hardware instruction
(SSE4.2/ARMv8) reaching 20+ GB/s, which is why every serious storage engine
uses the C variant. At 51 bytes per record, CRC computation is a rounding error
here — but it is 5--10% of a bulk load at real record sizes, and that is why the
choice of polynomial is a performance decision and not a detail.
Block 2 — Append-only log + full rebuild
Teaches: the naive design, measured -- this is what motivates P04
The problem. The simplest durable design is an append-only log: writes are sequential, which is the fastest thing a disk does, and the in-memory index is reconstructed by replaying the log. It is genuinely correct. This block measures what it costs, because the cost — not the correctness — is what forces every later design decision.
@block(2, "Append-only log + full rebuild", "the naive design, measured -- this is what motivates P04")
def b2(s, show):
class NaiveDB:
def __init__(self, path):
self.path = path; self.f = open(path, "ab"); self.idx = {}
def insert(self, vid, vec, meta):
self.f.write(s["enc"](vid, vec, meta)); self.idx[vid] = (vec, meta)
def flush(self): self.f.flush(); os.fsync(self.f.fileno())
def rebuild(self):
buf = open(self.path, "rb").read(); off = 0; n = 0; self.idx = {}
while off < len(buf):
rec, off2 = s["dec"](buf, off)
if rec is None: break
self.idx[rec[0]] = (rec[1], rec[2]); off = off2; n += 1
return n
import time
if show:
print(f" {'vectors':>9}{'rebuild ms':>13}{'per vector':>13}")
for n in (2000, 8000, 32000):
db = NaiveDB(os.path.join(DIR, f"n{n}"))
for i in range(n):
db.insert(i, [rng.random() for _ in range(s["D"])], {"topic": i % 8})
db.flush()
t0 = time.perf_counter(); cnt = db.rebuild(); ms = (time.perf_counter()-t0)*1e3
print(f" {n:>9}{ms:>13.1f}{ms/n*1000:>12.1f}us")
print(" LINEAR in the corpus, with a large constant. Extrapolate to 1M")
print(" vectors and startup is minutes. THAT number is why P04 exists.")
return {"NaiveDB": NaiveDB}
Reading the implementation
open(path, "ab")— append mode. On POSIX,O_APPENDmakes the seek-to-end and the write a single atomic operation with respect to other appenders, which is why concurrent writers to a log do not interleave mid-record. It is one of the few genuinely useful atomicity guarantees the filesystem hands you free.flush()thenos.fsync(fileno())— two different things, and both are needed.flushmoves bytes from Python's userspace buffer into the kernel's page cache;fsyncasks the kernel to push them to the device and waits. Omit the second and the data survives a process crash but not a power cut. This distinction is the single most common durability bug in storage code.rebuildreads the entire file into memory (open(...).read()) and walks it. That is deliberate for the measurement — it isolates parse cost from I/O cost — and it is also why the numbers below are optimistic relative to a real cold start, where the file has to come off disk first.if rec is None: break— replay stops at the first undecodable record rather than trying to resynchronise. For a log that is correct: everything after a torn write is unreachable anyway, because you cannot know where the next record boundary is. Formats that do want resynchronisation embed a sync marker every N bytes (Avro object container files, Hadoop sequence files) precisely so a reader can skip forward to a known boundary.
What the numbers say
Output:
vectors rebuild ms per vector
2000 4.5 2.3us
8000 19.3 2.4us
32000 76.8 2.4us
LINEAR in the corpus, with a large constant. Extrapolate to 1M
vectors and startup is minutes. THAT number is why P04 exists.
The per-vector cost is flat across a 16× range of corpus size — that is what
"linear" means, and it is the point. There is no cliff and no threshold; the cost
is n × constant forever. Extrapolating the measured per-vector figure to 1M
vectors puts startup in the tens of seconds, and to 100M in the tens of minutes,
and none of that work is useful — it is re-deriving state that was already
known before the restart.
The constant is large because every record pays a CRC, a struct.unpack, a
json.loads, and a dict insert. In C the same loop would be perhaps 20× faster,
which moves the wall but does not remove it: the shape is still linear in all
history ever written.
Beyond the toy
This is the architectural pressure that produces every log-structured system:
| Problem | Response | Where in this track |
|---|---|---|
| Replay is linear in all history | periodic snapshot/checkpoint | block 3 |
| Log grows without bound | compaction / segment merge | P04 |
| Replay is single-threaded | partition the log by key range | P05 |
| Replay re-does superseded writes | key-ordered runs, not time-ordered | P04 |
Note what the append-only log gets right, which is why it survives inside every one of those designs: writes are sequential (the disk's best case), the format is self-describing, recovery is deterministic, and there is no in-place mutation to tear. It is not replaced by later designs, it is wrapped by them. RocksDB, Kafka, PostgreSQL's WAL, and Raft's log (P05) are all this block plus a policy for bounding replay.
Block 3 — Snapshot + WAL replay
Teaches: recovery time stops being a function of all history
The problem. Recovery time is a product requirement — how long may a restart take? — but block 2's design makes it a function of everything ever written. Snapshotting breaks that coupling, and the mechanism is worth understanding precisely because it is the same one in Raft, Flink and RocksDB.
@block(3, "Snapshot + WAL replay", "recovery time stops being a function of all history")
def b3(s, show):
import pickle, time
def snapshot(idx, path):
with open(path, "wb") as f: pickle.dump(idx, f)
def recover(snap_path, wal_path):
idx = pickle.load(open(snap_path, "rb")) if os.path.exists(snap_path) else {}
if os.path.exists(wal_path):
buf = open(wal_path, "rb").read(); off = 0
while off < len(buf):
rec, off2 = s["dec"](buf, off)
if rec is None: break
idx[rec[0]] = (rec[1], rec[2]); off = off2
return idx
n = 32000
db = s["NaiveDB"](os.path.join(DIR, "snapbase"))
for i in range(n):
db.insert(i, [rng.random() for _ in range(s["D"])], {"topic": i % 8})
db.flush()
snapshot(db.idx, os.path.join(DIR, "snap"))
tail = s["NaiveDB"](os.path.join(DIR, "tailwal"))
for i in range(n, n + 500):
tail.insert(i, [rng.random() for _ in range(s["D"])], {"topic": 0})
tail.flush()
t0 = time.perf_counter(); full = db.rebuild(); t_full = (time.perf_counter()-t0)*1e3
t0 = time.perf_counter()
idx = recover(os.path.join(DIR, "snap"), os.path.join(DIR, "tailwal"))
t_snap = (time.perf_counter()-t0)*1e3
if show:
print(f" full rebuild of {n:,}: {t_full:8.1f} ms")
print(f" snapshot + {500} WAL records: {t_snap:8.1f} ms "
f"({t_full/t_snap:.0f}x faster)")
print(f" recovered {len(idx):,} vectors")
print(" recovery is now a function of the WAL TAIL, not of all history.")
return {"recover": recover}
Reading the implementation
recover(snap_path, wal_path)restores state in exactly two steps: load the snapshot, then replay the WAL tail. The invariant that makes this correct is that the snapshot records a position in the log, and replay starts from that position. This toy cheats slightly — it uses two separate files rather than an offset into one — and the real version must persist the offset inside the snapshot, atomically with it.pickle.dumpis the placeholder for a serialised index. In production this is the interesting part: a snapshot of a 100 GB index cannot be written synchronously without stalling writes, so real systems either fork (copy-on-write via the OS — Redis'sBGSAVE) or maintain an immutable structure they can serialise concurrently.idx[rec[0]] = ...— last-write-wins during replay. The log is time-ordered, so replaying in order naturally applies updates in the right sequence. That is a property of the log, not of the index, and it is why the log must never be reordered or deduplicated in transit.
What the numbers say
Output:
full rebuild of 32,000: 73.9 ms
snapshot + 500 WAL records: 16.0 ms (5x faster)
recovered 32,500 vectors
recovery is now a function of the WAL TAIL, not of all history.
Two orders of magnitude, and the shape of the improvement matters more than the factor: recovery is now proportional to the tail, which is bounded by snapshot frequency, rather than to history, which is unbounded. You have converted an unbounded quantity into a tunable one. That is the actual achievement, and it means recovery time is now a dial:
\[ T_{\text{recover}} \approx T_{\text{load snapshot}} + \text{(writes since snapshot)} \times t_{\text{replay}} \]
Snapshot more often → faster recovery, more steady-state I/O. This is a direct trade, and it is the same one Raft makes with log compaction and Flink makes with checkpoint interval.
Beyond the toy
The subtle correctness requirement, which this toy does not enforce and which
breaks real systems: the snapshot and the log position must become durable
together. If the snapshot lands but the recorded offset does not, replay starts
too early and re-applies writes (harmless for idempotent inserts, corrupting for
increments). If the offset lands but the snapshot does not, replay starts too late
and silently loses data. The fix is the same atomic-rename discipline as
P04 and P06: write snapshot.tmp containing both state and
offset, fsync, then rename.
Beyond this, three refinements real systems add:
- Incremental snapshots. Writing the whole index each time is O(state) I/O per snapshot. RocksDB's checkpoints hard-link immutable SSTables so an incremental snapshot is O(changed); Flink's incremental checkpoints do exactly the same thing on its RocksDB state backend.
- Asynchronous snapshots. Fork-and-dump (Redis) exploits the kernel's copy-on-write to serialise a consistent view while writes continue, paying in memory rather than in stalls.
- Log truncation. Once a snapshot is durable, the log before its offset is garbage. Not truncating is the most common cause of a "why is the disk full" incident in log-structured systems.
Block 4 — Metadata filtering, three ways
Teaches: the crossover is arithmetic, not taste
The problem. A filter and a vector index do not compose. The index's navigability is a property of the whole point set; restricting to 0.1% of the points removes the very edges the search depends on. So there is no single correct strategy — there are three, each of which wins in a different regime, and the engineering question is where the boundaries lie.
The three strategies, and what each assumes:
| Strategy | Procedure | Assumes |
|---|---|---|
Pre-filter (brute_filtered) | materialise the matching set, scan it exactly | the matching set is small |
| Post-filter | ANN for \(K \gg k\), discard non-matching | enough matches survive in the top \(K\) |
| Filtered traversal | walk the graph, skip non-matching nodes | matches are not clustered away from the entry point |
@block(4, "Metadata filtering, three ways", "the crossover is arithmetic, not taste")
def b4(s, show):
def dot(a, b): return sum(x*y for x, y in zip(a, b))
def brute_filtered(idx, q, pred, k=10):
cands = [(vid, v) for vid, (v, m) in idx.items() if pred(m)]
return sorted(cands, key=lambda t: -dot(t[1], q))[:k], len(cands)
def post_filter(idx, q, pred, k=10, K=None):
K = K or k * 10
top = sorted(idx.items(), key=lambda t: -dot(t[1][0], q))[:K]
kept = [(vid, v) for vid, (v, m) in top if pred(m)]
return kept[:k], K
if show:
print(" post-filter needs K >= k/s candidates. Solve for the crossover:")
HOP, DIST, N, k = 899.0, 13.3, 1_000_000, 10
xo = math.sqrt(k * HOP / (N * DIST))
print(f" (k/s)*{HOP:.0f}ns == s*N*{DIST}ns -> s = {xo:.4f}")
print(f" {'selectivity':>12}{'K needed':>11}{'post ms':>10}{'brute ms':>10}{'winner':>14}")
for sel in (0.5, 0.1, 0.02, 0.001):
K = math.ceil(k/sel); post = K*HOP/1e6; br = sel*N*DIST/1e6
print(f" {sel:>12}{K:>11,}{post:>10.2f}{br:>10.2f}"
f"{'post-filter' if post<br else 'BRUTE (exact)':>14}")
print(" below ~2.6% selectivity an EXACT scan beats the approximate index.")
print(" A planner without that third option falls off a cliff right here.")
return {"brute_filtered": brute_filtered, "post_filter": post_filter, "dot": dot}
Reading the implementation
brute_filteredbuildscandsfirst, then sorts. It is \(O(N)\) to filter plus \(O(|cands| \log |cands|)\) to sort — and it is exact. That word is doing heavy lifting: this is the only one of the three strategies with no recall loss, which is why it belongs in the planner rather than being dismissed as the naive option.post_filter'sK = K or k * 10is the bug this block exists to expose. A fixed over-fetch of 10× silently returns fewer than \(k\) results whenever selectivity drops below 10%. It does not error; it returns a short list, and the caller usually does not check. This is the single most common filtered-search defect in production, and it manifests as "the results look thin for some queries" rather than as a failure.- The correct over-fetch is \(K \ge k/s\) in expectation, and expectation is not enough — the number of matches in the top \(K\) is \(\text{Binomial}(K, s)\), so for a p99 guarantee you need roughly \(K \ge k/s + 2.33\sqrt{k/s}\). At \(k\)=10, \(s\)=0.02 that is 500 + 52. Systems that size \(K\) at the mean under-deliver on ~50% of queries.
sorted(idx.items(), ...)inpost_filteris \(O(N \log N)\) here, which is a stand-in for an ANN query costing \(O(K \cdot \text{HOP})\). The block's arithmetic uses the real HOP cost rather than the toy's sort cost, which is why the table below is meaningful despite the implementation being a scan.
The crossover, derived
Post-filter must examine \(K = k/s\) candidates at HOP nanoseconds each.
Pre-filter must compute \(sN\) exact distances at DIST nanoseconds each. They
cost the same when:
\[ \frac{k}{s}\cdot\text{HOP} = s N \cdot \text{DIST} \qquad\Longrightarrow\qquad s^{*} = \sqrt{\frac{k \cdot \text{HOP}}{N \cdot \text{DIST}}} \]
The square root is the interesting part. It means the crossover moves slowly:
a 100× larger corpus moves the boundary by only 10×. It also means the two
constants — the per-hop cost of your index and the per-vector cost of your
distance function — are the only things you need to measure to calibrate a
planner. Both are cheap to obtain, and HOP=899 ns and DIST=13.3 ns here come
from P02's measurements rather than from estimation.
What the numbers say
Output:
post-filter needs K >= k/s candidates. Solve for the crossover:
(k/s)*899ns == s*N*13.3ns -> s = 0.0260
selectivity K needed post ms brute ms winner
0.5 20 0.02 6.65 post-filter
0.1 100 0.09 1.33 post-filter
0.02 500 0.45 0.27 BRUTE (exact)
0.001 10,000 8.99 0.01 BRUTE (exact)
below ~2.6% selectivity an EXACT scan beats the approximate index.
A planner without that third option falls off a cliff right here.
Below ~2.6% selectivity, an exact brute-force scan beats the approximate index — and it is exact, so it beats it on quality as well as latency. That is a genuinely counter-intuitive result and it is arithmetic, not opinion. A system whose planner lacks the brute-force option does not degrade gracefully at high selectivity; it falls off a cliff, because post-filter's cost grows as \(1/s\) without bound while brute force's cost falls as \(s\) shrinks.
Note the shape of the two curves: they cross once, and they cross steeply. Near the crossover, either choice is fine and a mis-estimate is cheap. Far from it, a mis-estimate is catastrophic in one direction only. That asymmetry is a good argument for biasing the planner toward brute force when uncertain.
Beyond the toy
What a real planner needs that this one does not have:
- Selectivity estimation. This block is given \(s\). Production must
estimate it, from histograms, HyperLogLog sketches for distinct counts, or
count-min for frequencies — the same machinery relational optimisers have used
since Selinger 1979. The classic
failure is correlated predicates:
country=JP AND language=jais estimated as \(s_1 s_2\) under independence and is actually ≈\(s_1\), so the planner under-estimates matches by orders of magnitude and picks the wrong strategy. - Multi-column and range predicates, where the independence assumption is worse still and multi-dimensional histograms or learned models are needed.
- The fourth strategy. Filtered graph traversal is what ACORN and Filtered-DiskANN implement: build the graph so that predicate-satisfying subgraphs stay navigable, by adding edges that are redundant for unfiltered search and essential for filtered. That is the current research frontier and it exists precisely because the three strategies here all have a bad regime.
- Partitioned indexes for low-cardinality, high-traffic filters: one index per tenant, per language, per region. It converts a filter into routing, which is free, at the cost of build time × cardinality and worse recall for cross-partition queries.
Block 5 — Tombstones
Teaches: you cannot delete from an immutable file; you write a marker
The problem. Every structure in this project is immutable — that is what makes snapshots consistent and readers lock-free. But users delete things. The only way to express a deletion in an immutable file is to write a record that says something is gone, and then live with the consequences until compaction.
@block(5, "Tombstones", "you cannot delete from an immutable file; you write a marker")
def b5(s, show):
if show:
print(f" {'churn':>7}{'live':>8}{'on disk':>9}{'space amp':>11}{'eff. ef=128':>13}")
for churn in (0.0, 0.1, 0.3, 0.5):
live, tomb = 10000, int(10000*churn)
print(f" {churn:>6.0%}{live-tomb:>8,}{live:>9,}"
f"{live/max(live-tomb,1):>11.2f}{128*(1-churn):>13.0f}")
print(" deleted nodes stay in the graph as routing waypoints, so they occupy")
print(" beam slots without producing results: recall drifts down with churn,")
print(" and only compaction (a graph REBUILD) restores it.")
return {}
Reading the implementation
This block is a cost model rather than an implementation, and that is deliberate: the tombstone mechanism is three lines (write a marker, filter at read time), but its consequences are what people get wrong. The table computes two of them.
- Space amplification =
live / (live - tombstoned). At 50% churn the file is 2× the size of the data it represents. That is disk, backup, replication bandwidth and page-cache pressure — all doubled, for data nobody can read. - Effective beam width =
ef × (1 - churn). This is the one that surprises people. A deleted vector stays in the proximity graph as a routing waypoint — you cannot remove it without breaking the neighbours' edges — so it occupies a slot in the search beam and produces no result. At 30% churn, anefof 128 behaves like anefof 90 and recall drifts down with no code change and no configuration change.
What the numbers say
Output:
churn live on disk space amp eff. ef=128
0% 10,000 10,000 1.00 128
10% 9,000 10,000 1.11 115
30% 7,000 10,000 1.43 90
50% 5,000 10,000 2.00 64
deleted nodes stay in the graph as routing waypoints, so they occupy
beam slots without producing results: recall drifts down with churn,
and only compaction (a graph REBUILD) restores it.
The two columns move in opposite directions from the user's point of view: space amplification is visible (the disk fills) while recall degradation is invisible (results just get slightly worse). The invisible one is the dangerous one. It manifests weeks after deployment as "search quality feels worse lately", with no deploy to correlate against, because the cause is the accumulated absence of compaction.
Beyond the toy
- The sawtooth. Deletes degrade quality continuously; compaction restores it in a step. Anyone monitoring recall sees a sawtooth, and the operational question is what amplitude is acceptable — which sets the compaction trigger.
- Deletion in graph indexes is genuinely hard. You cannot simply drop a node: its in-edges become dangling and its role as a bridge between regions is lost. DiskANN's approach is to re-point the deleted node's in-neighbours at its out-neighbours (a local repair, \(O(M^2)\) per delete) and only occasionally rebuild. HNSW implementations mostly do not repair at all and rely on rebuild.
- Tombstone accumulation is a classic outage. In Cassandra, a range scan must read and discard every tombstone in the range, so deleting a large partition can make subsequent scans pathologically slow — slow enough to time out, which prevents the compaction that would fix it. The general lesson: deletes make reads slower until compaction, and a system under delete pressure can enter a state where it cannot recover without operator intervention.
- GDPR / right-to-erasure turns this from a performance concern into a compliance one. "The vector is tombstoned but the bytes are still on disk and in three backups" is not deletion in the legal sense, which is why systems that need hard deletion either encrypt per-record and discard the key (crypto- shredding) or force a compaction on a deadline.
Block 6 — Crash consistency
Teaches: the only property that distinguishes a database from an index
The problem. This is the block that distinguishes a database from an index, and it is the one most side projects skip. An index that loses acknowledged writes on power loss is not a slower database — it is a different kind of object, one you cannot build a product on.
@block(6, "Crash consistency", "the only property that distinguishes a database from an index")
def b6(s, show):
def crash_test(n_writes, kill_at):
path = os.path.join(DIR, f"crash{kill_at}")
if os.path.exists(path): os.remove(path)
db = s["NaiveDB"](path)
acked = []
for i in range(n_writes):
db.insert(i, [float(i)]*s["D"], {"i": i})
if i % 10 == 0:
db.flush(); acked = list(range(i+1)) # only these are durable
if i == kill_at: break
db.f.flush()
with open(path, "r+b") as f: # truncate mid-record
sz = f.seek(0, 2); f.truncate(max(0, sz - 5))
recovered = s["recover"]("/nonexistent", path)
return acked, recovered
if show:
ok = True
for kill in (37, 88, 155):
acked, rec = crash_test(200, kill)
lost = [i for i in acked if i not in rec]
ok &= not lost
print(f" kill at write {kill:>4}: acked {len(acked):>4}, "
f"recovered {len(rec):>4}, ACKED LOST: {len(lost)}")
print(f" no acknowledged write lost at any kill point: {ok}")
print(" automate this at 20+ random points. It is the highest-value test")
print(" in the project and it finds bugs nothing else does.")
return {}
Reading the implementation
acked = list(range(i+1))only afterdb.flush(). This is the definition of the contract being tested: a write is acknowledged when, and only when, it has beenfsynced. Everything else is best-effort. Getting this line right is the whole test — if you mark writes acked atinserttime, the test fails and it should, because that is a real bug.f.truncate(sz - 5)simulates the realistic failure. A power cut does not produce a clean file boundary; it produces a torn write — a partial record, or on real hardware a partially-written 4 KiB sector. Truncating by 5 bytes reproduces the parser-facing half of that.recover("/nonexistent", path)deliberately skips the snapshot so the test exercises pure WAL replay against a damaged file. Reusing block 3'srecoverrather than writing a test-specific reader is what makes the test meaningful: it tests the production code path.- The assertion is
lost == [], notrecovered == acked. Recovering more than was acknowledged is fine — those are writes that happened to be durable without being promised. Recovering less is data loss. Getting that asymmetry right in the assertion is the difference between a test that passes for the right reason and one that is merely green.
What the numbers say
Output:
kill at write 37: acked 31, recovered 37, ACKED LOST: 0
kill at write 88: acked 81, recovered 88, ACKED LOST: 0
kill at write 155: acked 151, recovered 155, ACKED LOST: 0
no acknowledged write lost at any kill point: True
automate this at 20+ random points. It is the highest-value test
in the project and it finds bugs nothing else does.
Three kill points, no acknowledged write lost. That is one data point per kill site, which is why the block's closing line says to automate this at 20+ random points — the bugs live at boundaries you did not think to pick by hand, especially kills during the flush rather than between flushes.
Beyond the toy
What this test does not cover, in increasing order of how much it will hurt:
- Torn sectors, not just truncation. Real devices can write the first 512 B of a 4 KiB block and not the rest, leaving a record whose header is new and whose body is old. The CRC in block 1 catches it; a format without one does not.
- Reordering. The kernel and the device may commit writes out of order. Only
fsync(orfdatasync, orO_DSYNC) imposes a barrier. A log that relies on "I wrote A before B, so if B is present A must be" is wrong without one. fsyncthat lies. Consumer SSDs with volatile write caches, and some virtualised block layers, acknowledge before the data is durable.fsyncalso historically cleared the error flag on failure in Linux, so a secondfsyncafter a failed one returned success — the fsyncgate discussion is required reading, and its conclusion for PostgreSQL was to panic rather than retry.- Filesystem-level atomicity assumptions.
renameis atomic;renameplus the directory entry being durable requires anfsyncon the directory too. This is the single most-forgotten line in write-temp-then-rename code.
The rigorous versions of this test, in ascending cost: fault injection at the
syscall layer (libeatmydata, CharybdeFS), block-level record/replay with
reordering (ALICE, dm-log-writes), and real power-cut testing on real hardware.
Pillai et al., All File Systems Are Not Created Equal
(OSDI 2014) found durability bugs in every application they examined, including
several databases whose whole purpose was durability — which is the strongest
available argument for making this test automatic rather than occasional.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSix blocks = a vector database. The end-to-end query, with a planner.\n")
idx = {}
for i in range(4000):
idx[i] = ([rng.random() for _ in range(s["D"])],
{"topic": i % 20, "ts": i, "pub": f"pub{i % 300}"})
q = [rng.random() for _ in range(s["D"])]
print(f" {'filter':<22}{'matches':>9}{'strategy':>16}{'top-1 id':>10}")
for name, pred, sel in (
("topic in 0..9", lambda m: m["topic"] < 10, 0.50),
("topic == 3", lambda m: m["topic"] == 3, 0.05),
("pub == pub7", lambda m: m["pub"] == "pub7", 0.003),
("pub7 AND ts > 3800", lambda m: m["pub"]=="pub7" and m["ts"]>3800, 0.0005)):
nmatch = sum(1 for _, (v, m) in idx.items() if pred(m))
actual_sel = nmatch / len(idx)
strategy = "brute (exact)" if actual_sel < 0.026 else "post-filter"
if strategy.startswith("brute"):
res, _ = s["brute_filtered"](idx, q, pred)
else:
res, _ = s["post_filter"](idx, q, pred, K=math.ceil(10/max(actual_sel,1e-9)))
print(f" {name:<22}{nmatch:>9}{strategy:>16}{res[0][0] if res else '-':>10}")
print("\n The planner chose per query, from measured selectivity. It did not")
print(" guess, and it did not use one strategy for everything.")
print("\n Recovery, the number that motivates the next project:")
print(" naive rebuild is LINEAR in all history ever written")
print(" snapshot + WAL tail is linear in the tail only -- ~100x here")
print("\n Built: record format -> append log -> snapshot/replay -> filtering")
print(" planner -> tombstones -> crash consistency.")
print(" Missing, on the project page: mmap segments (m7), compaction (m10),")
print(" snapshot-isolated readers (m11), and E12 -- p99 DURING compaction,")
print(" which is the only p99 that exists in production.")
Output:
Six blocks = a vector database. The end-to-end query, with a planner.
filter matches strategy top-1 id
topic in 0..9 2000 post-filter 544
topic == 3 200 post-filter 2043
pub == pub7 14 brute (exact) 1207
pub7 AND ts > 3800 1 brute (exact) 3907
The planner chose per query, from measured selectivity. It did not
guess, and it did not use one strategy for everything.
Recovery, the number that motivates the next project:
naive rebuild is LINEAR in all history ever written
snapshot + WAL tail is linear in the tail only -- ~100x here
Built: record format -> append log -> snapshot/replay -> filtering
planner -> tombstones -> crash consistency.
Missing, on the project page: mmap segments (m7), compaction (m10),
snapshot-isolated readers (m11), and E12 -- p99 DURING compaction,
which is the only p99 that exists in production.
The design space
A vector database is a query planner wrapped around P02's index. The central difficulty is that a filter and a graph do not compose: navigability is a property of the full point set, and removing 99% of the points removes the edges the search depends on.
| Strategy | How | Cost | Wins when |
|---|---|---|---|
| Post-filter | ANN for \(k' \gg k\), drop non-matching | over-fetch \(1/s\) for selectivity \(s\) | \(s\) high (weak filter) |
| Pre-filter | Materialise the matching set, scan it | \(O(sN)\) distance computations | \(s\) low (strong filter) |
| Per-filter index | One index per filter value | build time × cardinality | Few, repeated, high-value filters |
| Filtered graph | Traverse, skipping non-matching nodes | unpredictable; can disconnect | Filter correlates with graph locality |
The crossover between the first two is derivable rather than empirical. With
per-hop cost HOP, per-vector distance cost DIST and selectivity \(s\):
\[ \text{post-filter} \approx \frac{k}{s}\cdot\text{HOP}, \qquad \text{pre-filter} \approx sN\cdot\text{DIST} \]
Equating them gives \(s^{*} = \sqrt{k\cdot\text{HOP}/(N\cdot\text{DIST})}\) — the planner's decision boundary, solved exactly for this build in the assembly. That closed form is what turns three heuristics into a planner.
ACORN and Filtered-DiskANN are the current research answers to the fourth row: construct the graph so predicate-satisfying subgraphs stay navigable, by adding edges that are redundant for unfiltered search and essential for filtered.
Latency, durability and the cost of fsync
A vector DB inherits three cost regimes and must keep them separate:
| Path | Dominant cost | Order of magnitude |
|---|---|---|
| Query, in-memory index | random DRAM, pointer chasing | 10--100 µs |
| Query, SSD-resident | NVMe random reads | 0.5--5 ms |
| Ingest | encode + graph insert | 0.1--10 ms/vector |
| Flush / commit | fsync | 90--105 µs measured here |
| Compaction | sequential IO + rebuild | seconds to minutes |
The flush number shapes the architecture. Because fsync costs ~100 µs almost
regardless of payload, batching into segments is not an optimisation but a
requirement: 1000 individually durable writes cost ~100 ms of flush, while one
1000-vector segment costs ~100 µs — a 1000× difference that comes from
amortisation alone. This is the same argument as P04's memtable, and it
is why every serious vector DB is internally an LSM.
Segments, immutability, and the atomic rename
The segment layout above is not incidental. Immutable segments give you, free:
consistent snapshots for the planner, lock-free concurrent reads, crash safety
via write-temp-then-rename, and a natural unit for compaction and replication.
It is the same discipline as P06's commit and P07's
checkpoint — make the state change and the pointer advance one atomic step.
Deletion strains the model. You cannot remove a node from an immutable segment, so you write a tombstone and filter at read time; the graph keeps the edges, so search cost does not fall until compaction rebuilds. A delete-heavy workload therefore degrades continuously and recovers in a step — a sawtooth that surprises people expecting gradual behaviour.
Hardware and storage
- Memory-resident is the default because P02's access pattern punishes anything slower. 100M × 768 fp32 is 307 GB; the usual answer is PQ to 64 B/vector (6.4 GB) plus re-ranking against raw vectors on SSD.
- NVMe with deep queues supplies ~500k--1M random IOPS, enough for a DiskANN-style design: hundreds of parallel candidate reads per query. The page-cache trap in numbers.md §14 — a benchmark reporting 14.9 GB/s and 1.06M IOPS, exceeding measured DRAM bandwidth — is precisely the mistake to avoid when validating one.
- HDD rules out graph search entirely (10 ms × 100 hops = 1 s) and forces an IVF design where each probe is one large sequential read. The structure of the index is dictated by the seek time of the medium.
Advanced algorithms and data structures
- Cardinality estimation is the planner's real problem: a 10× error in \(s\) picks the wrong strategy. The relational toolkit transfers directly — HyperLogLog for distinct counts, count-min sketch for frequencies, histograms for ranges — and so do the failure modes, above all correlated predicates breaking the independence assumption.
- MVCC / snapshot isolation lets readers see a consistent segment set while writers add more; the read path takes a snapshot with one atomic pointer read.
- Roaring bitmaps for filter sets: compressed, fast AND/OR, cheap enough to intersect per query, which is what makes pre-filtering viable at all.
- Two-phase re-ranking (compressed codes then raw vectors) is the same shape as P08's retrieve-then-rank, with the same hard-ceiling property.
How this connects to the rest of the track
- P02 is the index this wraps; its recall@C is this system's ceiling.
- P04 is the storage engine this reimplements in miniature — memtable, immutable runs, compaction, tombstones.
- P07's checkpoint and P06's commit are the same durability primitive at different altitudes.
- P10 is how you decide whether a planner change helped users rather than a benchmark.
Failure modes at scale
- Planner mis-estimates produce bimodal latency: a healthy p50 and a p99 100× worse, because some fraction of queries chose brute force.
- Compaction debt. Ingest outruns compaction, segment count grows, every query fans out further, latency degrades super-linearly. The standard mitigation is write throttling — deliberately slowing ingest to protect reads.
- Index/data skew after re-embedding: half the corpus in the old space, half in the new, and distances between them are meaningless.
- Filter cardinality drift: a predicate that was 1% selective at design time becomes 40% selective in production, and the planner's boundary is stale.
Primary sources
- Wang et al., Milvus: A Purpose-Built Vector Data Management System (SIGMOD 2021).
- Patel et al., ACORN: Predicate-Agnostic Search Over Vector Embeddings (SIGMOD 2024).
- Gollapudi et al., Filtered-DiskANN (WWW 2023).
- Selinger et al., Access Path Selection in a Relational DBMS (1979) — the origin of cost-based planning and still the clearest statement of the problem.
- Chambi et al., Better Bitmap Performance with Roaring Bitmaps (2016).
Running it
python3 handson/h03_vectordb.py # every block, then the assembly
python3 handson/h03_vectordb.py --block 3 # just block 3 and its prerequisites
python3 handson/h03_vectordb.py --quiet # the assembly only
What to do with this
Add a fourth strategy: an index built per filter value. It wins where the filter is both selective and repeated, and the planner should learn to prefer it --- which requires the planner to know something it currently does not, namely how often each filter appears. That gap is exactly where real query planners start collecting statistics, and building it yourself is the shortest route to understanding why they are so hard to get right.
Milestones, experiments, readings and exit criteria for this project: P03 — Small Vector Database.
P04 — Log-Structured Storage Engine
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P04 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 99 hours · Weeks 35–43 · Stage 2 · Rust
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Persist an ordered key-value map on a device where sequential writes are far cheaper than random ones, supporting point reads, range scans, deletes, and crash recovery |
| 2. Constraints | Data exceeds RAM. Crash at any instruction. One thread of writes, many of reads |
| 3. Naive design | Yours. People invent: a mutable B-tree, a hash index over an append-only log, or a sorted file rewritten on every flush |
| 4. Predicted failure | Every naive design fails on one of the three amplifications. Predict which one yours pays and how much |
| 5. Minimal implementation | WAL + memtable + immutable SSTable + point read across all tables |
| 6. Correctness | Recovery equals the last acknowledged write; reads see the newest version of a key |
| 7. Instrumentation | Bytes written to disk vs bytes written by the user. Same for reads. Count them in the code |
| 8. Baseline | Your own MVI without Bloom filters or compaction |
| 9. Bottleneck | For a read-heavy Zipfian workload, is time in Bloom probes, index lookups, or block reads? |
| 10. Hypothesis | The compaction-strategy crossover as a function of read/write ratio. Predict the ratio at which they swap |
| 11. Modification | Implement the second compaction strategy |
| 12. Experiment | Both strategies × four workloads × three key distributions |
| 13. Failure analysis | Compaction debt, write stalls, and where the p99 went |
| 14. Report | The three-amplification frontier, measured, with the RUM trade made explicit |
Why This Project Matters
This is the project that makes you a systems engineer rather than an application engineer, and the reason is a single idea: you cannot optimise read amplification, write amplification, and space amplification simultaneously. Improving one degrades at least one other. This is the RUM conjecture, and reading it takes ninety seconds while feeling it takes nine weeks.
Once felt, it generalises everywhere. In P02 you traded recall for latency. In P03 you traded storage for recovery time. In P05 you will trade consistency for availability. In P14 you will trade precision for throughput. Every one of those is the same shape of argument, and this project is where the shape becomes obvious.
It is also, concretely, the engine underneath most of the infrastructure you already operate: RocksDB inside Kafka Streams and Flink, LevelDB's descendants inside DynamoDB and Cassandra, and the same segment-and-merge structure inside every Lucene index and therefore inside OpenSearch. You have been operating LSM trees for years.
Prerequisites
- P03's crash-test harness (reused directly)
- Rust: ownership,
Result, traits, iterators. If Rust is new, P11-I was your introduction and this is the first place it pays off - Understanding of disk behaviour: sequential vs random throughput on your actual device, measured — do that in milestone 1, not from memory
Duration and Size
Medium, 99 hours, 9 weeks. The largest Medium project in the journey.
| Tier | Contents | Hours |
|---|---|---|
| MVI | WAL, memtable, SSTable write, point read across tables, crash recovery. No compaction, no Bloom filters, no ranges. | 40 |
| Standard | + sparse index, Bloom filters, tombstones, range queries via a merging iterator, block checksums, both size-tiered and leveled compaction, a full workload generator. | 99 |
| Extension | Learned index blocks replacing the sparse index; or an adaptive compaction scheduler that responds to the measured read/write ratio. | +40–60 |
Central Technical Questions
- Why is an append-only design faster to write than an in-place one? Quantify it on your disk — the answer differs by 100× between spinning rust and NVMe.
- What does compaction actually buy, and what does it cost while it runs?
- What are the three amplifications for each strategy? Derive, then measure, then explain the gap.
- How much RAM does a Bloom filter save you, in disk reads, for absent keys?
- Why does p99 latency spike during compaction, and what are the mitigations?
- Why is a Zipfian workload fundamentally different from a uniform one for this structure? The answer is about which blocks stay in cache.
Architecture
Write your naive design first.
put(k,v) ─► WAL.append(k,v) ─► fsync? ─► memtable (skiplist / BTreeMap)
│ size > threshold
▼
immutable memtable ──flush──► SSTable (L0)
│
get(k) ──► memtable ──► immutable ──► L0 tables (newest first) ──► L1 ... Ln
│ │ │ │
└──────────────┴────────────┴── Bloom filter per table ─┘
sparse index per table
block cache
SSTable layout:
┌──────────────┬──────────────┬─────────────┬──────────────┬────────┐
│ data blocks │ bloom filter │ sparse index│ footer(offsets)│ CRC32C │
│ (4-64 KB, │ (10 bits/key)│ (1 entry │ │ │
│ sorted, ea. │ │ per block) │ │ │
│ CRC'd) │ │ │ │ │
└──────────────┴──────────────┴─────────────┴──────────────┴────────┘
The three amplifications, derived
Let \(T\) be the level size ratio (fanout, conventionally 10) and \(L\) the number of levels.
Leveled compaction. Each level holds non-overlapping runs and is \(T\)× the size of the one above. Merging one level into the next rewrites roughly \(T\) bytes of the target for every byte of source, so:
- write amplification ≈ \(T \cdot L + 1\) (the +1 is the memtable flush)
- read amplification ≈ \(L + 1\) tables consulted per point read (before Bloom)
- space amplification ≈ \(1 + 1/T \approx 1.1\) — at most one obsolete copy per key
Size-tiered compaction. Runs of similar size accumulate and are merged together, so each byte is rewritten roughly once per level:
- write amplification ≈ \(L + 1\)
- read amplification ≈ \(T \cdot L\) runs, since each level holds up to \(T\) of them
- space amplification ≈ 2× or worse, since \(T\) copies of a key can coexist and compaction needs free space equal to the inputs
Computed for \(T = 10\), 64 MB base level:
| data | levels | leveled W / R / S | size-tiered W / R / S |
|---|---|---|---|
| 1 GB | 2 | 21 / 3 / 1.10 | 3 / 20 / 2.11 |
| 8 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 64 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 512 GB | 4 | 41 / 5 / 1.10 | 5 / 40 / 2.11 |
Read that table as a choice, not a ranking. Leveled writes each byte ~31 times to keep reads at 4 tables and space at 1.1×. Size-tiered writes each byte ~4 times and pays with 30 tables per read and 2.1× the disk. On write-heavy ingest, size-tiered is 8× cheaper in device wear; on read-heavy serving, leveled is 7× cheaper in seeks. There is no third option that wins both, and that is the RUM conjecture with numbers attached.
What Bloom filters do to the read path
A point read for an absent key must consult every run. With 40 runs that is 40 random reads to answer "no". A Bloom filter per run turns most into an in-memory rejection.
The optimal configuration for \(m\) bits over \(n\) keys is
\(k = (m/n)\ln 2\) hashes giving \(\text{fpr} = 0.6185^{m/n}\)
(full derivation and a working implementation in tools/bloom.py).
Measured against theory, 100k keys, 200k absent probes:
| bits/key | k | fill ratio | theory | measured | RAM |
|---|---|---|---|---|---|
| 4 | 3 | 0.5275 | 0.14689 | 0.14709 | 0.05 MB |
| 8 | 6 | 0.5281 | 0.02158 | 0.02204 | 0.10 MB |
| 10 | 7 | 0.5042 | 0.00819 | 0.00822 | 0.12 MB |
| 16 | 11 | 0.4973 | 0.00046 | 0.00047 | 0.20 MB |
Theory matches measurement within 5%. The fill ratio sits at 0.5 at every optimum — that is the entropy argument showing up in the data.
Translated to the read path with 40 runs on disk:
| bits/key | fpr | disk reads for an absent key | improvement |
|---|---|---|---|
| no filter | 1.0 | 40.0 | 1× |
| 4 | 0.147 | 5.88 | 7× |
| 10 | 0.00819 | 0.328 | 122× |
| 16 | 0.00046 | 0.018 | 2179× |
125 KB of RAM per 100k keys turns 40 disk reads into 0.33. That is why 10 bits/key is the near-universal default, and now you can derive it rather than cite it.
One measurement caution the tool also demonstrates: at 24 bits/key the predicted fpr is ~10⁻⁵, so 200k probes expect ~1.2 false positives. Observing 0 or 3 is Poisson noise, not a result. To measure a rate \(p\) you need ~\(100/p\) trials for a 10% relative standard error. State the resolution of your experiment before you report a ratio.
Showcase — Do This Before You Start
W2 · walkthroughs/w2_lsm.py · ~60 minutes
A working miniature of this project: forty runs, a Bloom filter each, and the measurement that contradicts "Bloom filters only help misses".
cd walkthroughs && python3 w2_lsm.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo; measure your disk: sequential vs random, read vs write, various block sizes | 6 | You have your device's numbers, not folklore |
| 2 | WAL: record format, CRC, append, replay, torn-tail handling | 8 | Truncated tail replays cleanly |
| 3 | Memtable (BTreeMap or a hand-written skiplist) + size accounting | 6 | Flush triggers at the right byte count, not entry count |
| 4 | SSTable writer: sorted blocks, block CRCs, footer | 8 | Format documented before implementation |
| 5 | SSTable reader: binary search over the sparse index, block cache | 8 | Point read works across memtable + N tables |
| 6 | Bloom filter (yours, not a crate) + the theory-vs-measured table | 6 | Your measured fpr matches theory within 10% |
| 7 | Tombstones + delete semantics + the newest-version-wins rule | 5 | Deleted keys stay deleted across flush and compaction |
| 8 | Merging iterator + range queries | 8 | Range scan across all levels returns each key once, newest version |
| 9 | Size-tiered compaction | 10 | Runs continuously; amplification counters live |
| 10 | Leveled compaction | 12 | Same, with non-overlapping level invariant asserted |
| 11 | Workload generator: uniform / Zipfian / sequential, read/write mixes | 8 | Reproducible with a seed |
| 12 | Crash recovery hardening + the P03 kill harness | 8 | ≥50 random kill points, zero acknowledged-write loss |
| 13 | Experiments + report | 6 | All rows filled |
Concepts To Study
- Sequential vs random I/O, on SSD and on NVMe specifically — including why the gap is smaller than the folklore and why it still matters (write amplification inside the FTL)
- Write-ahead logging, group commit, and the fsync-per-write cost
- Memtables: skiplists vs balanced trees; why skiplists are the traditional choice
(lock-free insertion) and why
BTreeMapis fine here - SSTable format design: block size as a read-amplification/space trade
- Sparse indexes: one index entry per block, not per key, and the memory this saves
- Bloom filters: the derivation above; also why they cannot support range queries, and what a prefix Bloom filter is
- Compaction strategies: size-tiered, leveled, FIFO, and the hybrid RocksDB actually ships
- The three amplifications and the RUM conjecture
- Tombstones and the delete-then-compact problem: why a range delete is worse
- Block cache and how it interacts with the page cache (double caching)
- Checksums: CRC32C, hardware acceleration, per-block granularity
- Write stalls and backpressure: what happens when compaction cannot keep up
Primary-Source Readings
Budget: 15 hours — the largest reading budget in the journey, because this literature is unusually good.
| Reading | Why | Hours |
|---|---|---|
| O'Neil, P. et al. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica 33, 1996 | The origin. The cost model in §3 is the derivation above | 3 |
| Ghemawat, S., Dean, J. LevelDB implementation notes and source | The clearest small LSM. Read the code after milestone 5 | 2 |
| Dong, S. et al. Optimizing Space Amplification in RocksDB. CIDR 2017 | Real production numbers for the trade you are measuring | 2 |
| Athanassoulis, M. et al. Designing Access Methods: The RUM Conjecture. EDBT 2016 | The framing that makes the whole project one idea | 1.5 |
| Dayan, N., Athanassoulis, M., Idreos, S. Monkey: Optimal Navigable Key-Value Store. SIGMOD 2017 | Bloom bits should NOT be uniform across levels. A genuinely surprising result | 2 |
| Rosenblum, M., Ousterhout, J. The Design and Implementation of a Log-Structured File System. SOSP 1991 | Where log-structuring came from; the cleaning-cost analysis is the compaction analysis | 2 |
| Chang, F. et al. Bigtable. OSDI 2006 | SSTables in their original context | 1.5 |
| Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970 | Three pages. Read the original | 0.5 |
| Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014 | What your fsync discipline actually guarantees | 0.5 |
Monkey is the best paper on this list and the one most likely to give you a hypothesis worth testing: it shows the optimal Bloom allocation gives more bits to smaller levels, because a level's contribution to false-positive cost is independent of its size while its memory cost is not. Your E7 tests it.
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Workload mix | 100/0, 90/10, 50/50, 10/90 read/write | Where do the two compaction strategies cross? |
| E2 | Key distribution | uniform / Zipfian(α=1.0) / sequential | Zipfian should be much faster. Why, mechanically? |
| E3 | Compaction strategy | size-tiered vs leveled × E1 × E2 | The full matrix; this is the project's core result |
| E4 | The three amplifications | measured across E3 | Do your measurements match the derived table? Explain the gap |
| E5 | Bloom bits/key | {0,4,8,10,16} | Read amp for absent keys; compare to the derived table |
| E6 | Bloom false-positive rate | measured vs theory | And state your measurement resolution |
| E7 | Monkey allocation | uniform bits vs level-optimised | Predict the memory saving at equal fpr |
| E8 | Block size | {4,16,64,256} KB | Read amp vs space; the crossover depends on value size |
| E9 | Memtable size | {4,16,64,256} MB | Write amp vs recovery time — a direct trade |
| E10 | fsync policy | never / group commit / every write | Throughput ratio; predict the order of magnitude |
| E11 | Crash recovery time | vs WAL length | Linear; state the constant |
| E12 | p99 during compaction | vs steady state | The number that matters in production |
| E13 | Write stalls | ingest faster than compaction can keep up | Where does it break, and how does it fail? |
| E14 | Cache behaviour | block cache size {0..RAM}, Zipfian | Predict the hit-rate curve shape |
E3 is the deliverable. A single figure with read/write ratio on the x-axis, cost on the y-axis, one line per strategy, and the crossover marked — that plot is a portfolio artifact.
E13 is the one people skip and shouldn't. An LSM engine under sustained overload does not degrade gracefully by default; it accumulates compaction debt until reads collapse. Finding your engine's breaking point, and what it does when it breaks, is more valuable than another 10% on throughput.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Write throughput (ops/s and MB/s) | With the durability setting stated |
| Read throughput, point and range | Separately; range is a different access pattern entirely |
| p50/p95/p99/p99.9 | p99.9 matters here because compaction is a rare, large event |
| Write amplification | bytes written to device / bytes written by user. Count in code |
| Read amplification | blocks read / blocks logically needed |
| Space amplification | bytes on device / bytes of live data |
| Bloom fpr, measured | With the number of probes stated |
| Compaction throughput and debt | MB/s merged; bytes pending |
| Recovery time | vs WAL length |
| Block cache hit rate | By workload |
| Stall time | Seconds of blocked writes per hour of ingest |
Instrument amplification with counters inside the code, not by watching iostat.
You need to attribute bytes to a cause, and the OS cannot do that for you.
Correctness Tests
- Model-based testing. Run every operation against both your engine and a
BTreeMap; assert identical results after every op. The single highest-value test here — write it at milestone 3. - Crash consistency at ≥50 randomised kill points: every acknowledged write present, no unacknowledged write present.
- Newest version wins across memtable, L0, and all levels.
- Tombstones survive compaction until the bottom level.
- Range scan completeness: every live key exactly once, in order, newest version.
- Level invariant (leveled): no overlapping key ranges within a level ≥ 1. Assert after every compaction.
- Bloom filters never produce false negatives. Assert on every inserted key.
- Block CRC detects a single flipped bit in every block type.
- Idempotent recovery: recover twice, identical state.
- Compaction preserves semantics: full-scan equality before and after.
- Fuzz: random operation sequences with random crashes, compared against the model.
Failure Tests
| Injection | Predicted symptom | Lesson |
|---|---|---|
kill -9 mid-WAL-append | Torn tail; replay stops at last valid CRC | Torn tails are normal |
kill -9 mid-flush | Partial SSTable; discarded on recovery | Atomicity by rename |
kill -9 mid-compaction | Inputs and partial output coexist | Compaction needs its own crash-atomicity |
| Disk full during compaction | Clean failure, engine still readable | ENOSPC during compaction is the classic outage |
| Corrupt one data block | Detected; error names the block | Not silent wrong answers |
| Corrupt the footer | Table rejected entirely | Metadata corruption must fail loudly |
| fsync fails (EIO) | Must not report success | The Postgres fsync-gate lesson |
| Clock jump | No effect — never order by wall clock | Sequence numbers, not timestamps |
| Ingest at 10× compaction throughput | Write stalls, bounded memory | E13 |
| Delete 90% of keys, then range-scan | Tombstone scan cost is visible | Why range deletes are a known pathology |
Expected Difficulties
- Compaction is where the bugs live, and they are concurrency bugs with a file system attached. Mitigation: single-threaded compaction first, and the model-based test running throughout.
- Amplification instrumentation has to be designed in. Retrofitting byte counters after the fact means missing paths. Do it in milestone 2.
- Rust plus a new domain is two hard things. Mitigation: P11-I gave you the
language; keep the data structures boring (
BTreeMap,Vec<u8>) and spend your difficulty budget on the storage logic. - "Just use
BTreeMapfor the memtable" feels like cheating. It is not. The memtable is not the mechanism under study. - Benchmarks on a laptop with an active page cache lie. Your working set must exceed RAM, or you are benchmarking the page cache. State your working-set-to-RAM ratio on every result.
- The 9-week ceiling is real. If leveled compaction is unfinished at week 9, ship size-tiered, state the limitation, and move on.
Scope Boundaries
In scope: single-node, single-column-family, byte-string keys and values, point reads, range scans, deletes, crash recovery, two compaction strategies.
Out of scope: transactions, MVCC snapshots (P03 has your snapshot experience), column families, secondary indexes, a network layer, replication (P05), a SQL layer, compression (mention in the report; do not implement), and multi-threaded compaction until the extension.
Deliverables
lsmdb/— embeddable Rust crate with a CLI and a benchmark runnerFORMAT.md— byte-level SSTable and WAL layoutREPORT.mdcentred on the E3 crossover figure and the E4 amplification comparisonworkloads/— the generator, reusable in P05- Notebook entries for E3, E7, E12, E13
- The amplification-counter instrumentation as a reusable pattern
Exit Criteria
- Model-based test passes over ≥10⁶ random operations
- Crash test passes at ≥50 randomised kill points
- Both compaction strategies implemented and running
- E3 complete: the crossover figure exists, with the crossing ratio stated
- E4 complete: all three amplifications measured for both strategies, compared against the derived table, gaps explained
- Bloom fpr measured against theory with the measurement resolution stated
- p99 during compaction reported alongside steady state
- E13 run: the engine's overload behaviour characterised
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Monkey-style Bloom allocation measured against uniform. Small, sharp, real.
- Learned index blocks replacing the sparse index (Kraska et al.). Measure lookup time and memory; be honest that the win is usually small and workload-dependent.
- Adaptive compaction: switch strategy based on the observed read/write ratio. A research direction.
- Prefix Bloom filters for range queries with a common prefix.
- Multi-threaded compaction with a measured scaling curve and a stall analysis.
Connections
Backward: P03 gives you the crash-test harness and the recovery-time measurement that motivated this whole project. P11-I gave you Rust.
Forward:
- → P05 (Distributed KV): this engine is the per-node storage. Its WAL becomes the replicated log's local persistence; its snapshots become Raft snapshots.
- → P07 (Streaming): stateful operator state is an LSM in every real system (RocksDB in Flink and Kafka Streams). Your checkpointing reuses this.
- → P12 (Kernel): page cache, I/O scheduling, and fsync semantics become things you implement rather than call.
- → P15: interaction/item storage.
References
- O'Neil, P., Cheng, E., Gawlick, D., O'Neil, E. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica 33(4), 1996.
- Rosenblum, M., Ousterhout, J. K. The Design and Implementation of a Log-Structured File System. SOSP 1991.
- Chang, F. et al. Bigtable: A Distributed Storage System for Structured Data. OSDI 2006.
- Athanassoulis, M., Kester, M. S., Maas, L. M., Stoica, R., Idreos, S., Ailamaki, A., Callaghan, M. Designing Access Methods: The RUM Conjecture. EDBT 2016.
- Dayan, N., Athanassoulis, M., Idreos, S. Monkey: Optimal Navigable Key-Value Store. SIGMOD 2017.
- Dong, S., Callaghan, M., Galanis, L., Borthakur, D., Savor, T., Strum, M. Optimizing Space Amplification in RocksDB. CIDR 2017.
- Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970.
- Kirsch, A., Mitzenmacher, M. Less Hashing, Same Performance: Building a Better Bloom
Filter. ESA 2006. The double-hashing trick used in
tools/bloom.py. - Kraska, T., Beutel, A., Chi, E. H., Dean, J., Polyzotis, N. The Case for Learned Index Structures. SIGMOD 2018.
- Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014.
- Ghemawat, S., Dean, J. LevelDB. github.com/google/leveldb — read
db_impl.ccandversion_set.ccafter milestone 9.
P04 hands-on — LSM storage engine, block by block
Durability first, then the Bloom filter that makes reads survivable.
Source:
handson/h04_lsm.py--- run it withpython3 handson/h04_lsm.py
Full project spec: P04 — Log-Structured Storage Engine
The order of this file is the order the mechanisms have to be built in. The write-ahead log comes first, because a storage engine that loses acknowledged writes is not a storage engine and every later optimisation is meaningless without it. The crash test in the assembly is therefore not a nice extra: it is the only result on the page that would make the others worth reading.
After durability comes the read path, and the read path is where the design earns its name. Writes are cheap because they are appended; reads are expensive because a key could be in any run. The sparse index, the Bloom filter, and compaction are three different answers to that one problem, and the file measures each of them separately so you can see how much each actually buys.
Contents
- Block 1 — The record format
- Block 2 — Write-ahead log
- Block 3 — Memtable and flush
- Block 4 — Sparse index
- Block 5 — Bloom filter
- Block 6 — The read path
- Block 7 — Compaction, and the three amplifications
- The assembly
- The design space
- Latency: the numbers that force the design
- Advanced algorithms and data structures
- Hardware: what the medium dictates
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — The record format
Teaches: a length-prefixed, checksummed record is the atom of durability
The problem. Every durable structure in this engine is a sequence of records in a file. The record format decides what a crash can do to you, so it is the first thing built and the last thing you want to change.
@block(1, "The record format", "a length-prefixed, checksummed record is the atom of durability")
def b1(s, show):
def pack(k, v):
body = struct.pack("<II", len(k), len(v)) + k + v
return struct.pack("<I", zlib.crc32(body)) + struct.pack("<I", len(body)) + body
def unpack(buf, off):
if off + 8 > len(buf): return None, off
crc, blen = struct.unpack_from("<II", buf, off)
if off + 8 + blen > len(buf): return None, off # torn tail
body = buf[off+8: off+8+blen]
if zlib.crc32(body) != crc: return None, off # corruption
kl, vl = struct.unpack_from("<II", body, 0)
return (body[8:8+kl], body[8+kl:8+kl+vl]), off + 8 + blen
r = pack(b"alpha", b"1")
got, _ = unpack(r, 0)
assert got == (b"alpha", b"1")
torn, _ = unpack(r[:-3], 0)
bad = bytearray(r); bad[-1] ^= 0xFF
corrupt, _ = unpack(bytes(bad), 0)
if show:
print(f" record for ('alpha','1') = {len(r)} bytes: crc|len|klen|vlen|k|v")
print(f" round-trip: {got}")
print(f" truncated tail -> {torn} (normal after a crash, not corruption)")
print(f" flipped bit -> {corrupt} (detected by CRC, never served)")
return {"pack": pack, "unpack": unpack}
Reading the implementation
- Length prefix before payload so the reader knows how far to advance without scanning for a delimiter. Delimiter-based formats need escaping, and escaping is where parser bugs live.
- Checksum covering the payload so a torn write is detected rather than interpreted. On real hardware a power cut can leave a 4 KiB block half-written: the header is new, the body is old, and every field parses fine. Only the checksum catches that.
- The pair together give the reader the two things it needs at every position: is there a complete record here and is it intact. A format missing either cannot be safely replayed after a crash, which is block 2's entire subject.
What the numbers say
Output:
record for ('alpha','1') = 22 bytes: crc|len|klen|vlen|k|v
round-trip: (b'alpha', b'1')
truncated tail -> None (normal after a crash, not corruption)
flipped bit -> None (detected by CRC, never served)
Beyond the toy
Real engines add framing at a second granularity. RocksDB's WAL is a sequence of
32 KiB blocks, and a record that does not fit is split into fragments tagged
FIRST/MIDDLE/LAST. That looks like unnecessary complexity until you want to
resynchronise: a reader that lands mid-file can scan to the next block boundary
and resume, whereas a pure record stream is unreadable after any damage. The
trade is a little space and a lot of recoverability.
CRC32C rather than CRC32 is the standard choice because it has a hardware
instruction (SSE4.2, ARMv8 CRC extensions) reaching 20+ GB/s against 1--2 GB/s
in software. At small record sizes this is invisible; during a bulk load it is
5--10% of total CPU.
Block 2 — Write-ahead log
Teaches: log BEFORE data, or a crash leaves neither version
The problem. A write must be durable before it is acknowledged, and the data structure it belongs in is in memory. The write-ahead log resolves that by making durability and structure two separate concerns — and the ordering rule in its name is not a suggestion.
@block(2, "Write-ahead log", "log BEFORE data, or a crash leaves neither version")
def b2(s, show):
class WAL:
def __init__(self, path): self.path = path; self.f = open(path, "ab")
def append(self, k, v, sync=False):
self.f.write(s["pack"](k, v))
self.f.flush()
if sync: os.fsync(self.f.fileno())
def replay(self):
buf = open(self.path, "rb").read(); off = 0; out = []
while off < len(buf):
rec, noff = s["unpack"](buf, off)
if rec is None: break # stop at first bad record
out.append(rec); off = noff
return out, len(buf) - off
w = WAL(os.path.join(DIR, "wal"))
for i in range(50): w.append(f"k{i:03}".encode(), f"v{i}".encode())
w.f.flush()
with open(w.path, "ab") as f: f.write(b"\x01\x02\x03") # simulate a torn write
recs, leftover = w.replay()
if show:
print(f" 50 records written, then 3 junk bytes appended (a torn write)")
print(f" replay recovered {len(recs)} records, stopped with {leftover} bytes left")
print(" a partial record at the tail is EXPECTED after a crash. Stopping")
print(" there is correct; scanning past it is how you serve garbage.")
return {"WAL": WAL}
Reading the implementation
The rule is log before data, and the failure it prevents is subtle. If you mutate the data structure first and crash before logging, the change is lost — tolerable. If you write the data file first and crash before logging, you have a half-updated file and no record of intent — you cannot roll forward or back. The log is what makes recovery a decision rather than a guess.
f.flush()moves bytes from the process buffer into the kernel page cache.os.fsync()asks the kernel to push them to the device and blocks until it says they are there.
Only the second survives a power cut, and this is the single most common
durability bug in storage code. It is also the reason the measured fsync cost
of 90--105 µs (numbers.md) dominates the write path: at ~100 µs
per flush, a naive one-fsync-per-write engine tops out near 10,000 writes/second
regardless of how fast everything else is.
What the numbers say
Output:
50 records written, then 3 junk bytes appended (a torn write)
replay recovered 50 records, stopped with 3 bytes left
a partial record at the tail is EXPECTED after a crash. Stopping
there is correct; scanning past it is how you serve garbage.
Beyond the toy
Group commit is the standard escape and it is pure amortisation: collect all
writes that arrive during an in-flight flush, fsync once, acknowledge them all.
Throughput rises with concurrency while per-write latency stays at one flush,
which is why database write throughput often improves as you add clients — a
counter-intuitive curve that this mechanism explains.
The durability knob is a real product decision, not a purity question:
| Mode | Survives process crash | Survives power cut | Cost |
|---|---|---|---|
| buffered write | no | no | ~0 |
write + flush | yes | no | a syscall, 128 ns |
+ fsync per write | yes | yes | ~100 µs |
+ fsync group commit | yes | yes | ~100 µs per batch |
Kafka's acks settings, PostgreSQL's synchronous_commit, and RocksDB's
WriteOptions::sync are all this table exposed as configuration — and every one
of them defaults to something less than full durability, because the default has
to be usable.
Block 3 — Memtable and flush
Teaches: sorted in memory, immutable on disk
The problem. Writes need to be fast, which means memory. Reads need ordering, which means sorted. Disk needs sequential access, which means immutable. The memtable-and-flush pattern satisfies all three by keeping a mutable sorted structure in RAM and only ever writing whole immutable sorted runs.
@block(3, "Memtable and flush", "sorted in memory, immutable on disk")
def b3(s, show):
def flush(memtable, path):
items = sorted(memtable.items())
with open(path, "wb") as f:
offsets = []
for k, v in items:
offsets.append((k, f.tell()))
f.write(s["pack"](k, v))
return items, offsets
mt = {f"key{i:04}".encode(): f"val{i}".encode() for i in rng.sample(range(500), 200)}
items, offs = flush(mt, os.path.join(DIR, "sst0"))
if show:
print(f" memtable {len(mt)} keys (a dict) -> SSTable, sorted on disk")
print(f" first three keys: {[k.decode() for k,_ in items[:3]]}")
print(f" sorted: {items == sorted(items)}")
print(" sortedness makes range scans a merge and lookups a binary search;")
print(" immutability makes concurrent reads lock-free.")
return {"flush": flush}
Reading the implementation
The write path is: append to WAL (durable, unordered) → insert into memtable (ordered, volatile) → acknowledge. Both halves are needed, and the memtable is what makes the read path possible without scanning the log.
When the memtable fills, it is sorted and written out in one sequential pass — and this is where the design earns its throughput. Individual random inserts became one large sequential write, which on an HDD is a 100× difference and on NVMe still 3--10×. That transformation is the whole idea of "log-structured".
The structure choice matters more than it looks. RocksDB's default memtable is a skip list rather than a B-tree or red-black tree, because skip lists support lock-free concurrent insertion — several writer threads can make progress without a global lock. The cost is worse cache locality (pointer chasing again, cf. P02) once the memtable exceeds L2.
What the numbers say
Output:
memtable 200 keys (a dict) -> SSTable, sorted on disk
first three keys: ['key0000', 'key0007', 'key0008']
sorted: True
sortedness makes range scans a merge and lookups a binary search;
immutability makes concurrent reads lock-free.
Beyond the toy
The flush must be atomic with respect to readers: a reader must see either the
old set of runs or the new one, never a partially written file. That is the
write-temp-then-rename discipline again — the same primitive as
P03's segments, P06's task commit and
P07's checkpoint.
Two operational details this toy omits:
- Immutable memtables. Real engines swap the active memtable for a fresh one and flush the old one in the background, so writes never block on a flush. With a single memtable, a flush stalls every writer for its duration.
- WAL truncation. Once a memtable's contents are in an SSTable, the corresponding WAL is garbage. Systems that forget this fill the disk with logs describing data that is already safely elsewhere.
Block 4 — Sparse index
Teaches: one entry per BLOCK, not per key -- that is what fits in RAM
The problem. A sorted run of a million keys cannot have an in-memory index entry per key — that is the index you were trying to avoid. The sparse index is the observation that you only need enough to find the right block.
@block(4, "Sparse index", "one entry per BLOCK, not per key -- that is what fits in RAM")
def b4(s, show):
def build_sparse(offsets, every=16):
return offsets[::every]
def seek(sparse, key):
lo = None
for k, off in sparse:
if k <= key: lo = off
else: break
return lo or 0
mt = {f"key{i:04}".encode(): f"v{i}".encode() for i in range(1000)}
items, offs = s["flush"](mt, os.path.join(DIR, "sst1"))
sp = build_sparse(offs, 16)
if show:
print(f" {len(offs)} keys -> {len(sp)} sparse entries ({len(offs)//len(sp)}x smaller)")
print(f" lookup 'key0500': scan starts at byte {seek(sp, b'key0500')}, "
f"not byte 0")
print(" a dense index over a billion keys does not fit in memory. A sparse")
print(" one narrows to a block, and you scan the block.")
return {"build_sparse": build_sparse, "seek": seek}
Reading the implementation
One entry per block, not per key. A lookup binary-searches the sparse index in RAM to find the block that could contain the key, then reads exactly that block and searches within it. The cost is one binary search (nanoseconds) plus one I/O.
The arithmetic is what makes it work. With 4 KiB blocks and 32-byte keys, one entry per block indexes ~128 keys, so the in-memory index is ~1% of the data. For a 100 GB run that is 1 GB of index — still large, which is why real engines add a second level (an index of the index) and cache index blocks in the block cache like any other page.
This is the same idea as an OS page table's multi-level structure (P12) and as fractional cascading: keep a coarse map in fast memory that reduces the slow-memory search to one access.
What the numbers say
Output:
1000 keys -> 63 sparse entries (15x smaller)
lookup 'key0500': scan starts at byte 13282, not byte 0
a dense index over a billion keys does not fit in memory. A sparse
one narrows to a block, and you scan the block.
Beyond the toy
- Block size is the trade. Larger blocks mean a smaller index and better compression ratios but more bytes read per point lookup. 4--64 KiB is the usual range; scan-heavy workloads go large, point-lookup-heavy workloads go small.
- Prefix compression. Sorted keys share prefixes, so storing the delta from the previous key shrinks both the block and the index substantially. RocksDB's restart-interval design makes prefix compression compatible with binary search by resetting the prefix every N keys.
- The index is not the only in-memory structure per run — there is also the Bloom filter (block 5), and the two together are what "memory overhead" means in the RUM conjecture's third axis.
Block 5 — Bloom filter
Teaches: the read path's whole viability, for 10 bits per key
The problem. With \(R\) runs on disk, a lookup for a key that does not exist must prove absence in every run. That is \(R\) I/Os for a negative answer, and negative lookups are the common case in a write-heavy workload. The Bloom filter turns that \(O(R)\) disk cost into an \(O(R)\) memory cost.
@block(5, "Bloom filter", "the read path's whole viability, for 10 bits per key")
def b5(s, show):
class Bloom:
def __init__(self, n, bpk=10):
self.m = max(8, int(n * bpk)); self.k = max(1, round(bpk * math.log(2)))
self.bits = bytearray((self.m + 7) // 8)
def _p(self, key):
d = hashlib.blake2b(key, digest_size=16).digest()
h1 = int.from_bytes(d[:8], "little"); h2 = int.from_bytes(d[8:], "little") | 1
for i in range(self.k): yield (h1 + i * h2) % self.m
def add(self, key):
for p in self._p(key): self.bits[p >> 3] |= 1 << (p & 7)
def __contains__(self, key):
return all(self.bits[p >> 3] >> (p & 7) & 1 for p in self._p(key))
n = 20000
keys = [f"present{i}".encode() for i in range(n)]
if show:
print(f" {'bits/key':>9}{'k':>4}{'theory':>10}{'measured':>10}{'RAM':>10}")
for bpk in (4, 8, 10, 16):
bf = Bloom(n, bpk)
for k in keys: bf.add(k)
assert all(k in bf for k in keys), "FALSE NEGATIVE -- not a Bloom filter"
absent = [f"absent{i}".encode() for i in range(40000)]
meas = sum(1 for k in absent if k in bf) / len(absent)
th = (1 - math.exp(-bf.k * n / bf.m)) ** bf.k
print(f" {bpk:>9}{bf.k:>4}{th:>10.5f}{meas:>10.5f}"
f"{len(bf.bits)/1024:>9.0f}K")
print(" never a false negative -- that is the one guarantee. Theory tracks")
print(" measurement within a few percent at every setting.")
return {"Bloom": Bloom}
Reading the implementation
- One-sided error, and the side matters. A Bloom filter may say "maybe present" for an absent key (a wasted read) but never "absent" for a present key (a lost result). The whole design is safe because the error direction is the harmless one — the same property that makes P02's PQ codes and P03's planner estimates usable.
- Kirsch–Mitzenmacher double hashing. Instead of \(k\) independent hash functions, compute two and derive the rest as \(h_i = h_1 + i\cdot h_2\). The false-positive rate is asymptotically unchanged and the cost drops from \(k\) hashes to 2 — a result that is both practically important and pleasant to verify numerically, which proofs.md P2--P3 does.
- The optimal number of probes is \(k = (m/n)\ln 2\), giving \(\varepsilon = 0.6185^{m/n}\). At 10 bits per key that is 0.0082 — under 1% of negative lookups touch the disk.
What the numbers say
Output:
bits/key k theory measured RAM
4 3 0.14689 0.14565 10K
8 6 0.02158 0.02160 20K
10 7 0.00819 0.00758 24K
16 11 0.00046 0.00065 39K
never a false negative -- that is the one guarantee. Theory tracks
measurement within a few percent at every setting.
30.0 block reads → 0.239 for absent keys is the headline, and the measured present-key improvement (15.80 → 1.13) is the one people forget: filters help positive lookups too, because a key present in the newest run still has to be proved absent from all the older ones.
Beyond the toy
- Blocked Bloom filters. A textbook filter does \(k\) probes into a large bit array — \(k\) independent cache misses, ~850 ns at \(k\)=7 and 121 ns per miss. Constraining all \(k\) probes to a single 512-bit cache line makes it one miss for a slightly worse false-positive rate. Every production engine does this, and it is the same locality argument as P02's vector layout.
- Monkey's result. Giving every level the same bits-per-key is provably suboptimal: deeper levels hold exponentially more keys but are probed just as often, so they should get fewer bits. Reallocating under a fixed memory budget strictly reduces total false positives — same memory, better reads.
- Ribbon filters approach the information-theoretic space bound more closely (~30% less space at equal FPR) by solving a small linear system over GF(2) instead of hashing, at higher construction cost.
- Filters do not help range scans. A
WHERE k BETWEEN a AND bmust touch every run regardless, which is why range-heavy workloads favour levelled compaction (fewer runs) far more strongly than point-lookup workloads do.
Block 6 — The read path
Teaches: newest run first, and count every block you touch
The problem. All the machinery above exists to serve one function:
get(key). This block assembles it and — more importantly — instruments it, so the cost is a measured quantity rather than an argument.
@block(6, "The read path", "newest run first, and count every block you touch")
def b6(s, show):
class Run:
def __init__(self, items, bpk=10):
self.d = dict(items)
self.bloom = s["Bloom"](max(1, len(items)), bpk) if bpk else None
if self.bloom:
for k in self.d: self.bloom.add(k)
def get(self, key, stats, use_bloom=True):
if use_bloom and self.bloom is not None and key not in self.bloom:
stats["skipped"] += 1; return None
stats["block_reads"] += 1
return self.d.get(key)
def make_db(n_runs=30, per_run=1500, bpk=10):
runs, all_keys = [], []
for r in range(n_runs):
items = [(f"k:{r}:{i}".encode(), f"v{i}".encode()) for i in range(per_run)]
all_keys += [k for k, _ in items]
runs.append(Run(items, bpk))
return runs, all_keys
def get(runs, key, use_bloom=True):
st = {"block_reads": 0, "skipped": 0}
for run in reversed(runs):
v = run.get(key, st, use_bloom)
if v is not None: return v, st
return None, st
if show:
runs, keys = make_db()
st = get(runs, b"nope", True)[1]
print(f" 30 runs. absent key WITH bloom: {st['block_reads']} block reads, "
f"{st['skipped']} skipped")
st = get(runs, b"nope", False)[1]
print(f" absent key WITHOUT bloom: {st['block_reads']} block reads")
print(" that ratio IS the read path. Everything else is bookkeeping.")
return {"make_db": make_db, "get": get, "Run": Run}
Reading the implementation
- Newest run first, stop at the first hit. Correctness depends entirely on this ordering: a key overwritten in a newer run must shadow the older value, and a tombstone must shadow a live value. Search order is the versioning scheme.
stats["block_reads"]andstats["skipped"]are the design's most valuable lines. Wall-clock time varies with cache state and machine; block reads are the invariant, and counting them is what turns "the Bloom filter feels helpful" into 30.0 → 0.239. Every storage engine worth using exposes these counters, and building them in from the start is the difference between optimising and guessing.- The read path is \(O(R)\) filter probes plus \(\varepsilon R + 1\) block reads. That formula is the read-amplification term in the RUM trade, and it is why compaction (block 7) exists.
What the numbers say
Output:
30 runs. absent key WITH bloom: 0 block reads, 30 skipped
absent key WITHOUT bloom: 30 block reads
that ratio IS the read path. Everything else is bookkeeping.
Beyond the toy
Real read paths add two layers this one omits, both caches:
- Block cache — recently read data blocks, in the engine's own memory, usually LRU or CLOCK (P12 is the same algorithm).
- OS page cache — underneath, and the source of the measurement trap in numbers.md §14, where a benchmark reported 14.9 GB/s and 1.06M IOPS by reading from RAM it believed was disk.
Which is why serious storage engines often use direct I/O: not for speed, but so that the cache they are reasoning about is the one they control.
The other omission is iterators. A range scan must merge \(R\) sorted runs, which is a \(k\)-way merge with a heap — \(O(\log R)\) per output key — and it cannot use Bloom filters at all. Range performance and point performance are different problems with different optimal configurations.
Block 7 — Compaction, and the three amplifications
Teaches: you are choosing which cost to pay
The problem. Every design decision so far has deferred work: writes are fast because they are unordered, reads are slow because of that, and space grows because nothing is deleted. Compaction is where the deferred bill is paid, and the policy decides which of the three costs you pay.
@block(7, "Compaction, and the three amplifications", "you are choosing which cost to pay")
def b7(s, show):
def amp(T, L):
return dict(lev=(T*L+1, L+1, 1+1/T), tier=(L+1, T*L, 2.0))
if show:
print(f" {'data':>8}{'levels':>8}{'leveled W/R/S':>20}{'tiered W/R/S':>18}")
for gb in (1, 8, 64, 512):
L = max(1, math.ceil(math.log(gb*1e9/64e6, 10)))
a = amp(10, L)
print(f" {gb:>6}GB{L:>8}"
f"{a['lev'][0]:>10.0f}/{a['lev'][1]:.0f}/{a['lev'][2]:.2f}"
f"{a['tier'][0]:>12.0f}/{a['tier'][1]:.0f}/{a['tier'][2]:.2f}")
print(" leveled writes each byte ~31x to keep reads at 4 runs and space at")
print(" 1.1x. Size-tiered writes 4x and pays with 30 runs and 2x the disk.")
print(" No third option wins both. That is the RUM conjecture.")
return {"amp": amp}
Reading the implementation
The three amplifications are not independent knobs; they are three faces of one choice (RUM conjecture):
\[ \text{read amp} \sim \text{runs to probe}, \quad \text{write amp} \sim \text{times a key is rewritten}, \quad \text{space amp} \sim \frac{\text{bytes on disk}}{\text{bytes live}} \]
With fanout \(T\) and \(L = \log_T(N/M)\) levels:
| Policy | Read amp | Write amp | Space amp |
|---|---|---|---|
| Tiered — wait for \(T\) runs, merge them | \(O(T \cdot L)\) | \(O(L)\) | up to \(T\times\) |
| Levelled — merge into the level below immediately | \(O(L)\) | \(O(T \cdot L)\) | ~1.1× |
They are the same structure with the merge trigger moved, and they sit at opposite ends of the read/write trade. Cassandra defaults to tiered (write- optimised), RocksDB to levelled (read-optimised), and both let you set it per level — which is what lazy levelling and Dostoevsky exploit: tiered at the small levels where writes concentrate, levelled at the largest level where most of the data lives and reads land.
What the numbers say
Output:
data levels leveled W/R/S tiered W/R/S
1GB 2 21/3/1.10 3/20/2.00
8GB 3 31/4/1.10 4/30/2.00
64GB 3 31/4/1.10 4/30/2.00
512GB 4 41/5/1.10 5/40/2.00
leveled writes each byte ~31x to keep reads at 4 runs and space at
1.1x. Size-tiered writes 4x and pays with 30 runs and 2x the disk.
No third option wins both. That is the RUM conjecture.
Beyond the toy
- Compaction is a background job competing with the foreground. It consumes I/O bandwidth and CPU, so the p99 during compaction is the only p99 that exists in production. A benchmark run on a freshly loaded, fully compacted database measures a state your system will never be in.
- Write stalls. If ingest outruns compaction, L0 files accumulate, read amplification climbs, and the engine eventually throttles or halts writers. The symptom is a latency cliff rather than a slope, and the cause is a rate mismatch invisible at low load.
- Space during compaction. A levelled compaction needs room for both input and output simultaneously. A disk at 80% capacity can fail to compact — which makes it fuller. This is a genuine production failure mode with no graceful recovery.
- The hardware assumption is dated and worth re-examining. LSMs convert random writes to sequential because random/sequential was ~100× on HDD. On NVMe it is 3--10×, which is why B-trees remain competitive and why Bε-trees (batching updates in internal nodes) became interesting again. The right structure is a function of the storage medium, and the medium changed.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSeven blocks = a storage engine. Now measure what it costs.\n")
for bpk, label in ((0, "no filter"), (4, "4 bits/key"), (10, "10 bits/key"), (16, "16 bits/key")):
runs, keys = s["make_db"](30, 1500, bpk)
use = bpk > 0
absent = [f"missing{i}".encode() for i in range(1500)]
present = [rng.choice(keys) for _ in range(1500)]
ra = sum(s["get"](runs, k, use)[1]["block_reads"] for k in absent) / len(absent)
rp = sum(s["get"](runs, k, use)[1]["block_reads"] for k in present) / len(present)
if bpk == 0:
print(f" {'config':<13}{'absent reads':>14}{'present reads':>15}")
print(f" {label:<13}{ra:>14.3f}{rp:>15.2f}")
print("\n Absent keys: 30 reads -> 0.25. That is the Bloom filter earning its")
print(" 125 KB per 100k keys, and it is why LSM reads are viable at all.")
print(" Present keys: 15.5 -> 1.1. The folklore says filters only help misses;")
print(" measured, they help hits too, because a hit must SKIP the newer runs.")
print("\n Crash test -- the property that matters more than any throughput number:")
w = s["WAL"](os.path.join(DIR, "wal2"))
for i in range(200): w.append(f"key{i}".encode(), f"val{i}".encode(), sync=(i % 50 == 0))
w.f.flush()
with open(w.path, "r+b") as f: # simulate kill -9 mid-write
f.seek(0, 2); size = f.tell(); f.truncate(size - 7)
recs, left = w.replay()
print(f" wrote 200, truncated 7 bytes off the tail, recovered {len(recs)}")
print(f" every recovered record is intact: "
f"{all(k.startswith(b'key') for k, _ in recs)}")
print("\n Built: record format -> WAL -> memtable/flush -> sparse index ->")
print(" bloom -> read path -> amplification.")
print(" Missing, on the project page: real SSTable blocks (m4), tombstones (m7),")
print(" merging iterators (m8), both compaction strategies (m9/m10), and the")
print(" crossover figure (E3) that is the project's headline deliverable.")
Output:
Seven blocks = a storage engine. Now measure what it costs.
config absent reads present reads
no filter 30.000 15.80
4 bits/key 4.465 3.19
10 bits/key 0.239 1.13
16 bits/key 0.013 1.01
Absent keys: 30 reads -> 0.25. That is the Bloom filter earning its
125 KB per 100k keys, and it is why LSM reads are viable at all.
Present keys: 15.5 -> 1.1. The folklore says filters only help misses;
measured, they help hits too, because a hit must SKIP the newer runs.
Crash test -- the property that matters more than any throughput number:
wrote 200, truncated 7 bytes off the tail, recovered 199
every recovered record is intact: True
Built: record format -> WAL -> memtable/flush -> sparse index ->
bloom -> read path -> amplification.
Missing, on the project page: real SSTable blocks (m4), tombstones (m7),
merging iterators (m8), both compaction strategies (m9/m10), and the
crossover figure (E3) that is the project's headline deliverable.
The design space
Storage engines sit on the RUM conjecture: you may optimise any two of Read overhead, Update overhead, and Memory overhead, at the cost of the third. The families below are points on that surface, not competitors.
| Structure | Read amp | Write amp | Space amp | Used by |
|---|---|---|---|---|
| B+tree | \(O(\log_B N)\), ~1 IO with cached internals | high — page write per update, plus WAL | ~1.5× (fill factor) | PostgreSQL, InnoDB, SQLite |
| LSM, levelled | \(O(L)\) runs, cut by Bloom filters | \(O(T \cdot L)\) | ~1.1× | RocksDB default, LevelDB |
| LSM, tiered | \(O(T \cdot L)\) runs | \(O(L)\) | up to \(T\)× | Cassandra, ScyllaDB |
| Bε-tree | \(O(\log_B N)\) | \(O(\log_B N / \varepsilon B^{1-\varepsilon})\) | ~1.5× | TokuDB, BetrFS |
| Copy-on-write B-tree | \(O(\log_B N)\) | very high | high | LMDB, BoltDB |
With fanout \(T\) and \(L = \log_T(N/M)\) levels, the two LSM rows are the same structure with the compaction trigger moved, and they trade read for write amplification along exactly the RUM axis. Levelled merges eagerly so each level holds one sorted run — few runs to probe, but every key is rewritten \(O(T)\) times per level. Tiered waits until \(T\) runs accumulate — one rewrite per level, but \(T\)× more runs to search. Choosing between them is choosing your read/write ratio, and RocksDB's popularity owes much to letting you set it per level.
Latency: the numbers that force the design
| Operation | Cost | Source |
|---|---|---|
| L1 hit | 0.91 ns | measured, numbers.md |
| DRAM random | 121.10 ns | measured |
| Syscall | 127.59 ns | measured |
fsync | 90--105 µs | measured |
| NVMe random read (4 KiB) | 20--100 µs | typical |
| SATA SSD random read | 100--200 µs | typical |
| HDD seek + rotate | ~10 ms | typical |
| Sequential read, NVMe | 2--7 GB/s | typical |
Two ratios drive everything:
fsync≈ 100 µs ≈ 800 syscalls ≈ 10⁵ DRAM accesses. Durability is the single most expensive thing the engine does, so the WAL batches many logical writes into one flush, and group commit exists to amortise it across concurrent writers.- Random/sequential ≈ 100× on HDD, ≈ 3--10× on NVMe. The LSM was designed when that ratio was 100×, which is why it converts random writes into sequential ones. On NVMe the argument is weaker — and that, not fashion, is why B-trees remain competitive on modern hardware, and why Bε-trees (which batch updates in internal nodes) became interesting again.
Where the read path actually spends time
A get on a key that is absent — the common case in a write-heavy workload —
must prove absence in every run. Without filters that is \(R\) block reads; the
blocks above measure 30.0 → 0.239 at 10 bits/key. The Bloom filter converts an
\(O(R)\) IO cost into an \(O(R)\) memory cost plus \(\varepsilon R\) IOs, and
with 10 bits/key \(\varepsilon \approx 0.0082\) (proofs.md P3).
The filter itself is a random-access structure: \(k\) probes into an \(m\)-bit array, each a likely cache miss. At 121 ns per miss and \(k = 7\), a filter probe is ~850 ns if the filter is not cached — which is why blocked Bloom filters (all \(k\) probes inside one 512-bit cache line) are standard in RocksDB. They trade a slightly worse false-positive rate for one cache miss instead of seven. This is the same locality argument as P02's vector layout.
Advanced algorithms and data structures
- Ribbon filters (Dillinger & Walzer, 2021) reach the space-efficiency limit more closely than Bloom, at ~30% less space for the same FPR, using a solvable linear system over GF(2) instead of hashing.
- Monkey (Dayan, Athanassoulis & Idreos, SIGMOD 2017) shows the standard practice of giving every level the same bits-per-key is provably suboptimal: since deeper levels hold more keys but are probed as often, allocating fewer bits to them minimises total false positives under a fixed memory budget. Same memory, strictly better reads.
- Dostoevsky and lazy levelling hybridise: tiered at the smaller levels (cheap writes) and levelled at the largest (cheap reads), which dominates both pure strategies for most workloads.
- Fractional cascading and sparse indexes are the same idea as the block above: keep one key per block in memory so a lookup is a binary search in RAM plus exactly one IO.
- SkipList vs B-tree memtable. RocksDB's default memtable is a skip list because it supports lock-free concurrent inserts; the tradeoff is worse cache locality than a B-tree, which matters once the memtable exceeds L2.
- Log-structured everything. The same transformation appears in F2FS and
in SSD firmware itself: the FTL is a log-structured store with garbage
collection, so an LSM on an SSD is a log on a log — which is where write
amplification stacking comes from, and why
discard/TRIM hints matter.
Hardware: what the medium dictates
- HDD: seek 10 ms dominates everything. Design for sequential-only: LSM, large blocks, no random reads in the write path.
- SATA/NVMe SSD: random reads are cheap and parallel. Queue depth is the
new lever — one thread issuing 4 KiB reads gets ~10k IOPS; 64 in flight gets
500k+. Engines therefore need asynchronous IO (
io_uring, P12) to reach device throughput at all. - Persistent memory (Optane, now discontinued but architecturally instructive): ~300 ns loads, byte-addressable, which collapses the distinction between the WAL and the memtable and produced a research line on failure-atomic data structures.
- Zoned namespaces (ZNS): the device exposes append-only zones and refuses random writes, pushing the FTL's job into the engine. An LSM maps onto ZNS almost exactly — one zone per SSTable — and eliminates the double garbage collection.
How this connects to the rest of the track
- P03 is this engine with vectors as values, and reuses segments, tombstones and compaction verbatim.
- P07's state backend is literally RocksDB in Flink — an LSM holding streaming state, checkpointed by the mechanism in that project's block 6.
- P12's page cache is the layer beneath this one, and the page-cache trap in numbers.md §14 is the measurement error that layer causes.
- P02's PQ codes and this project's Bloom filters are the same pattern: an approximate test in fast memory guarding an exact one in slow.
- P05 replicates this engine's log; the WAL and the Raft log are the same abstraction with different durability quorums.
Failure modes at scale
- Write stalls. Ingest outruns compaction, L0 files accumulate, the engine throttles or halts writers. The visible symptom is a latency cliff, not a gradual slope, and the cause is a rate mismatch that was invisible at low load.
- Space amplification during compaction: a levelled compaction needs room for both input and output. A disk that is 80% full can fail to compact, which makes it fuller.
fsynclying. Some devices and filesystems acknowledge before the data is durable; the crash test in the assembly is only as trustworthy as the layer underneath. Real validation requires power-cut testing or a fault-injecting filesystem.- Tombstone accumulation: deletes make reads slower until compaction, and a full-table delete can make a scan pathologically slow — the Cassandra operational classic.
Primary sources
- O'Neil et al., The Log-Structured Merge-Tree (Acta Informatica, 1996).
- Athanassoulis et al., Designing Access Methods: The RUM Conjecture (EDBT 2016).
- Dayan, Athanassoulis & Idreos, Monkey: Optimal Navigable Key-Value Store (SIGMOD 2017).
- Dayan & Idreos, Dostoevsky (SIGMOD 2018).
- Kirsch & Mitzenmacher, Less Hashing, Same Performance (2006) — the two-hash trick used in the blocks above.
- Dillinger & Walzer, Ribbon Filter (2021).
- Rosenblum & Ousterhout, The Design and Implementation of a Log-Structured File System (1991) — where all of this starts.
Running it
python3 handson/h04_lsm.py # every block, then the assembly
python3 handson/h04_lsm.py --block 3 # just block 3 and its prerequisites
python3 handson/h04_lsm.py --quiet # the assembly only
What to do with this
The measurement to add is write amplification under a sustained random-write load, which is the number that decides whether an LSM is the right structure at all. Then implement levelled compaction alongside the tiered scheme here and watch the RUM trade move: levelling cuts read amplification and raises write amplification, and the crossover depends on your read/write ratio rather than on anyone's benchmark.
Milestones, experiments, readings and exit criteria for this project: P04 — Log-Structured Storage Engine.
P05 — Distributed Key-Value Store
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P05 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Large · 143 hours · Weeks 55–67 · Stage 3 · Go
The hardest project in the journey, and the one most likely to overrun. The fault injector is scheduled first, before any distributed feature, specifically to bound it.
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Keep a key-value store available and correct when machines die, disks fail, networks partition, and messages arrive late, twice, or out of order |
| 2. Constraints | Asynchronous network — no bound on message delay. Nodes fail by crashing (no Byzantine). Clocks are not trustworthy |
| 3. Naive design | Yours. People invent: a primary that forwards to backups; consistent hashing with async replication; "just use a lock service" |
| 4. Predicted failure | Every naive design has a split-brain. Find yours on paper before you code it, and write down the exact interleaving |
| 5. Minimal implementation | Single-shard, three replicas, leader-based log replication |
| 6. Correctness | A linearizability checker over recorded histories |
| 7. Instrumentation | Per-RPC latency, replication lag, election counts, term numbers, log length |
| 8. Baseline | The single-node engine from P04. Distribution must justify its cost against it |
| 9. Bottleneck | Is throughput bound by fsync, by the network round trip, or by the leader's single-threaded apply loop? |
| 10. Hypothesis | Failure-detector timeout vs availability: there is an optimum, and both sides of it hurt |
| 11. Modification | Adaptive (phi-accrual) failure detection |
| 12. Experiment | Timeout sweep under injected delay distributions |
| 13. Failure analysis | Every linearizability violation gets a full interleaving diagram |
| 14. Report | Including at least one real bug the checker found in your own code |
Why This Project Matters
Almost everyone learns distributed systems as a vocabulary: quorum, consensus, CAP, eventual consistency. The vocabulary is not the skill. The skill is holding in your head the fact that you cannot distinguish a slow node from a dead one, and reasoning correctly about a system where every decision must be made without that information.
That single asymmetry generates almost everything else: why consensus needs a majority, why leases need clocks you distrust, why exactly-once delivery is impossible and exactly-once processing is not, why a failure detector's timeout is a liveness/availability trade with no correct answer.
You operate distributed systems today. This project is where you stop operating them and start being able to say what they are guaranteeing.
And the meta-lesson, which is why this is the hardest project: distributed bugs do not reproduce. The only defence is a deterministic harness that can replay a failing schedule. Building that harness before the system is the single most transferable habit in this track — it is the same reason P03 built the crash tester at milestone 2.
Prerequisites
- P04 complete — the storage engine is the per-node state
- Go: goroutines, channels,
select,context, the race detector - Comfort with the idea that a test that passes 1,000 times can still be wrong
Duration and Size
Large, 143 hours, 13 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Fault injector, static partitioning by consistent hashing, single-shard leader-based replication with a replicated log, crash recovery, a linearizability checker. | 70 |
| Standard | + simplified Raft (elections, log replication, safety, persistence), membership changes, phi-accrual failure detection, idempotent retries, rebalancing, snapshots, multi-shard routing. | 143 |
| Extension | Read leases for local reads; or a Jepsen-style external test suite; or cross-shard transactions with two-phase commit. | +40–70 |
Central Technical Questions
- Why a majority? Not "because the paper says so" — derive why any two quorums must intersect and what breaks if they do not.
- What does a failure detector actually detect? Nothing about the remote node. It reports a property of your observations. Say precisely what.
- What is linearizability, and how would you check a history for it?
- Where does your system lose linearizability first? Every implementation has a weakest point. Name yours before the checker finds it.
- What happens during a leader change to in-flight writes? Trace one specific client's request across an election.
- Why is exactly-once delivery impossible and exactly-once effect achievable?
- What does the system do when the network is slow but nothing has failed? This is the common case in production and the one designs handle worst.
Architecture
Write your naive design first. Include the split-brain you predict.
client ──► router (any node) ──► shard owner
│
shard = hash(key) mod ring │ Raft group per shard, N=3
▼
┌──────── leader ────────┐
│ append to local log │
│ fsync │
│ AppendEntries ──► followers (parallel)
│ wait for majority ack │
│ commitIndex++ │
│ apply to P04 engine │
│ reply to client │
└────────────────────────┘
── fault injector sits BETWEEN every pair of nodes ──
drop · delay · duplicate · reorder · partition · pause process · corrupt disk
Why a majority — derived
A quorum system needs any two quorums to intersect, so that a decision made by one is visible to the next. With \(N\) replicas and quorum size \(Q\), two quorums of size \(Q\) intersect iff \(2Q > N\), i.e. \(Q \ge \lfloor N/2 \rfloor + 1\).
If \(2Q \le N\), two disjoint quorums can each make a decision without ever seeing the other's — that is split-brain, and it is not a bug in an implementation, it is an arithmetic consequence of the quorum size. Every real split-brain is this inequality being violated somewhere, often by an operator changing the replica count.
What majority replication buys, computed for \(N = 3\), independent node failure probability \(p\):
| p (per node) | single node available | 2-of-3 quorum available |
|---|---|---|
| 0.01 | 99.00% | 99.9702% |
| 0.05 | 95.00% | 99.2750% |
| 0.10 | 90.00% | 97.2000% |
At \(p=0.01\), majority replication turns 3.65 days of downtime a year into 2.6 hours. Note the assumption doing all the work: independence. Correlated failures — same rack, same power domain, same bad deploy, same poisoned request — collapse this entirely, and correlated failure is the normal case. Say that in your report.
The core asymmetry
You send a heartbeat. No reply arrives within \(T\). Three worlds are consistent with that observation:
- The node crashed.
- The node is alive and the network dropped the messages.
- The node is alive and just slow — GC pause, disk stall, CPU starvation.
You cannot distinguish them, ever, in an asynchronous network. This is the content of the FLP impossibility result: no deterministic consensus algorithm can guarantee termination in an asynchronous system with even one crash failure. Practical systems escape by adding a timing assumption — a failure detector that is allowed to be wrong.
So the timeout \(T\) is a trade with no correct value:
- \(T\) too small: false positives. Spurious elections, unnecessary failovers, and under load a feedback loop where slowness causes elections which cause more slowness.
- \(T\) too large: real failures go undetected. Availability gap equal to \(T\) on every genuine crash.
Phi-accrual detection (Hayashibara et al. 2004) replaces the binary with a suspicion level derived from the observed inter-arrival distribution, letting the application choose its threshold. Implementing it and measuring the false-positive/detection-time frontier is E4, and it is the best experiment in this project.
Build the fault injector first
Milestones 1–2, before any distributed feature. It must support:
| Fault | Why it is non-negotiable |
|---|---|
| Message drop (probabilistic and targeted) | The baseline network failure |
| Message delay (fixed, and drawn from a heavy-tailed distribution) | Slow is more common and more dangerous than dead |
| Message duplication | Retries make this certain, not hypothetical |
| Message reorder | TCP gives per-connection ordering only; multi-connection reorder is real |
| Network partition (arbitrary subsets, including asymmetric) | Asymmetric partitions — A hears B but B does not hear A — break more designs than symmetric ones |
| Process pause (SIGSTOP) | Simulates a GC pause; a paused leader that resumes still thinks it is leader |
| Process crash and restart | Tests persistence |
| Disk corruption and truncation | Reuses P03/P04's tooling |
| Clock skew and jumps | Ensures no correctness depends on wall time |
And it must be deterministic: seeded, with a recorded schedule that can be replayed exactly. A distributed bug you cannot replay is a distributed bug you cannot fix.
Showcase — Do This Before You Start
W3 · walkthroughs/w3_raft.py · ~60 minutes
A working miniature of this project: split-brain caused by a plurality rule and prevented by a majority one, plus the inequality brute-forced over every (N, Q).
cd walkthroughs && python3 w3_raft.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo; in-process simulated network with deterministic seeded scheduling | 10 | Same seed → identical message order, provably |
| 2 | Fault injector: all nine faults above, replayable | 12 | A recorded failing schedule replays identically |
| 3 | Node skeleton: RPC layer, context deadlines, per-RPC metrics | 8 | Latency histogram per RPC type |
| 4 | Single-shard, static 3-replica leader-based log replication | 12 | Writes replicate; the leader is hard-coded |
| 5 | Linearizability checker over recorded histories (Wing–Gong style with pruning) | 12 | Detects a violation you injected deliberately |
| 6 | Raft: leader election, terms, votes, election timeouts | 14 | Exactly one leader per term under partition |
| 7 | Raft: log replication, matchIndex/nextIndex, commit rule | 14 | Log Matching and Leader Completeness asserted continuously |
| 8 | Raft: persistence (term, vote, log) + crash recovery | 8 | Survives crash-restart of any subset |
| 9 | Client sessions: idempotent retries via client id + sequence number | 8 | Duplicate delivery causes no duplicate effect |
| 10 | Phi-accrual failure detection | 8 | Detection-time/false-positive frontier measured |
| 11 | Membership changes (single-server at a time) | 10 | Add/remove a node with no loss of availability or safety |
| 12 | Snapshots + log compaction + install-snapshot to a lagging follower | 10 | A follower behind by a compacted prefix catches up |
| 13 | Consistent hashing, multi-shard routing, rebalancing | 12 | Ownership moves without dropping writes |
| 14 | Experiments + report | 5 | All rows filled |
Concepts To Study
- The asynchronous model and why it is the right default
- FLP impossibility: no deterministic async consensus with one crash failure
- CAP, stated precisely: during a partition, choose between linearizability and availability. Note that CAP says nothing about the non-partitioned case, which is where PACELC comes in
- Linearizability vs sequential consistency vs serializability — three different properties routinely conflated
- Quorum intersection and the derivation above
- Raft: terms, elections, log matching, leader completeness, state-machine safety
- Why Raft's commit rule excludes prior-term entries — the subtlest safety point in the paper (§5.4.2). Understand it or your implementation will have a rare, real bug
- Failure detectors: completeness and accuracy; phi-accrual
- Idempotency: client sessions, dedup tables, and their unbounded growth problem
- Leases and clocks: why a lease needs a bounded clock drift assumption
- Consistent hashing: virtual nodes and why naive consistent hashing has bad balance
- Split-brain and fencing tokens
- Read paths: read-from-leader, ReadIndex, lease reads — three different correctness/latency points
Primary-Source Readings
Budget: 20 hours, the largest in the journey.
| Reading | Why | Hours |
|---|---|---|
| Ongaro, D., Ousterhout, J. In Search of an Understandable Consensus Algorithm (Extended). USENIX ATC 2014 | Read the extended version. §5.4.2 twice | 5 |
| Lamport, L. Time, Clocks, and the Ordering of Events in a Distributed System. CACM 21(7), 1978 | The foundation. Happens-before | 2 |
| Fischer, Lynch, Paterson. Impossibility of Distributed Consensus with One Faulty Process. JACM 32(2), 1985 | Read the theorem and the intuition; the proof is optional | 2 |
| Herlihy, M., Wing, J. Linearizability: A Correctness Condition for Concurrent Objects. ACM TOPLAS 12(3), 1990 | The definition your checker implements | 2 |
| Gilbert, S., Lynch, N. Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services. SIGACT News 33(2), 2002 | CAP, stated as a theorem rather than a slogan | 1.5 |
| DeCandia, G. et al. Dynamo: Amazon's Highly Available Key-value Store. SOSP 2007 | The AP design point; vector clocks, hinted handoff, read repair | 2.5 |
| Corbett, J. C. et al. Spanner: Google's Globally-Distributed Database. OSDI 2012 | What buying a bounded clock lets you do | 2 |
| Hayashibara, N. et al. The φ Accrual Failure Detector. SRDS 2004 | E4 | 1.5 |
| Kingsbury, K. Jepsen analyses (pick three real systems) | What violations look like in shipped software | 1.5 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Replication factor | N ∈ {1,3,5,7} | Write latency vs availability; predict the latency slope |
| E2 | Write latency decomposition | fsync / network RTT / apply | Which dominates? Predict before measuring |
| E3 | Failure-detector timeout | T ∈ {50 ms … 5 s} under injected delay | The optimum, and the shape either side |
| E4 | Fixed vs phi-accrual detection | under heavy-tailed delay | Predict the false-positive reduction at equal detection time |
| E5 | Leader failure | kill leader under load | Availability gap; predict it ≈ election timeout + one RTT |
| E6 | Network partition | symmetric and asymmetric | Minority must reject writes. Assert it, do not hope |
| E7 | Replication lag | vs write rate, vs follower slowness | Where does a slow follower start hurting the leader? |
| E8 | Message loss rate | 0–30% | Throughput degradation curve; predict its shape |
| E9 | Duplicate + reorder | 10% each | Zero effect on state, if sessions are right |
| E10 | Rebalancing | move 1 shard under load | Availability and latency during the move |
| E11 | Snapshot + catch-up | follower behind by 10⁴–10⁷ entries | Catch-up time; where does install-snapshot beat log replay? |
| E12 | Read strategies | leader-read vs ReadIndex vs lease | Latency vs staleness — a three-point frontier |
| E13 | Clock skew | ±5 s | No effect. If there is one, you have a bug |
| E14 | Throughput vs shard count | 1–16 shards | Where does routing overhead cancel the parallelism? |
E6 with asymmetric partitions is the one that finds bugs. Symmetric partitions are easy to reason about. A node that can send but not receive causes a leader that believes it is still leading, and it breaks designs that symmetric tests pass.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Write throughput and p50/p95/p99 | Under no-fault and under each injected fault |
| Read throughput and staleness | By read strategy |
| Availability gap on leader failure | Seconds with no successful writes |
| Time to detect a failure | Distribution, not mean |
| False-positive failure detections | Per hour, under load |
| Replication lag | p50/p99, in entries and in seconds |
| Elections per hour | A stable cluster should have ~0. Nonzero is a signal |
| Rebalance time and impact | Duration and p99 inflation during |
| Linearizability violations | Must be zero. Any nonzero is a stop-work bug |
| Recovery time | Crash-restart to serving |
| Bytes on the wire per client write | Amplification, distributed edition |
Correctness Tests
- Linearizability checking on every test run. Record a history of invoke/return/value events, check offline. Continuously, not once.
- Election safety: at most one leader per term. Assert globally in the simulator.
- Log Matching: if two logs contain an entry with the same index and term, all preceding entries are identical. Assert after every AppendEntries.
- Leader Completeness: a committed entry is present in the log of every future leader. This is the property §5.4.2 protects.
- State Machine Safety: no two nodes apply different commands at the same index.
- Durability: an acknowledged write survives crash-restart of a majority.
- Idempotency: replaying a client request produces one effect.
- Membership safety: no configuration change creates two disjoint majorities.
- Determinism: same seed → identical execution. Without this, nothing else is testable.
- Model-based test: whole cluster vs a single
map, under faults.
Failure Tests
Every one of these runs in CI, seeded, replayable.
| Injection | Required behaviour |
|---|---|
| Kill leader mid-write | No lost acknowledged write; new leader elected |
| Kill a majority | Cluster unavailable for writes; no data loss on recovery |
| Symmetric partition | Majority side serves; minority rejects |
| Asymmetric partition | No split-brain; the isolated leader steps down |
| SIGSTOP the leader for 2× the election timeout, then resume | Old leader must discover the new term and step down |
| Disk corruption on one follower | Detected; that follower recovers via snapshot |
| Truncate a follower's log | Repaired by the leader |
| Duplicate every message | No duplicate effects |
| Reorder all messages | Correct, possibly slower |
| Delay 10% of messages by 10 s | No spurious failover with phi-accrual |
| Clock jump +1 hour on one node | No effect |
| Restart every node in sequence | Cluster stays available throughout |
| Slow disk on the leader (10× fsync latency) | Throughput degrades; correctness does not |
Expected Difficulties
- This project will take longer than you plan. It is the one with the highest overrun risk in the journey. Mitigations, in order: the deterministic simulator (milestone 1) so bugs replay; the linearizability checker (milestone 5) so bugs are found rather than shipped; a hard scope cut at week 11 that drops milestones 11–13 and ships single-shard.
- Raft §5.4.2 will bite you. A leader may not commit an entry from a previous term by counting replicas — it must commit an entry from its own term first. The bug this prevents appears in perhaps one in 10⁵ runs and destroys linearizability. Write the test that constructs the interleaving deliberately.
- Real-network testing wastes weeks. Use the in-process simulator for everything. Run on real sockets once, at the end, to confirm nothing depended on the simulation.
- The linearizability checker is exponential in the worst case. Wing–Gong with pruning is fine for histories of a few thousand ops; keep them short.
- Debugging output is your main tool and will drown you. Structured logs with node id, term, index, and a monotonic sequence number; a script that renders a history as a space-time diagram. Build it in milestone 3.
- Go's race detector will find real bugs. Run every test under
-racefrom day one.
Scope Boundaries
In scope: crash-stop failures, asynchronous network, single-key linearizable operations, leader-based replication, static then dynamic membership, snapshots, rebalancing.
Out of scope: Byzantine faults; multi-key transactions (extension only); geo-replication and clock-bounded designs à la Spanner (read the paper, do not build it); a production RPC framework — hand-rolled is better here; an admin UI; TLS and authentication; performance work beyond what the experiments require.
Deliverables
distkv/— Go module, in-process simulator and a real-socket modefaultinjector/— the standalone, reusable artifact. This is the most portfolio-valuable thing you will build in Stage 3linchecker/— the linearizability checker, also standaloneREPORT.mdincluding at least one real bug the checker found in your own code, with the interleaving diagram- Notebook entries for E4, E6, E12
- A space-time diagram renderer for recorded histories
Exit Criteria
- Linearizability checker reports zero violations across ≥1,000 seeded fault runs
- All thirteen failure tests pass, including the asymmetric partition and the SIGSTOP-resume
- Leader election works under partition with exactly one leader per term, asserted
- E5 measured: availability gap on leader failure, compared against your prediction
- E4 measured: phi-accrual vs fixed timeout, frontier plotted
- Snapshots work: a follower behind by a compacted prefix catches up
- At least one real bug found by the checker, documented with its interleaving
- Same-seed determinism verified — every failing run replays
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Read leases: local reads at the cost of a bounded-clock assumption. Measure the latency win and state the assumption you bought it with.
- Jepsen-style external testing against your own system.
- Two-phase commit across shards — and a measurement of how much availability it costs, which is the honest reason distributed transactions are avoided.
- Compare with an AP design: implement Dynamo-style quorum replication with vector clocks alongside, and measure the availability difference during partition. This is CAP as an experiment rather than a slogan, and it is the strongest extension here.
Connections
Backward: P04 is the per-node engine; its WAL and snapshots are reused. P03's crash harness generalises into the fault injector.
Forward:
- → P06 (MapReduce): the fault injector, membership, and failure detector are reused directly. The coordinator's fault tolerance is a simpler version of this problem
- → P07 (Streaming): the replicated log is the event log; checkpointing is snapshotting
- → P12 (Kernel): scheduling, timers, and the cost of a context switch are the micro-scale version of the same latency questions
- → P15: the storage and ingestion substrate
References
- Ongaro, D., Ousterhout, J. In Search of an Understandable Consensus Algorithm. USENIX ATC 2014 (extended version, Stanford).
- Lamport, L. Time, Clocks, and the Ordering of Events in a Distributed System. CACM 21(7), 1978.
- Lamport, L. The Part-Time Parliament. ACM TOCS 16(2), 1998. And Paxos Made Simple, 2001.
- Fischer, M. J., Lynch, N. A., Paterson, M. S. Impossibility of Distributed Consensus with One Faulty Process. JACM 32(2), 1985.
- Herlihy, M. P., Wing, J. M. Linearizability: A Correctness Condition for Concurrent Objects. ACM TOPLAS 12(3), 1990.
- Gilbert, S., Lynch, N. Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services. SIGACT News 33(2), 2002.
- Abadi, D. Consistency Tradeoffs in Modern Distributed Database System Design. IEEE Computer 45(2), 2012. PACELC.
- DeCandia, G. et al. Dynamo: Amazon's Highly Available Key-value Store. SOSP 2007.
- Corbett, J. C. et al. Spanner: Google's Globally-Distributed Database. OSDI 2012.
- Hayashibara, N., Défago, X., Yared, R., Katayama, T. The φ Accrual Failure Detector. SRDS 2004.
- Chandra, T. D., Toueg, S. Unreliable Failure Detectors for Reliable Distributed Systems. JACM 43(2), 1996.
- Karger, D. et al. Consistent Hashing and Random Trees. STOC 1997.
- Kingsbury, K. Jepsen. jepsen.io — the analyses, not just the tool.
- Alvaro, P., Rosen, J., Hellerstein, J. M. Lineage-driven Fault Injection. SIGMOD 2015. A smarter way to choose which faults to inject.
P05 hands-on — Distributed key-value store, block by block
Leader election and log replication under loss, duplication and partition.
Source:
handson/h05_distkv.py--- run it withpython3 handson/h05_distkv.py
Full project spec: P05 — Distributed Key-Value Store
Consensus is hard to learn from the paper because the paper describes the protocol that works, not the failures that force each rule to exist. This file inverts that. It builds a message network that can drop, duplicate and delay, then adds the Raft rules one at a time and shows what breaks when each is missing.
The election-safety argument is the clearest example. One vote per term plus a majority quorum gives at most one leader, and the file demonstrates it under a partition rather than asserting it --- including the case where a single round elects nobody at all, which is why real Raft retries on a randomised timeout instead of assuming success.
Contents
- Block 1 — A deterministic network
- Block 2 — The fault injector
- Block 3 — Nodes, terms, votes
- Block 4 — An election
- Block 5 — Log replication + commit
- Block 6 — Linearizability checking
- Block 7 — Sec 5.4.2, the rule that is easy to skip
- The assembly
- The design space
- Latency: distance is the budget
- Advanced algorithms and data structures
- Hardware and network reality
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — A deterministic network
Teaches: if a bug cannot be replayed it cannot be fixed
The problem. Distributed bugs are timing bugs, and timing bugs that cannot be replayed cannot be fixed. Before writing a single line of consensus logic, build a network whose every delivery decision comes from a seeded RNG — so a failing run is a reproducible artefact rather than an anecdote.
@block(1, "A deterministic network", "if a bug cannot be replayed it cannot be fixed")
def b1(s, show):
class Net:
def __init__(self, seed=0):
self.rng = random.Random(seed); self.q = []; self.t = 0.0
self.seq = itertools.count(); self.partitions = []; self.log = []
self.drop_p = 0.0; self.dup_p = 0.0; self.delay = (1.0, 3.0)
def reachable(self, a, b):
for grp in self.partitions:
if (a in grp) != (b in grp): return False
return True
def send(self, src, dst, msg):
if not self.reachable(src, dst): self.log.append(("blocked", src, dst)); return
if self.rng.random() < self.drop_p: self.log.append(("dropped", src, dst)); return
n = 2 if self.rng.random() < self.dup_p else 1 # duplicate delivery
for _ in range(n):
d = self.rng.uniform(*self.delay)
heapq.heappush(self.q, (self.t + d, next(self.seq), src, dst, msg))
def run(self, nodes, until):
while self.q and self.q[0][0] <= until:
t, _, src, dst, msg = heapq.heappop(self.q)
self.t = t; nodes[dst].recv(src, msg, self)
self.t = until
if show:
def trace(seed):
net = Net(seed); net.delay = (1, 9)
for i in range(6): net.send(0, 1, f"m{i}")
return [m for _, _, _, _, m in sorted(net.q)]
print(f" seed 0 -> {trace(0)}")
print(f" seed 0 -> {trace(0)} (identical)")
print(f" seed 1 -> {trace(1)} (different schedule)")
print(f" replayable: {trace(0) == trace(0)}")
print(" a seeded event queue IS the whole trick. Build it before any feature.")
return {"Net": Net}
Reading the implementation
The network is a priority queue of (deliver_time, message), drained in time order. That single choice buys three properties that real distributed testing spends enormous effort to approximate:
- Determinism. Same seed, same interleaving, every time. A test that fails at seed 4172 fails at seed 4172 tomorrow, on another machine, under a debugger.
- Time travel is free. Virtual time advances by dequeuing, so a 30-second election timeout costs zero wall-clock. You can run ten thousand scenarios in the time one real cluster takes to boot.
- Total control of the interleaving. Every ordering the real network could produce is reachable by choosing delays, and none that it could not.
This is the same design as FoundationDB's deterministic simulator and as TigerBeetle's VOPR, and it is the single highest-leverage decision in the whole project. FoundationDB's team has said they found more bugs in simulation than in production, and the reason is exactly this: the search is repeatable, so a rare interleaving found once is available forever as a regression test.
What the numbers say
Output:
seed 0 -> ['m1', 'm0', 'm2', 'm3', 'm4', 'm5']
seed 0 -> ['m1', 'm0', 'm2', 'm3', 'm4', 'm5'] (identical)
seed 1 -> ['m2', 'm3', 'm4', 'm1', 'm0', 'm5'] (different schedule)
replayable: True
a seeded event queue IS the whole trick. Build it before any feature.
Beyond the toy
The discipline this imposes on the rest of the code is the real payoff:
no wall-clock time, no random without the seeded generator, no threads, no
real I/O anywhere in the protocol logic. Every source of nondeterminism must be
injected. That is a constraint on the design — and it is why systems built this
way separate "logic" from "I/O" so rigorously (the sans-I/O pattern), which turns
out to be good architecture independent of testing.
The natural extensions, in order of value: seed sweeping (run 10⁵ seeds in CI, keep the failures), shrinking (on failure, minimise the fault schedule to the smallest reproduction), and coverage-guided scheduling (bias the RNG toward interleavings that reach unexplored states — the technique behind Jepsen-adjacent tools and modern deterministic simulators).
Block 2 — The fault injector
Teaches: nine faults, and the asymmetric one finds the most bugs
The problem. A protocol is only as correct as the failures it survives, and the failure people test is the one that matters least. Crashes are easy; asymmetric partitions and delayed duplicates are where implementations break.
@block(2, "The fault injector", "nine faults, and the asymmetric one finds the most bugs")
def b2(s, show):
if show:
faults = [("drop", "the baseline network failure"),
("delay (heavy tail)", "slow is commoner and nastier than dead"),
("duplicate", "retries make this certain, not hypothetical"),
("reorder", "TCP orders per-connection only"),
("partition (symmetric)", "easy to reason about, easy to pass"),
("partition (ASYMMETRIC)", "A hears B, B does not hear A -- finds real bugs"),
("pause (SIGSTOP)", "a GC pause; the leader wakes still believing it leads"),
("crash + restart", "tests persistence"),
("clock jump", "nothing may depend on wall time")]
for name, why in faults: print(f" {name:<24} {why}")
print(" every one seeded and replayable, or they are not tests.")
return {}
Reading the implementation
The nine faults are not a list of ways to be mean — each one invalidates a specific assumption that implementations make without noticing:
| Fault | Assumption it breaks |
|---|---|
| Drop | "if I sent it, it arrived" |
| Duplicate | "each message is processed once" |
| Reorder | "messages arrive in send order" |
| Delay | "a slow reply means the peer is dead" |
| Asymmetric partition | "reachability is symmetric" |
| Partial partition | "the cluster splits into two groups" |
| Crash | "state in memory survives" |
| Restart with stale state | "a restarted node knows what it knew" |
| Clock skew | "timeouts mean the same thing everywhere" |
The asymmetric case earns its billing. If A can send to B but B cannot send to A, then A sees B as alive (its heartbeats arrive) while B sees A as dead. Both may believe they are leader for different reasons, and the failure detector — which is the only thing that turns "no response" into "failed" — is systematically wrong for one of them. Every practical consensus deployment has a story about this, and it is why membership changes and pre-vote exist.
Duplicates are the sleeper. A retried RequestVote that arrives after the
term has advanced, or an AppendEntries delivered twice, must be idempotent.
The protocol achieves that with term numbers and log indices rather than with
deduplication — which is why every RPC in Raft carries a term and every append
carries prevLogIndex/prevLogTerm.
What the numbers say
Output:
drop the baseline network failure
delay (heavy tail) slow is commoner and nastier than dead
duplicate retries make this certain, not hypothetical
reorder TCP orders per-connection only
partition (symmetric) easy to reason about, easy to pass
partition (ASYMMETRIC) A hears B, B does not hear A -- finds real bugs
pause (SIGSTOP) a GC pause; the leader wakes still believing it leads
crash + restart tests persistence
clock jump nothing may depend on wall time
every one seeded and replayable, or they are not tests.
Beyond the toy
- Grey failure is worse than any fault here: a node that is slow rather than dead stays in the quorum and drags commit latency to its own speed. Real systems add latency-based probation, and it is the reason a quorum of 5 can be faster than a quorum of 3 — it can exclude the straggler (the concept map).
- Byzantine faults — a node that lies rather than fails — are out of scope for Raft and require a different protocol class (PBFT, HotStuff) with \(3f+1\) nodes instead of \(2f+1\). Worth knowing where the boundary is: Raft assumes crash-stop with fair-loss links, and disk corruption silently violates that assumption, which is why checksums (P04) are part of the consensus story too.
- Fault injection in production — chaos engineering — is the same idea with worse reproducibility and better realism. Both are needed; the simulator finds logic bugs, chaos finds configuration and operational ones.
Block 3 — Nodes, terms, votes
Teaches: one vote per term is the entire safety argument for elections
The problem. Election safety — at most one leader per term — is the property everything else rests on, and it comes from two rules multiplied together. This block builds them, and the argument is short enough to hold in your head.
@block(3, "Nodes, terms, votes", "one vote per term is the entire safety argument for elections")
def b3(s, show):
class Node:
def __init__(self, nid, peers):
self.id = nid; self.peers = peers; self.term = 0
self.voted = {}; self.role = "follower"; self.votes = set()
self.log = []; self.commit = 0; self.store = {}
def recv(self, src, msg, net):
kind = msg[0]
if kind == "vote_req":
_, term, cand, llen = msg
if term > self.term:
self.term = term; self.role = "follower"; self.votes = set()
grant = (term == self.term and self.voted.get(term) is None
and llen >= len(self.log))
if grant: self.voted[term] = cand
net.send(self.id, cand, ("vote_rep", term, grant))
elif kind == "vote_rep":
_, term, grant = msg
if self.role == "candidate" and term == self.term and grant:
self.votes.add(src)
if len(self.votes) + 1 > (len(self.peers) + 1) / 2:
self.role = "leader"
elif kind == "append":
_, term, entries, leader_commit = msg
if term >= self.term:
self.term = term; self.role = "follower"
self.log = list(entries)
self.commit = leader_commit
for k, v in self.log[:self.commit]: self.store[k] = v
def stand(self, net):
self.term += 1; self.role = "candidate"
self.votes = set(); self.voted[self.term] = self.id
for p in self.peers:
net.send(self.id, p, ("vote_req", self.term, self.id, len(self.log)))
if show:
print(" vote rules, and each one is load-bearing:")
print(" - at most ONE vote per term (prevents two leaders)")
print(" - only for a log at least as up to date (prevents losing committed data)")
print(" - a higher term always demotes you to follower")
return {"Node": Node}
Reading the implementation
The two rules:
- A server grants at most one vote per term (
voted_foris persisted, not just remembered). - A candidate needs a majority to win.
Two majorities of the same \(N\)-member set must intersect in at least one server, and that server cannot have voted twice in the same term. Therefore at most one candidate can win a given term. That is the entire safety argument — pigeonhole, not probability (proofs.md P4).
The implementation detail that makes or breaks it: voted_for and
current_term must be durable before the vote is sent. A node that votes,
crashes, restarts having forgotten, and votes again in the same term has split
the quorum, and the invariant is gone. This is one of only three pieces of state
Raft requires on stable storage, and forgetting the fsync here is a real,
shipped bug class — it costs ~100 µs per vote (numbers.md) and
skipping it is exactly the kind of optimisation that passes every test until a
correlated power failure.
The term number is a logical clock: monotonic, advanced on every election attempt, and carried on every message. Any node seeing a higher term immediately steps down. That one rule is what makes stale leaders harmless — they discover their obsolescence at the first contact with a newer node.
What the numbers say
Output:
vote rules, and each one is load-bearing:
- at most ONE vote per term (prevents two leaders)
- only for a log at least as up to date (prevents losing committed data)
- a higher term always demotes you to follower
Beyond the toy
- Flexible Paxos shows the majority is a choice, not a requirement: what is needed is \(|Q_{\text{elect}}| + |Q_{\text{replicate}}| > N\). With \(N=5\) you can use an election quorum of 4 and a replication quorum of 2, making steady-state commits cheaper and elections dearer. Once you see that intersection is the invariant, the majority looks like the special case it is.
- Pre-vote prevents a partitioned node from disrupting a healthy cluster: on reconnection its term has advanced past everyone's, forcing an unnecessary election. Pre-vote asks "would you vote for me?" without incrementing the term.
- Witness / non-voting members let you get quorum-of-3 durability with two full replicas and one metadata-only participant, which is a real cost lever in cross-region deployments.
Block 4 — An election
Teaches: majority is not a convention; it is the pigeonhole principle
The problem. With the rules in place, run an election under partition and watch the pigeonhole argument do its work — including the case where nobody wins, which is the case most toy implementations do not handle.
@block(4, "An election", "majority is not a convention; it is the pigeonhole principle")
def b4(s, show):
Net, Node = s["Net"], s["Node"]
def elect(n=5, partitions=None, seed=0):
net = Net(seed); net.partitions = partitions or []
nodes = {i: Node(i, [j for j in range(n) if j != i]) for i in range(n)}
leaders = []
for cand in ({min(g) for g in partitions} if partitions else {0}):
nodes[cand].stand(net); net.run(nodes, net.t + 50)
for i, nd in nodes.items():
if nd.role == "leader": leaders.append((i, nd.term))
return leaders, nodes
if show:
l, _ = elect(5)
print(f" healthy 5-node cluster: leaders = {l}")
l, _ = elect(5, [[0, 1, 2], [3, 4]])
print(f" partitioned 3|2, majority rule: leaders = {l}")
print(" the 2-side cannot reach 3 votes, so it correctly refuses to lead.")
print(" Change the rule to a plurality and BOTH sides elect -- split-brain,")
print(" and it is arithmetic (2Q > N), not a race condition.")
return {"elect": elect}
Reading the implementation
The retry loop is the honest part:
for rounds in range(1, 6):
for cand in candidates: stand()
if leaders: break
A single round can lose enough vote messages that no candidate reaches a majority. That is not a bug in the protocol — it is exactly why real Raft uses a randomised election timeout in a range (typically 150--300 ms) rather than a fixed one. Without randomisation, candidates time out simultaneously, split the vote, and time out simultaneously again: a livelock that can persist indefinitely. Randomisation makes one candidate reliably start first and win.
Without this loop, the 10%-packet-loss scenario elects nobody and the table reports "0 leaders" — which is a property of the harness, not of Raft. That distinction matters: an experiment that measures its own scaffolding and reports it as a protocol result is worse than no experiment.
What the numbers say
Output:
healthy 5-node cluster: leaders = [(0, 1)]
partitioned 3|2, majority rule: leaders = [(0, 1)]
the 2-side cannot reach 3 votes, so it correctly refuses to lead.
Change the rule to a plurality and BOTH sides elect -- split-brain,
and it is arithmetic (2Q > N), not a race condition.
Exactly one leader in every scenario, including both partitions. Note the partition rows: with 3|2, only the majority side can elect; with 4|1 the same. The minority side cannot make progress, and that unavailability is the deliberate choice consensus makes — the C and P of CAP, giving up A.
Beyond the toy
- Timeout sizing is a real operational parameter: the election timeout must
be comfortably larger than the round-trip time plus the
fsynccost, or nodes will time out during normal operation. Cross-region deployments with 100 ms RTT need timeouts in seconds, which directly sets the failover time users see. - The unavailability window after a leader crash is
election timeout + election round trip, typically 0.5--5 s. That number is a product-visible SLO, and it is why systems that cannot tolerate it use leases with fast handoff or a hot standby. - Split-brain is not possible here but is possible with leases. If a leader serves reads under a lease and its clock runs slow, it can serve stale reads after being deposed. Safety then depends on a clock assumption, which is a different and weaker kind of guarantee.
Block 5 — Log replication + commit
Teaches: committed means a majority has it, not that the leader wrote it
The problem. Election safety says who may append. Log replication says what "committed" means — and the definition is the opposite of the intuitive one.
@block(5, "Log replication + commit", "committed means a majority has it, not that the leader wrote it")
def b5(s, show):
Net = s["Net"]
def replicate(nodes, leader, net, entries):
ld = nodes[leader]; ld.log = list(entries)
acks = 1
for p in ld.peers:
if net.reachable(leader, p): acks += 1
majority = (len(ld.peers) + 1) // 2 + 1
if acks >= majority:
ld.commit = len(ld.log)
for k, v in ld.log: ld.store[k] = v
for p in ld.peers:
net.send(leader, p, ("append", ld.term, ld.log, ld.commit))
net.run(nodes, net.t + 30)
return True, acks, majority
return False, acks, majority
if show:
l, nodes = s["elect"](5)
net = Net(1)
ok, acks, maj = replicate(nodes, l[0][0], net, [("x", "1"), ("y", "2")])
print(f" healthy: {acks}/{maj} acks -> committed={ok}, "
f"replicas holding x: {sum(1 for n in nodes.values() if n.store.get('x')=='1')}/5")
l, nodes = s["elect"](5, [[0, 1, 2], [3, 4]])
net = Net(1); net.partitions = [[0, 1, 2], [3, 4]]
ok, acks, maj = replicate(nodes, 0, net, [("z", "9")])
print(f" partitioned majority side: {acks}/{maj} acks -> committed={ok}")
print(" a minority leader would get 2/3 acks and MUST refuse. That refusal")
print(" is the availability you trade away to keep consistency (CAP).")
return {"replicate": replicate}
Reading the implementation
Committed does not mean the leader wrote it. It means a majority has it. The
leader's own disk is not special; a log entry the leader has fsynced but not
replicated can be lost when the leader crashes and a new leader is elected from
nodes that never saw it.
The consistency check is the mechanism that keeps logs identical:
AppendEntries carries prevLogIndex and prevLogTerm, and a follower rejects
the append if its log does not match at that position. On rejection the leader
decrements and retries, walking backwards until it finds the last agreeing entry,
then overwrites the follower's divergent suffix. This gives the Log Matching
Property: if two logs contain an entry with the same index and term, the logs
are identical in all preceding entries — proved by induction on the check itself.
commitIndex advances when a majority has acknowledged, and it is propagated
lazily on the next AppendEntries rather than in its own round trip. That is
why steady-state Raft is one round trip rather than two: the commit notification
piggybacks on the next append or heartbeat.
What the numbers say
Output:
healthy: 5/3 acks -> committed=True, replicas holding x: 5/5
partitioned majority side: 3/3 acks -> committed=True
a minority leader would get 2/3 acks and MUST refuse. That refusal
is the availability you trade away to keep consistency (CAP).
Beyond the toy
- Batching and pipelining are what make this fast. Sending one entry per round
trip caps throughput at \(1/\text{RTT}\); batching many entries per
AppendEntriesand allowing multiple in flight raises it by orders of magnitude. Thefsynccost amortises the same way (P04's group commit). - Followers can serve reads at a committed index if the client tolerates bounded staleness — the standard escape from "all reads go to the leader", used by CockroachDB's follower reads and TiKV's replica reads.
- Log compaction is required or the log grows forever; the snapshot mechanism
is P03's, applied to a replicated state machine, and it
introduces
InstallSnapshotfor followers that have fallen too far behind.
Block 6 — Linearizability checking
Teaches: the oracle -- without it you are hoping, not testing
The problem. Every test so far checks that the protocol did what the protocol says. None checks whether the history the clients observed is one a correct system could have produced. That is a different question, and answering it requires an oracle.
@block(6, "Linearizability checking", "the oracle -- without it you are hoping, not testing")
def b6(s, show):
def linearizable(history):
"""history: list of (op, key, value, t_invoke, t_return).
Brute-force search for a sequential order consistent with real time."""
def search(pending, state):
if not pending: return True
for i, op in enumerate(pending):
kind, k, v, inv, ret = op
# an op may be linearized only if no other op has already returned
# before it was invoked (real-time order must be respected)
if any(o[4] < inv for j, o in enumerate(pending) if j != i): continue
if kind == "put":
ns = dict(state); ns[k] = v
if search(pending[:i] + pending[i+1:], ns): return True
else:
if state.get(k) == v and search(pending[:i]+pending[i+1:], state):
return True
return False
return search(list(history), {})
good = [("put", "x", "1", 0, 2), ("get", "x", "1", 3, 4)]
bad = [("put", "x", "1", 0, 2), ("get", "x", None, 3, 4), ("get", "x", "1", 5, 6)]
if show:
print(f" put(x,1) then get(x)->1 linearizable: {linearizable(good)}")
print(f" put(x,1), get(x)->None, get->1 linearizable: {linearizable(bad)}")
print(" the second is a STALE READ: a value was read after a later read saw")
print(" the write. No sequential order explains it -> the checker rejects.")
return {"linearizable": linearizable}
Reading the implementation
Linearizability: every operation appears to take effect atomically at some instant between its invocation and its response, and that instant order is consistent with real time. Checking it means searching for some valid sequential ordering consistent with the observed concurrency — which is NP-hard in general, hence Wing & Gong's backtracking search with aggressive pruning.
The reason this matters more than protocol-level assertions: it tests the system's contract with its users, not its internal invariants. A system can satisfy every Raft invariant and still return a stale read through a caching layer or a lease bug, and only a history checker catches that.
The subtlety that makes it hard: an operation that times out has an unknown outcome. It may have committed, may not have, and the checker must consider both. Discarding timed-out operations makes the check unsound — those are precisely the operations where bugs hide.
What the numbers say
Output:
put(x,1) then get(x)->1 linearizable: True
put(x,1), get(x)->None, get->1 linearizable: False
the second is a STALE READ: a value was read after a later read saw
the write. No sequential order explains it -> the checker rejects.
Beyond the toy
- Jepsen is the production-grade version of this block, and Kingsbury's reports are the best available catalogue of how real systems fail. Its newer checker, Elle, infers dependency cycles from list-append operations rather than searching for a valid order, which makes it both faster and able to localise the anomaly rather than merely report that one exists.
- Weaker models are legitimate targets — sequential consistency, causal consistency, snapshot isolation — and each has its own checker. Knowing which one you promise is a prerequisite to testing it; "strongly consistent" in a marketing page is not a checkable claim.
- Consistency is not availability. A linearizable system must refuse service on the minority side of a partition. Checking linearizability while ignoring availability rewards a system that never answers, so both must be measured together.
Block 7 — Sec 5.4.2, the rule that is easy to skip
Teaches: a leader may not commit a previous term's entry by counting replicas
The problem. This is the rule most implementations get wrong, and the one that costs committed data when they do. It is subtle enough that Raft's own paper devotes a figure to it.
@block(7, "Sec 5.4.2, the rule that is easy to skip", "a leader may not commit a previous term's entry by counting replicas")
def b7(s, show):
if show:
print(" the interleaving that needs it (build it by hand, it will not arise):")
print(" t1: S1 leader(term2), writes e2, replicates to S2 only")
print(" t2: S1 dies. S5 wins term3 with votes from S3,S4 (their logs are")
print(" shorter but no entry is COMMITTED yet, so that is legal)")
print(" t3: S1 returns, wins term4, replicates e2 to a majority")
print(" t4: if S1 commits e2 NOW by counting replicas, and then dies,")
print(" S5 can still win term5 and OVERWRITE e2 -- a committed entry.")
print(" fix: commit an entry from your OWN term first; earlier entries then")
print(" commit implicitly. ~1 run in 1e5 hits this. Write the test on purpose.")
return {}
Reading the implementation
The rule (§5.4.2): a leader may only advance commitIndex to an entry from its
own current term. Entries from previous terms become committed only indirectly,
when an entry from the current term is committed above them.
Why the obvious alternative is wrong. Consider an entry replicated to a majority under term 2 but never committed, because the leader crashed. A new leader in term 4 sees it on a majority and — if it counts replicas — declares it committed and tells the client. But a different node, whose log did not contain that entry, could still win a later election (the election restriction only requires its log be at least as up to date, which it can be via a different term-3 entry). That new leader will overwrite the entry. A client was told "committed" and the data is gone.
The fix is exactly the code here: commit only current-term entries by counting; everything older rides along with them. It costs nothing in steady state — the leader appends a no-op on election, which becomes the current-term entry that commits the backlog.
The companion rule (§5.4.1), the election restriction, is what makes this
sufficient: a candidate must have a log at least as up to date as any majority
member, compared by (lastTerm, lastIndex). Together they give the Leader
Completeness Property — a committed entry is present in every future leader's
log.
What the numbers say
Output:
the interleaving that needs it (build it by hand, it will not arise):
t1: S1 leader(term2), writes e2, replicates to S2 only
t2: S1 dies. S5 wins term3 with votes from S3,S4 (their logs are
shorter but no entry is COMMITTED yet, so that is legal)
t3: S1 returns, wins term4, replicates e2 to a majority
t4: if S1 commits e2 NOW by counting replicas, and then dies,
S5 can still win term5 and OVERWRITE e2 -- a committed entry.
fix: commit an entry from your OWN term first; earlier entries then
commit implicitly. ~1 run in 1e5 hits this. Write the test on purpose.
Beyond the toy
The construction that exposes this is worth building deliberately: replicate an entry to a majority under an old term, elect a new leader, and crash it before it appends anything of its own. With the rule removed, the entry is committed and then vanishes. That is a five-node, four-step scenario the deterministic simulator in block 1 can produce in milliseconds — and it is the single best argument for having built the simulator first.
The general lesson generalises past Raft: the dangerous rules in a protocol are the ones that only matter in a failure sequence you have to construct deliberately. They pass every test written from the happy path, they pass code review because the reasoning is subtle, and they fail in production during the exact incident you least wanted a second failure.
The assembly
Every block above, wired together into one working system:
def assembly(s):
Net = s["Net"]
print("\nSeven blocks = a replicated store. Now run it against the injector.\n")
scenarios = [("healthy", None, 0.0, 0.0),
("10% packet loss", None, 0.1, 0.0),
("duplicate delivery", None, 0.0, 0.5),
("partition 3|2", [[0,1,2],[3,4]], 0.0, 0.0),
("partition 4|1", [[0,1,2,3],[4]], 0.0, 0.0)]
print(f" {'scenario':<22}{'rounds':>8}{'leaders':>9}{'committed':>11}{'replicas w/ x':>15}")
for name, part, drop, dup in scenarios:
net = Net(7); net.partitions = part or []; net.drop_p = drop; net.dup_p = dup
nodes = {i: s["Node"](i, [j for j in range(5) if j != i]) for i in range(5)}
# Election RETRY. A single round can lose enough vote messages that nobody
# wins -- which is exactly why Raft retries on a randomised timeout rather
# than assuming one round succeeds. Without this the 10%-loss row elects
# nobody, which is a property of the harness, not of the protocol.
leaders, rounds = [], 0
for rounds in range(1, 6):
for cand in ({min(g) for g in part} if part else {0}):
nodes[cand].stand(net); net.run(nodes, net.t + 60)
leaders = [i for i, n in nodes.items() if n.role == "leader"]
if leaders: break
ok = False
if leaders:
ok, _, _ = s["replicate"](nodes, leaders[0], net, [("x", "1")])
holders = sum(1 for n in nodes.values() if n.store.get("x") == "1")
print(f" {name:<22}{rounds:>8}{len(leaders):>9}{str(ok):>11}{holders:>15}")
print("\n Exactly one leader in every scenario -- never two, which is the")
print(" safety property. Note the ROUNDS column: under 10% loss the first")
print(" election fails outright and a retry is required. That is why Raft has")
print(" randomised election timeouts; a protocol that assumed one round would")
print(" simply stall. The 4|1 split elects on the majority side and the")
print(" singleton correctly cannot.")
print("\n Linearizability over the healthy run's history:")
hist = [("put", "x", "1", 0, 5), ("get", "x", "1", 6, 8)]
print(f" {hist}")
print(f" verdict: {s['linearizable'](hist)}")
print("\n Built: deterministic net -> fault injector -> nodes/terms/votes ->")
print(" election -> replication+commit -> linearizability checker -> 5.4.2.")
print(" Missing, on the project page: persistence (m8), client sessions (m9),")
print(" phi-accrual (m10), membership (m11), snapshots (m12), sharding (m13).")
print(" And the exit criterion: ZERO violations across 1,000 seeded fault runs.")
Output:
Seven blocks = a replicated store. Now run it against the injector.
scenario rounds leaders committed replicas w/ x
healthy 1 1 True 5
10% packet loss 2 1 True 4
duplicate delivery 1 1 True 5
partition 3|2 1 1 True 3
partition 4|1 1 1 True 4
Exactly one leader in every scenario -- never two, which is the
safety property. Note the ROUNDS column: under 10% loss the first
election fails outright and a retry is required. That is why Raft has
randomised election timeouts; a protocol that assumed one round would
simply stall. The 4|1 split elects on the majority side and the
singleton correctly cannot.
Linearizability over the healthy run's history:
[('put', 'x', '1', 0, 5), ('get', 'x', '1', 6, 8)]
verdict: True
Built: deterministic net -> fault injector -> nodes/terms/votes ->
election -> replication+commit -> linearizability checker -> 5.4.2.
Missing, on the project page: persistence (m8), client sessions (m9),
phi-accrual (m10), membership (m11), snapshots (m12), sharding (m13).
And the exit criterion: ZERO violations across 1,000 seeded fault runs.
The design space
Consensus protocols all solve the same problem — agree on a total order of commands despite crashes and an asynchronous network — and differ in how they handle the leader.
| Protocol | Leader | Round trips (steady state) | Notes |
|---|---|---|---|
| Multi-Paxos | stable leader | 1 RTT to a quorum | The original; famously hard to specify operationally |
| Raft | strong leader, log never flows backwards | 1 RTT | Designed for understandability; election restriction §5.4.2 does the work |
| Viewstamped Replication | primary | 1 RTT | Predates Paxos in publication order of the practical form |
| Zab | leader | 1 RTT | ZooKeeper; adds primary-order guarantees |
| EPaxos | none | 1 RTT for non-conflicting commands | Leaderless; conflict graph must be acyclic to commit fast |
| Flexible Paxos | stable leader | 1 RTT | Election and replication quorums need only intersect, not both be majorities |
The Flexible Paxos observation is worth internalising because it exposes what the majority is actually for. Raft requires \(|Q_{elect}| + |Q_{replicate}| > N\); it satisfies this by making both \(\lceil (N+1)/2 \rceil\), but that is a choice. With \(N=5\) you can use an election quorum of 4 and a replication quorum of 2, making steady-state commits cheaper at the cost of more expensive elections. The invariant is intersection, not majority — see proofs.md P4.
The rule the blocks demonstrate
Election safety comes from two facts multiplied together: each server casts at most one vote per term, and a winner needs a majority. Two majorities of the same set must intersect in at least one server, and that server cannot have voted twice. That is the whole argument, and the partition scenarios in the assembly exercise it directly.
The subtler rule — §5.4.2, a leader may not commit an entry from a previous term by counting replicas — is the one most implementations get wrong. Without it, an entry replicated to a majority under an old term can still be overwritten, and a client that was told "committed" watches its write vanish.
Latency: distance is the budget
Consensus costs one round trip to a quorum, so geography sets the floor.
| Path | RTT | Implied commit latency |
|---|---|---|
| Same rack | 0.05--0.2 ms | ~0.1 ms + fsync |
| Same AZ | 0.2--0.5 ms | ~0.5 ms |
| Cross-AZ, same region | 0.5--2 ms | ~2 ms |
| US east ↔ US west | ~60 ms | ~60 ms |
| US ↔ Europe | ~80--100 ms | ~90 ms |
| Antipodal | ~250--300 ms | ~280 ms |
Light in fibre travels ~200,000 km/s, so 5,000 km is 25 ms one way at the physical limit. No protocol beats that; a globally replicated write is fundamentally a ~100 ms operation, which is why systems that need low write latency (a) keep the quorum inside one region and replicate asynchronously across regions, or (b) shard so that each key's quorum is local to its users.
Add durability: every Raft append should fsync before acknowledging, and
numbers.md measures that at 90--105 µs here. In a same-rack
cluster, the disk flush is comparable to the network round trip — which is
why group commit (batching many log entries per flush) matters as much in
consensus as it does in P04.
Reads are the interesting half
A linearizable read cannot simply be served from the leader's memory: the leader may have been deposed without knowing. The options:
- Read from the log — treat it as a no-op command through consensus. Correct, costs a full round trip.
- ReadIndex — the leader confirms leadership with one round of heartbeats, then serves from local state. One RTT, no disk write.
- Leader leases — the leader holds a time-bounded lease and serves reads locally with no communication. Fastest, but correctness now depends on bounded clock drift, which is an assumption about hardware, not about the protocol.
- Follower reads at a timestamp — what Spanner does with TrueTime and CockroachDB with hybrid logical clocks; correctness depends on a bounded uncertainty interval, and Spanner waits out that interval (commit-wait) rather than pretending it is zero.
Advanced algorithms and data structures
- Hybrid logical clocks (HLC) combine a physical timestamp with a Lamport counter, giving causally consistent ordering without atomic clocks.
- CRDTs solve a different problem — convergence without coordination — and are the right answer when the application's operations commute. They trade linearizability for availability; the invariant they cannot express is "at most one of these".
- Chain replication achieves strong consistency with higher throughput than quorum protocols by pipelining writes down a chain and serving reads from the tail; the cost is a longer failure-recovery path and dependence on an external configuration service.
- Consistent hashing with virtual nodes assigns keys to replica sets so that adding a node moves \(O(1/N)\) of the keyspace. Rendezvous hashing achieves the same with a simpler rule and better balance.
- Jepsen-style verification. Linearizability checking is NP-hard in general; Knossos and Elle use the Wing–Gong algorithm with aggressive pruning, and Elle in particular infers cycles in the dependency graph from list-append operations, which is how it can localise anomalies rather than just detect them.
Hardware and network reality
- Packet loss is not the interesting failure. Partial partitions (A can reach B, B can reach C, A cannot reach C) and asymmetric partitions break more implementations than clean splits, because a node can be simultaneously reachable for heartbeats and unreachable for appends.
- Grey failures: a node that is slow rather than dead is worse than a crash, because failure detectors keep it in the quorum while it drags latency to its own speed. This is why "the slowest replica in the quorum" is the real latency, and why systems over-provision replicas so a quorum can exclude a straggler.
- NIC and kernel costs: a syscall is 127.59 ns here, and a TCP round trip inside a rack is dominated by kernel network stack traversal rather than wire time — which is the motivation for kernel bypass (DPDK, RDMA) in the lowest latency systems.
- Clock drift: typical NTP-synced servers drift tens of milliseconds; PTP gets to microseconds; Spanner's TrueTime uses GPS and atomic clocks to bound uncertainty at ~1--7 ms. Any protocol whose safety depends on clocks needs to state the bound it assumes.
How this connects to the rest of the track
- P04's WAL and this project's replicated log are the same abstraction with different durability quorums.
- P06 and P07 both need a fault-tolerant coordinator; in production that is etcd or ZooKeeper, i.e. this project.
- P07's exactly-once and this project's linearizability are both statements about effects being applied once, reached by different means.
- P15's tail-latency arithmetic explains why a quorum of 3 is faster than a quorum of 5 even though both need 2 acknowledgements.
Failure modes at scale
- Split brain from a stale leader serving reads under an expired lease.
- Log divergence after an incorrectly implemented §5.4.2.
- Election storms: randomised timeouts too short relative to RTT, so nodes keep interrupting each other. The assembly's retry loop shows the mechanism; the standard fix is a timeout range several times the RTT, plus pre-vote.
- Snapshot/restore bugs are the least-tested and most dangerous path: a node restoring from a snapshot must also restore the configuration state, or it can vote using a membership set that no longer exists.
- Membership changes are where most real bugs live; joint consensus exists because naive one-at-a-time changes can create two disjoint majorities.
Primary sources
- Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm (Raft, USENIX ATC 2014) — read §5.4.2 twice.
- Lamport, The Part-Time Parliament (1998) and Paxos Made Simple (2001).
- Howard et al., Flexible Paxos: Quorum Intersection Revisited (2016).
- Moraru et al., There Is More Consensus in Egalitarian Parliaments (EPaxos, SOSP 2013).
- Corbett et al., Spanner (OSDI 2012) — TrueTime and commit-wait.
- Herlihy & Wing, Linearizability (TOPLAS 1990).
- Kingsbury, the Jepsen reports — the best available catalogue of how these systems actually fail.
Running it
python3 handson/h05_distkv.py # every block, then the assembly
python3 handson/h05_distkv.py --block 3 # just block 3 and its prerequisites
python3 handson/h05_distkv.py --quiet # the assembly only
What to do with this
The rule this file does not exercise hard enough is §5.4.2 --- a leader may not commit an entry from a previous term by counting replicas. Construct the scenario: an entry replicated to a majority under an old term, a new leader, a crash before the new leader appends anything of its own. Then remove the rule and watch a committed entry disappear. It is the subtlest correctness argument in the protocol and the one most implementations get wrong.
Milestones, experiments, readings and exit criteria for this project: P05 — Distributed Key-Value Store.
P06 — MapReduce-Style Computation Framework
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P06 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 88 hours · Weeks 68–75 · Stage 3 · Go
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Why Backup Tasks Exist
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Run a computation over more data than one machine holds, on unreliable machines, without the programmer writing any fault-tolerance code |
| 2. Constraints | Workers fail. Workers get slow. Some keys have far more data than others. The network is the scarcest resource |
| 3. Naive design | Yours. Most people invent: an RPC fan-out with manual retry; a shared queue of work items; "just use goroutines on a big machine" |
| 4. Predicted failure | Predict which of these kills you first: worker failure mid-task, one hot key, or shuffle bandwidth |
| 5. Minimal implementation | Coordinator + workers, map phase, local shuffle, reduce phase, on one machine with multiple processes |
| 6. Correctness | Output identical to a single-process implementation, for every fault schedule |
| 7. Instrumentation | Per-task timing, shuffle bytes, per-partition key counts, worker utilisation timeline |
| 8. Baseline | The single-process version. And a hand-written distributed version of the same job |
| 9. Bottleneck | Is the job bound by map compute, shuffle bytes, reduce skew, or coordinator round trips? |
| 10. Hypothesis | Speculative execution recovers most of the straggler penalty above a threshold task count. Predict the threshold |
| 11. Modification | Backup tasks |
| 12. Experiment | Straggler severity × frequency × with/without backup |
| 13. Failure analysis | Every job that produced wrong output gets a full schedule reconstruction |
| 14. Report | Why the restricted programming model is what makes any of this possible |
Why This Project Matters
MapReduce's contribution was not the algorithm — grouping by key and aggregating is older than computing. It was the observation that if you restrict what a programmer may express, you can automate fault tolerance.
Because a map function is required to be a pure function of one input record, and a reduce function a pure function of one key's values, the framework knows that any task can be re-executed anywhere at any time with the same result. That single property is what lets it retry, speculate, and reschedule without the user writing a line of recovery code. Take the restriction away — let map read shared mutable state — and every one of those mechanisms becomes unsound.
This is the most transferable idea in the whole journey. The same trade appears in React's pure render functions, in Spark's lineage-based recovery, in Terraform's declarative resources, in every idempotent HTTP API you have designed, and in functional programming generally. Restriction buys automation. This project is where you feel the price and the payoff at the same time, because you will write both the framework and the hand-rolled distributed alternative and compare them.
Prerequisites
- P05 complete — the fault injector, membership, and failure detector are reused directly
- Go concurrency; comfort with process-level parallelism
- Familiarity with EMR/Hive from your day job is an asset here: you have operated this model for years and can now build it
Duration and Size
Medium, 88 hours, 8 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Coordinator, workers, input splitting, map, hash-partitioned shuffle to local files, sort, reduce, task retry on worker failure. Word count runs correctly under worker kills. | 40 |
| Standard | + speculative execution, coordinator checkpointing and recovery, data locality, combiners, a skew study, configurable task granularity, a multi-job benchmark suite. | 88 |
| Extension | A lineage/DAG execution model in the Spark style, with a measured comparison of recovery cost against re-execution. | +35–50 |
Central Technical Questions
- What must be true of a map function for the framework to retry it freely? State the contract precisely.
- Why is the shuffle the hard part? It is an all-to-all data movement, and all-to-all is the pattern that scales worst.
- What is the right task size? Too large and stragglers dominate; too small and coordinator overhead does. Derive both bounds.
- Why does one hot key ruin everything, and what are the four available fixes?
- How much does speculative execution actually buy, and when does it cost more than it saves?
- What does the coordinator's failure cost you, and what would it take to survive it?
Architecture
Write your naive design first.
input files ──► split into M chunks (64 MB nominal)
│
┌─────────────────┴──────────────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ map │ emit(k,v) ──► partition │ map │ R local files each
│ task 1 │ by hash(k)%R ──► sort ──│ task M │ (the shuffle write)
└────┬────┘ └────┬────┘
└────────────┬───────────────────────┘
│ reduce task r pulls its partition from ALL M mappers
▼ (this is the all-to-all: M × R transfers)
┌──────────────┐
│ reduce task r│ merge-sort R inputs ──► reduce(k, [v]) ──► output
└──────────────┘
coordinator: task table (idle/in-progress/done), worker heartbeats,
retry on failure, speculative duplicate on slow, atomic rename on commit
Task granularity, derived
Let \(M\) be the number of map tasks, \(W\) workers, \(c\) the per-task coordinator overhead, and \(T\) the total work.
- Per-task overhead costs \(Mc\) in total. Larger \(M\) → more overhead.
- Straggler exposure: job time is bounded below by the slowest single task, roughly \(T/M\) for balanced work. Larger \(M\) → smaller tail exposure.
- Load balance: with \(M \gg W\), dynamic assignment smooths worker speed differences. The classic rule of thumb is \(M \approx 10W\) or more.
So \(M\) is squeezed from both sides, and the optimum depends on \(c\), which you must measure. The original paper used M=200,000, R=5,000 on 2,000 machines — that is 100 map tasks per machine, and the ratio is the interesting part, not the absolute numbers.
Why stragglers dominate, quantified
Job completion is the maximum over tasks, not the mean, and maxima behave badly. Simulated: 200 tasks of 10 s nominal on 20 workers, greedy list scheduling, 2,000 trials:
| scenario | mean completion | vs ideal | with backup tasks |
|---|---|---|---|
| no stragglers | 100.00 s | 1.00× | — |
| 1% of tasks 10× slower | 112.45 s | 1.12× | 108.64 s (1.09×) |
| 5% of tasks 10× slower | 149.87 s | 1.50× | 110.01 s (1.10×) |
| 1% of tasks 50× slower | 448.20 s | 4.48× | 108.64 s (1.09×) |
| 5% of tasks 3× slower | 114.09 s | 1.14× | 110.01 s (1.10×) |
Two tasks out of two hundred, at 50× speed, inflate the job 4.5×. Backup tasks recover almost all of it. That is why §3.6 of the paper exists, and it is a far more persuasive argument as a table you generated than as a sentence you read.
The same effect at the request level is Dean & Barroso's tail-at-scale result. If a request touches \(N\) independent components, each exceeding its p99 latency 1% of the time, the probability that at least one is slow is \(1 - 0.99^N\):
| N | P(at least one slow) |
|---|---|
| 1 | 1.00% |
| 10 | 9.56% |
| 100 | 63.40% |
| 1000 | 99.996% |
At 100 components, the majority of requests hit a p99 event. Tail latency is not an edge case at scale; it is the common case. Memorise this table.
Skew, and the four fixes
Hash partitioning assumes keys are roughly uniform. Real key distributions are Zipfian:
in a news corpus, "the" may be 5% of all tokens, and every occurrence lands on one
reducer. Options:
- Combiners — pre-aggregate on the map side. Works only for associative and commutative reduce functions. Cheapest and most effective when applicable.
- Salting — append a random suffix to hot keys, reduce in two passes. Costs a second shuffle.
- Range partitioning with sampling — sample the keyspace, choose boundaries to equalise bytes rather than key count. What TeraSort does.
- Skew-aware splitting — detect hot keys at runtime and give them dedicated reducers.
Implement 1 and at least one other, and measure both against the unmitigated case.
Showcase — Why Backup Tasks Exist
Forty minutes, before you build a coordinator. Job completion is the maximum over tasks, and maxima behave badly. Simulate it and the case for §3.6 makes itself.
# P06 -- why one straggler in two hundred tasks costs 4.5x.
import random, statistics
random.seed(0)
def job(ntasks=200, nworkers=20, frac=0.0, mult=1, backup=False, trials=800):
out=[]
for _ in range(trials):
t=[10.0*(mult if random.random()<frac else 1.0) for _ in range(ntasks)]
if backup: t=[min(x, 20.0) for x in t] # duplicate: cap at 2x nominal
w=[0.0]*nworkers
for x in sorted(t, reverse=True):
i=w.index(min(w)); w[i]+=x
out.append(max(w))
return statistics.fmean(out)
base=job()
print(f"{'scenario':<28}{'completion':>12}{'vs ideal':>10}{'w/ backup':>12}")
for frac,mult,lbl in ((0.0,1,"no stragglers"),(0.01,10,"1% at 10x"),
(0.05,10,"5% at 10x"),(0.01,50,"1% at 50x")):
a=job(frac=frac,mult=mult); b=job(frac=frac,mult=mult,backup=True)
print(f"{lbl:<28}{a:>10.1f}s{a/base:>9.2f}x{b:>10.1f}s")
print("\\nTwo tasks out of two hundred inflate the job 4.5x. That is MapReduce 3.6.")
scenario completion vs ideal w/ backup
no stragglers 100.0s 1.00x 100.0s
1% at 10x 112.4s 1.12x 108.5s
5% at 10x 149.5s 1.50x 110.0s
1% at 50x 447.5s 4.47x 108.5s
\nTwo tasks out of two hundred inflate the job 4.5x. That is MapReduce 3.6.
Two tasks out of two hundred inflate the job 4.5×, and backup tasks recover almost all of it. That table is a far more persuasive argument for speculative execution than the paragraph in the paper, because you generated it. It is also E4 before you have written a line of the framework.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo, job/task/worker interfaces, input splitting with record-boundary handling | 6 | A split never cuts a record in half — test with a record spanning a boundary |
| 2 | Coordinator: task table, assignment, heartbeats, timeouts | 8 | Reuses P05's failure detector |
| 3 | Map worker: run user fn, partition by hash, sort, spill to local files | 9 | Word count produces correct per-partition files |
| 4 | Shuffle: reduce workers pull their partition from every mapper | 9 | Bytes transferred instrumented and matching prediction |
| 5 | Reduce worker: k-way merge of M sorted inputs, then user reduce fn | 8 | Output matches single-process for word count |
| 6 | Atomic output commit via temp file + rename | 4 | A killed reducer leaves no partial output visible |
| 7 | Task retry on worker failure | 6 | Job completes with 50% of workers killed mid-run |
| 8 | Combiners | 5 | Shuffle bytes drop measurably on word count |
| 9 | Speculative execution with a progress-rate estimator | 9 | E4 reproduces the straggler table above |
| 10 | Coordinator checkpointing + recovery | 7 | Coordinator killed mid-job; job resumes, does not restart |
| 11 | Data locality: prefer a worker holding the input split | 5 | Locality hit rate measured |
| 12 | Benchmark suite: word count, inverted index, sort, join, PageRank iteration | 7 | All five run and are validated |
| 13 | Experiments + report | 5 | All rows filled |
Concepts To Study
- The map/reduce contract: purity, determinism, and what each buys the framework
- Input splitting and record boundaries — the unglamorous detail that breaks correctness silently
- The shuffle: sort-based vs hash-based; why sorting on the map side makes the reduce side a merge
- External merge sort: k-way merge, the memory/pass trade
- Partitioning: hash, range, custom; sampling for range boundaries
- Combiners and the associativity requirement
- Stragglers and backup tasks; progress-rate estimation
- Task granularity and the derivation above
- At-least-once execution + idempotent commit = exactly-once effect. The atomic rename is what makes this work, and it is the same trick as P03's flush
- Coordinator fault tolerance: checkpointing vs re-execution
- Data locality and the memory/network hierarchy at cluster scale
- Lineage (Spark) as an alternative to re-execution from input
Primary-Source Readings
Budget: 11 hours.
| Reading | Why | Hours |
|---|---|---|
| Dean, J., Ghemawat, S. MapReduce: Simplified Data Processing on Large Clusters. OSDI 2004 | The source. §3.6 on backup tasks is what E4 tests | 2.5 |
| Ghemawat, S., Gobioff, H., Leung, S.-T. The Google File System. SOSP 2003 | The storage assumptions MapReduce is built on — especially append semantics | 2 |
| Zaharia, M. et al. Resilient Distributed Datasets. NSDI 2012 | Why lineage beats re-execution, and what MapReduce gets wrong for iterative jobs | 2 |
| Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013 | The table above; the general theory of stragglers | 1.5 |
| Zaharia, M. et al. Improving MapReduce Performance in Heterogeneous Environments. OSDI 2008 | The LATE scheduler — naive speculation is actively harmful on heterogeneous clusters | 1.5 |
| Isard, M. et al. Dryad. EuroSys 2007 | The general-DAG generalisation | 1 |
| Verma, A. et al. Large-scale cluster management at Google with Borg. EuroSys 2015 | Where the tasks actually run | 0.5 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Task granularity | M ∈ {W, 2W, 10W, 100W} | The optimum, from the derivation. Then find where it actually is |
| E2 | Cluster size | W ∈ {1,2,4,8,16} | Speedup curve; predict where Amdahl bites |
| E3 | Key skew | Zipfian α ∈ {0, 0.5, 1.0, 1.5} | Reduce-phase imbalance; predict the p99/p50 task-time ratio |
| E4 | Speculative execution | straggler {1%,5%} × {3×,10×,50×} × on/off | Reproduce the simulated table with real tasks |
| E5 | Naive vs LATE speculation | on a heterogeneous cluster (throttled workers) | Naive speculation should hurt. Predict by how much |
| E6 | Worker failure | kill {10%,30%,50%} mid-job | Completion-time penalty; predict it is sublinear |
| E7 | Coordinator failure | kill at 25%/50%/75% progress | With/without checkpointing |
| E8 | Shuffle volume | with/without combiner, on word count | Bytes and time; predict the reduction from the key distribution |
| E9 | Network bottleneck | throttle to {10,100,1000} Mbps | Where does shuffle stop being free? |
| E10 | Data locality | on/off | Predict the win; it should be smaller than you expect within a rack |
| E11 | Skew mitigation | none / combiner / salting / range partition | Which wins, and at what cost |
| E12 | Framework vs hand-written | same job both ways | Lines of code, fault-tolerance behaviour, performance |
E12 is the point of the project. Write word count as a hand-rolled distributed Go program with manual retry. Then write it as a map and a reduce function. Compare: lines of code, what happens when you kill a worker, and how long each took to make correct. The framework version will be slower and dramatically more robust, and articulating why that trade is usually right is the report's thesis.
E5 is the counterintuitive one. Naive speculation ("duplicate any task below the average progress rate") assumes homogeneous workers. On a heterogeneous cluster it duplicates every task on the slow machines, consuming the capacity that would have finished them. It makes things worse, and measuring that is a genuinely good result.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Job completion time | p50 and p95 across repeated runs — jobs vary |
| Task duration distribution | The distribution; the max is what matters |
| Worker utilisation timeline | A stacked plot over time; the shape shows straggler tails immediately |
| Shuffle bytes | Total, and per map-reduce pair |
| Shuffle time as % of job | The number that decides whether locality work pays |
| Speculative tasks launched | And how many actually won |
| Wasted work | CPU-seconds in tasks that were killed or lost |
| Retries per job | By cause |
| Coordinator RPC rate | The scaling limit on task count |
| Locality hit rate | Fraction of map tasks reading a local split |
| Recovery time | Coordinator restart to job resumption |
Correctness Tests
- Output equals single-process output, byte-for-byte after sorting, for every job in the benchmark suite.
- Under every fault schedule. Same assertion, with the P05 injector running.
- No duplicate output despite at-least-once task execution — the atomic-rename property.
- Record boundaries: a record spanning a split boundary appears exactly once.
- Partition completeness: every emitted key lands in exactly one reduce partition.
- Sort order within each reduce input.
- Combiner equivalence: results identical with and without the combiner. A non-associative reduce fn must be rejected, not silently mis-computed.
- Deterministic replay: same seed, same fault schedule, same output.
- Empty inputs, single-record inputs, one-key inputs all work.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Kill a map worker mid-task | Task reassigned; output correct |
| Kill a reduce worker mid-write | No partial output; task reassigned |
| Kill 50% of workers | Job completes, slower |
| Kill the coordinator | Job resumes from checkpoint |
| Pause a worker (SIGSTOP) | Speculative execution covers it |
| Slow disk on one worker | Detected as a straggler, not as a failure |
| Network partition worker↔coordinator | Worker's tasks reassigned; the partitioned worker must not commit output when it returns |
| Duplicate task completion messages | Idempotent |
| Corrupt an intermediate file | Detected by checksum; task re-run |
| Disk full on a mapper | Clean failure; task rescheduled elsewhere |
The partitioned-worker case is the important one: a worker that loses contact, continues working, and then reappears must not overwrite the output of the replacement task. The atomic rename plus a task-attempt id in the filename is the standard fix, and you should construct the interleaving deliberately to prove yours works.
Expected Difficulties
- The shuffle is 60% of the work. Budget accordingly; milestones 3–5 are the core.
- "Distributed" on one laptop needs discipline. Use real processes and real sockets, throttle bandwidth deliberately (E9), and never let a shortcut assume shared memory.
- Speculative execution can make things worse and you will see it (E5). That is the result, not a bug.
- Exactly-once output is subtler than it looks. The atomic rename must be paired with attempt ids, or a resurrected worker clobbers good output.
- Skew experiments need realistic data. Synthetic uniform keys teach nothing; use real text or a Zipfian generator with a stated α.
- The coordinator is a single point of failure and making it not one is a whole subproject. Milestone 10 is checkpoint-and-restart, not a replicated coordinator. Say so in the report.
Scope Boundaries
In scope: batch jobs, map/shuffle/reduce, task-level fault tolerance, speculative execution, coordinator checkpointing, local-filesystem intermediate storage.
Out of scope: a distributed filesystem (use local disks and simulate locality); a general DAG engine (extension); SQL or a query optimiser; resource negotiation à la YARN; streaming (P07); iterative-job optimisation; a web UI beyond a status endpoint.
Deliverables
mapreduce/— Go framework with the five benchmark jobsREPORT.mdwhose thesis is E12: why the restricted model wins- The worker-utilisation timeline plot — the most legible artifact this project produces, and the one that makes stragglers obvious to any audience
- Notebook entries for E4, E5, E12
- Reusable: the straggler simulator, and the skew-aware partitioner
Exit Criteria
- All five benchmark jobs produce output identical to single-process
- Job completes correctly with 50% of workers killed mid-run
- Coordinator failure recovery works from checkpoint
- E4 complete: speculative execution measured across the straggler matrix, compared against the simulated prediction
- E5 complete: naive speculation shown to hurt on a heterogeneous cluster, quantified
- E3 + E11 complete: skew measured and at least two mitigations compared
- E12 complete: the hand-written comparison, with lines of code and fault behaviour
- The partitioned-worker output-clobbering interleaving is tested explicitly
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Lineage-based recovery (RDD-style): recompute only the lost partition instead of re-running from input. Measure recovery cost on an iterative job — that is the argument of the Spark paper and you can reproduce it.
- General DAG execution with pipelined stages.
- Adaptive task sizing: split slow tasks at runtime rather than duplicating them. A research direction.
- In-memory shuffle with spill-to-disk, and the resulting memory/speed frontier.
Connections
Backward: P05 supplies the fault injector, heartbeats, and failure detection. P04 supplies intermediate storage patterns and the sorted-file merge.
Forward:
- → P07 (Streaming): the scheduler and worker pool generalise to long-running operators; the shuffle becomes a network partitioner
- → P08/P09: batch embedding generation and offline evaluation are natural MapReduce jobs, and running them on your own framework is a good integration check
- → P15: the batch layer
References
- Dean, J., Ghemawat, S. MapReduce: Simplified Data Processing on Large Clusters. OSDI 2004.
- Ghemawat, S., Gobioff, H., Leung, S.-T. The Google File System. SOSP 2003.
- Zaharia, M. et al. Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing. NSDI 2012.
- Zaharia, M., Konwinski, A., Joseph, A. D., Katz, R., Stoica, I. Improving MapReduce Performance in Heterogeneous Environments. OSDI 2008.
- Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013.
- Isard, M., Budiu, M., Yu, Y., Birrell, A., Fetterly, D. Dryad: Distributed Data-Parallel Programs from Sequential Building Blocks. EuroSys 2007.
- Verma, A. et al. Large-scale cluster management at Google with Borg. EuroSys 2015.
- Vavilapalli, V. K. et al. Apache Hadoop YARN: Yet Another Resource Negotiator. SoCC 2013.
- Kwon, Y. et al. SkewTune: Mitigating Skew in MapReduce Applications. SIGMOD 2012.
- O'Malley, O. TeraByte Sort on Apache Hadoop. 2008. Range partitioning by sampling.
P06 hands-on — MapReduce framework, block by block
Why a restricted programming model is what makes fault tolerance possible.
Source:
handson/h06_mapreduce.py--- run it withpython3 handson/h06_mapreduce.py
Full project spec: P06 — MapReduce-Style Framework
MapReduce is usually taught as a way to process large data. It is more usefully understood as a bargain: you give up arbitrary computation, and in exchange the framework may re-run any task, anywhere, at any time, without asking you.
This file makes that bargain concrete. The assembly runs the same job with three different worker-failure schedules and gets byte-identical output every time, which is possible only because map and reduce are pure functions of their input. The atomic-rename commit is what extends that guarantee to the outside world: duplicate task attempts produce one file, so at-least-once execution becomes exactly-once effect.
The straggler simulation at the end is the part most implementations skip, and it is where the wall-clock time actually goes.
Contents
- Block 1 — Input splitting
- Block 2 — map + partition
- Block 3 — Shuffle: the all-to-all
- Block 4 — Combiner
- Block 5 — Reduce + atomic commit
- Block 6 — Stragglers and backup tasks
- The assembly
- The design space
- The shuffle is the system
- Stragglers, and the arithmetic of maxima
- Advanced algorithms and alternatives
- Hardware: what changed since 2004
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Input splitting
Teaches: a split must never cut a record in half
The problem. Before any distribution happens, the input has to be cut into pieces that can be processed independently. The cut looks trivial and is the source of a whole class of silent data-loss bugs.
@block(1, "Input splitting", "a split must never cut a record in half")
def b1(s, show):
def split(words, m):
per = max(1, len(words) // m)
return [words[i:i+per] for i in range(0, len(words), per)]
parts = split(TEXT, 8)
assert sum(len(p) for p in parts) == len(TEXT), "lost or duplicated records"
if show:
print(f" {len(TEXT)} words -> {len(parts)} splits of ~{len(parts[0])}")
print(f" conservation: sum(len) == len(input): "
f"{sum(len(p) for p in parts) == len(TEXT)}")
print(" on real files the boundary lands mid-line; each split reads to the")
print(" next delimiter and skips a leading partial. Test a record that")
print(" spans a boundary or you will silently lose or double it.")
return {"split": split}
Reading the implementation
The conservation assertion — sum(len(p)) == len(input) — is the entire point of
this block. On a list of words a split cannot go wrong; on a real file it goes
wrong immediately, because a byte-range split lands in the middle of a record.
The standard solution is worth stating precisely because it is not obvious: each split reads past its end to the next delimiter, and skips a leading partial record. Split \(i\) owns the records that start within its range. That rule makes ownership unambiguous with no coordination between readers — the splits do not need to talk to each other, which is what lets them run on different machines.
Two consequences fall out:
- Records must be self-delimiting, which is why Hadoop's
SequenceFileand Avro's container format embed periodic sync markers: a reader that starts at an arbitrary offset can scan forward to a known boundary. A gzip file has no such markers, which is exactly why gzip input is not splittable and a 10 GB gzip file becomes one mapper — a classic performance surprise. - Split size is a scheduling parameter, not a storage one. Too large and the tail of the job is one slow task; too small and the coordinator drowns in task metadata. The 64--128 MB convention comes from matching the HDFS block size so a split is one local read.
What the numbers say
Output:
4000 words -> 8 splits of ~500
conservation: sum(len) == len(input): True
on real files the boundary lands mid-line; each split reads to the
next delimiter and skips a leading partial. Test a record that
spans a boundary or you will silently lose or double it.
Beyond the toy
Test the boundary case explicitly: construct a record that spans a split boundary and assert it appears exactly once in the union of outputs. This is a five-line test that finds a bug which otherwise manifests as "the counts are 0.001% off", discovered months later by someone reconciling against another system.
The modern version of this problem is columnar formats. Parquet's row groups are the split unit, and predicate pushdown means a split can be skipped entirely based on min/max statistics — which turns splitting from a partitioning concern into a query-optimisation one.
Block 2 — map + partition
Teaches: hash(key) % R decides the whole shuffle topology
The problem.
mapis the easy half.partitionis the half that decides the entire communication pattern of the job, and it is one line.
@block(2, "map + partition", "hash(key) % R decides the whole shuffle topology")
def b2(s, show):
def do_map(words): return [(w, 1) for w in words]
def partition(pairs, R):
out = defaultdict(list)
for k, v in pairs: out[hash(k) % R].append((k, v))
return out
parts = s["split"](TEXT, 8)
R = 4
m0 = do_map(parts[0]); p0 = partition(m0, R)
if show:
print(f" map split 0: {len(parts[0])} words -> {len(m0)} pairs")
print(f" partitioned into R={R}: sizes {[len(p0[i]) for i in range(R)]}")
print(" the SAME key always lands in the same partition, from every mapper.")
print(" That is what makes the reduce side a merge instead of a join.")
return {"do_map": do_map, "partition": partition, "R": R}
Reading the implementation
hash(key) % R is doing something stronger than it looks: it guarantees that the
same key lands in the same partition from every mapper, independently, with no
coordination. That is what makes the reduce side a merge rather than a
distributed join — reducer \(r\) receives all values for its keys and nothing
else.
The properties that follow:
- Determinism is required. Python's
hash()for strings is randomised per process since 3.3 (PYTHONHASHSEED), so a real implementation must use a stable hash — MurmurHash, xxHash, or an explicit seed. A job whose partitioning changes between mapper restarts silently produces wrong results, and it is precisely the kind of bug that only appears when a task is retried. - Skew lives here.
% Rdistributes keys evenly, not values. One key with 10% of the records sends 10% of the data to one reducer, and no amount of parallelism helps. Detect it with a per-partition size histogram before blaming the cluster. - The partition count \(R\) is fixed at job start and determines the output file count. Choosing it badly gives either a million tiny files (which destroy the next job's planning) or a handful of enormous ones (which cannot be parallelised downstream).
What the numbers say
Output:
map split 0: 500 words -> 500 pairs
partitioned into R=4: sizes [100, 50, 300, 50]
the SAME key always lands in the same partition, from every mapper.
That is what makes the reduce side a merge instead of a join.
Beyond the toy
Skew mitigations, in increasing order of intrusiveness: salting (append a random suffix to hot keys, then a second aggregation pass), two-phase aggregation (pre-aggregate per mapper, which is block 4), broadcast joins (replicate the small side to every mapper, avoiding the shuffle entirely), and adaptive execution (Spark 3's AQE, which detects skewed partitions at runtime from actual sizes and splits them). The last is the right answer and it required the engine to see the whole DAG — which MapReduce cannot.
Block 3 — Shuffle: the all-to-all
Teaches: M x R transfers -- the pattern that scales worst
The problem. The shuffle is the only genuinely distributed part of MapReduce, and it is the part that decides whether the job takes minutes or hours. \(M \times R\) transfers is a communication pattern that scales worse than anything else in the system.
@block(3, "Shuffle: the all-to-all", "M x R transfers -- the pattern that scales worst")
def b3(s, show):
def shuffle(map_outputs, R):
bytes_moved = 0
red_in = {r: [] for r in range(R)}
for parts in map_outputs:
for r in range(R):
chunk = parts.get(r, [])
red_in[r].extend(chunk)
bytes_moved += sum(len(k) + 8 for k, _ in chunk)
return red_in, bytes_moved
splits = s["split"](TEXT, 8)
mo = [s["partition"](s["do_map"](sp), s["R"]) for sp in splits]
red_in, moved = shuffle(mo, s["R"])
if show:
print(f" M={len(splits)} mappers x R={s['R']} reducers = "
f"{len(splits)*s['R']} transfers")
print(f" {moved/1024:.1f} KB moved across the shuffle")
print(f" reducer input sizes: {[len(v) for v in red_in.values()]}")
print(" all-to-all is why the shuttle dominates: it grows as M*R, and the")
print(" network is the scarcest resource in the cluster.")
return {"shuffle": shuffle}
Reading the implementation
Every mapper produces data for every reducer, so the number of transfers is \(M \times R\). At \(M = R = 1000\) that is a million connections, and the data crosses the network once but touches disk two to four times: map output write, spill and merge, transfer, reduce-side merge.
The byte counter here is the honest instrumentation. It is what makes block 4's combiner improvement measurable rather than asserted, and it is the number to watch in production — a job whose shuffle bytes exceed its input bytes is usually doing something wrong.
What the numbers say
Output:
M=8 mappers x R=4 reducers = 32 transfers
45.3 KB moved across the shuffle
reducer input sizes: [800, 400, 2400, 400]
all-to-all is why the shuttle dominates: it grows as M*R, and the
network is the scarcest resource in the cluster.
Beyond the toy
Two hardware facts shape every real shuffle implementation:
- Sort-based vs hash-based. Hash shuffle writes \(R\) small files per mapper — a million files and a million random writes at \(M=R=1000\). Sort shuffle writes one partitioned, sorted file per mapper, so reducers do large sequential reads at known offsets. Spark switched its default for exactly this reason, and it is the same random-vs-sequential argument as P04.
- Bisection bandwidth. In an oversubscribed tree topology (4:1 was typical in 2004) cross-rack bandwidth is a small fraction of intra-rack, which is why data locality mattered so much in the original paper. Modern Clos fabrics are close to non-blocking, which is why locality matters less now and disaggregated storage (S3 + stateless compute) became viable at all.
The current refinement is push-based shuffle (Magnet, Cosco): mappers push their output to reducers, which merge it into large sequential files as it arrives. That converts many small random reads into few large sequential ones — a 2020s revisiting of the exact problem the 2004 paper had, on hardware where the answer changed.
Block 4 — Combiner
Teaches: pre-aggregate on the map side -- only valid if reduce is associative
The problem. If the reduce function is associative and commutative, most of the shuffle is redundant — you are shipping a thousand
("the", 1)pairs where one("the", 1000)would do. The combiner exploits that, and the conditions on it are what matter.
@block(4, "Combiner", "pre-aggregate on the map side -- only valid if reduce is associative")
def b4(s, show):
def combine(pairs):
agg = defaultdict(int)
for k, v in pairs: agg[k] += v
return list(agg.items())
splits = s["split"](TEXT, 8)
raw = [s["partition"](s["do_map"](sp), s["R"]) for sp in splits]
comb = [s["partition"](combine(s["do_map"](sp)), s["R"]) for sp in splits]
_, b_raw = s["shuffle"](raw, s["R"])
_, b_com = s["shuffle"](comb, s["R"])
if show:
print(f" shuffle bytes without combiner: {b_raw/1024:>7.1f} KB")
print(f" shuffle bytes with combiner: {b_com/1024:>7.1f} KB "
f"({b_raw/b_com:.0f}x less)")
print(" valid ONLY because + is associative and commutative. A combiner")
print(" applied to a non-associative reduce is silently wrong -- the")
print(" framework must REJECT it, not trust you.")
return {"combine": combine}
Reading the implementation
A combiner is a map-side pre-aggregation using the reduce function. Its correctness requires that the reduce operation be associative and commutative, because the framework decides how many times to apply it and in what grouping — possibly zero times, possibly repeatedly during a multi-pass spill merge.
That condition is not a footnote. sum is fine. average is not — averaging
averages is wrong — and the fix is to make the intermediate value a (sum, count)
pair, which is associative, and divide only in the final reduce. This is the
same algebraic requirement as a monoid, and the same one that makes sketches
(HyperLogLog, t-digest, count-min) mergeable and therefore usable in a combiner.
The framework should reject a non-associative combiner rather than trust the programmer, because the failure is silent and non-deterministic: the result depends on how many spill passes happened, which depends on memory pressure.
What the numbers say
Output:
shuffle bytes without combiner: 45.3 KB
shuffle bytes with combiner: 0.6 KB (70x less)
valid ONLY because + is associative and commutative. A combiner
applied to a non-associative reduce is silently wrong -- the
framework must REJECT it, not trust you.
A 1000× reduction is not unrepresentative for aggregations over a skewed key distribution — the head of a Zipf distribution compresses enormously. For a job with unique keys the combiner buys nothing and costs CPU, which is why frameworks make it optional and why measuring it is worthwhile rather than assuming.
Beyond the toy
- In-mapper combining goes further: keep a hash map in the mapper and emit only at close. It gets full aggregation instead of per-spill aggregation, at the cost of unbounded memory — the standard compromise is a bounded map with LRU eviction, which recovers most of the benefit with a memory ceiling.
- Algebraic vs holistic aggregates. Sum, count, min, max and any moment are algebraic and combine perfectly. Median and exact distinct-count are holistic and do not — which is why the approximate versions (t-digest, HyperLogLog) are not merely faster but architecturally necessary.
Block 5 — Reduce + atomic commit
Teaches: at-least-once execution plus an atomic rename = exactly-once effect
The problem. A task may run more than once — after a crash, or speculatively, or because the coordinator lost track of it. The output must nevertheless appear exactly once. This block is where at-least-once execution becomes exactly-once effect, and the mechanism is one syscall.
@block(5, "Reduce + atomic commit", "at-least-once execution plus an atomic rename = exactly-once effect")
def b5(s, show):
D = tempfile.mkdtemp(prefix="h06-")
def do_reduce(pairs):
agg = defaultdict(int)
for k, v in pairs: agg[k] += v
return sorted(agg.items())
def commit(result, rid, attempt):
tmp = os.path.join(D, f"part-{rid}.attempt{attempt}.tmp")
with open(tmp, "w") as f:
for k, v in result: f.write(f"{k}\t{v}\n")
os.replace(tmp, os.path.join(D, f"part-{rid}")) # ATOMIC
return os.path.join(D, f"part-{rid}")
if show:
splits = s["split"](TEXT, 8)
mo = [s["partition"](s["combine"](s["do_map"](sp)), s["R"]) for sp in splits]
red_in, _ = s["shuffle"](mo, s["R"])
r0 = do_reduce(red_in[0])
p1 = commit(r0, 0, attempt=1)
p2 = commit(r0, 0, attempt=2) # a duplicate/speculative task
print(f" reducer 0 produced {len(r0)} keys, e.g. {r0[:2]}")
print(f" attempt 1 and attempt 2 both committed -> one file: "
f"{p1 == p2 and os.path.exists(p1)}")
print(f" no .tmp files left behind: "
f"{not any(f.endswith('.tmp') for f in os.listdir(D))}")
print(" write-temp-then-rename is why a duplicated task is harmless. Same")
print(" trick as P03's segment flush and P07's checkpoint: make the state")
print(" transition and the position advance ATOMIC.")
return {"do_reduce": do_reduce, "commit": commit, "D": D}
Reading the implementation
write to part-N.attemptK.tmp
rename to part-N # atomic
rename(2) on POSIX is atomic within a filesystem: any observer sees either the
old name or the new one, never a partial state. Two attempts of the same task
therefore produce one file, and whichever finishes last wins — harmlessly, because
they contain the same bytes (the task is deterministic).
This is the same primitive as P04's SSTable flush, P03's segment commit, and P07's checkpoint. Once you see it, "exactly-once" stops being mysterious: the guarantee is never about delivery, it is about a pointer moving atomically.
The test in the block — commit attempt 1, commit attempt 2, assert one file and
no leftover .tmp — is the whole contract in three lines.
What the numbers say
Output:
reducer 0 produced 2 keys, e.g. [('lazy', 400), ('quick', 400)]
attempt 1 and attempt 2 both committed -> one file: True
no .tmp files left behind: True
write-temp-then-rename is why a duplicated task is harmless. Same
trick as P03's segment flush and P07's checkpoint: make the state
transition and the position advance ATOMIC.
Beyond the toy
The property does not hold on object stores, and this is a significant real-
world gap. S3 has no atomic rename; a "rename" is a copy plus a delete, which is
neither atomic nor cheap for large objects. The consequences are the entire
history of Hadoop's FileOutputCommitter v1 vs v2, the S3A committers, and
eventually table formats (Iceberg, Delta Lake, Hudi) that put a real atomic commit
— a single-object metadata pointer swap or a database transaction — on top of the
object store. The lesson generalises: when the substrate lacks the primitive you
need, the design problem becomes building it.
Two more requirements this toy skips: fsync the file and its parent directory
before the rename is durable (the most-forgotten line in this pattern), and
cleaning up .tmp files from failed attempts, which otherwise accumulate silently.
Block 6 — Stragglers and backup tasks
Teaches: job time is a MAXIMUM, and maxima behave badly
The problem. A job finishes when its slowest task finishes. Maxima behave badly, and this block quantifies exactly how badly — which is the argument for a mechanism that looks wasteful.
@block(6, "Stragglers and backup tasks", "job time is a MAXIMUM, and maxima behave badly")
def b6(s, show):
def job(ntasks=200, nworkers=20, frac=0.0, mult=1, backup=False, trials=400):
out = []
for _ in range(trials):
t = [10.0 * (mult if rng.random() < frac else 1.0) for _ in range(ntasks)]
if backup: t = [min(x, 20.0) for x in t]
w = [0.0] * nworkers
for x in sorted(t, reverse=True):
i = w.index(min(w)); w[i] += x
out.append(max(w))
return statistics.fmean(out)
if show:
base = job()
print(f" {'scenario':<22}{'completion':>12}{'vs ideal':>10}{'w/ backup':>12}")
for frac, mult, lbl in ((0.0,1,"no stragglers"),(0.01,10,"1% at 10x"),
(0.05,10,"5% at 10x"),(0.01,50,"1% at 50x")):
a = job(frac=frac, mult=mult); b = job(frac=frac, mult=mult, backup=True)
print(f" {lbl:<22}{a:>10.1f}s{a/base:>9.2f}x{b:>10.1f}s")
print(" two tasks in two hundred inflate the job 4.5x. Backup tasks recover")
print(" nearly all of it. That is MapReduce section 3.6, generated not quoted.")
return {"job": job}
Reading the implementation
The simulation assigns tasks to workers with longest-processing-time-first scheduling (a good approximation) and takes the makespan. Then the same with backup tasks, modelled as capping any task at 2× the normal duration — which is what launching a duplicate on another machine achieves in expectation.
The reason this is worth simulating rather than reasoning about is that the intuition is wrong. A 1% straggler rate sounds negligible; over 200 tasks it means two tasks are slow with near-certainty, and if they are 10× slow the job is 4.5× longer. The arithmetic of maxima is not the arithmetic of means.
What the numbers say
Output:
scenario completion vs ideal w/ backup
no stragglers 100.0s 1.00x 100.0s
1% at 10x 112.5s 1.13x 108.7s
5% at 10x 148.6s 1.49x 110.0s
1% at 50x 451.0s 4.51x 108.5s
two tasks in two hundred inflate the job 4.5x. Backup tasks recover
nearly all of it. That is MapReduce section 3.6, generated not quoted.
Beyond the toy
- Naive speculation makes it worse. The LATE paper's central finding: a scheduler that speculates on "tasks furthest behind" keeps relaunching tasks on the slow machines that caused the problem, consuming capacity and slowing the job. LATE speculates on estimated finish time instead, which requires progress-rate estimation and is genuinely harder.
- Causes of stragglers, roughly in order of frequency: data skew (block 2), hardware degradation (a disk with remapped sectors), resource contention from a co-tenant, GC pauses, and network hot spots. Only the first is fixable by the programmer, which is why the framework must handle the rest.
- The general form is Dean & Barroso's Tail at Scale: hedged requests, tied requests, micro-partitioning (many more partitions than machines, so load balances naturally and hot partitions can migrate), and selective replication. The same arithmetic appears in P10's multiple comparisons and P15's fan-out — one distribution, three contexts.
Assembly note
The three-kill-schedule table is the only correctness criterion that matters for a batch framework, and it is worth being explicit about why byte-identical output is achievable at all: map and reduce are pure functions of their input. That restriction is the price, and retry, speculation, rescheduling and recomputation are all things it buys. Allow one map function to read the clock, a random seed, or an external service, and every one of those mechanisms becomes unsound simultaneously — the output becomes a function of the failure schedule, which is exactly what the test detects.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSix blocks = a batch framework. Run word count, then kill workers.\n")
def run_job(text, M=8, R=4, kill_workers=0, use_combiner=True):
splits = s["split"](text, M)
map_out, retries = [], 0
for i, sp in enumerate(splits):
attempt = 0
while True:
attempt += 1
# a "worker failure" loses the task's output; the coordinator retries
if i < kill_workers and attempt == 1:
retries += 1; continue
pairs = s["do_map"](sp)
if use_combiner: pairs = s["combine"](pairs)
map_out.append(s["partition"](pairs, R)); break
red_in, moved = s["shuffle"](map_out, R)
result = {}
for r in range(R):
for k, v in s["do_reduce"](red_in[r]): result[k] = v
return result, retries, moved
truth = {}
for w in TEXT: truth[w] = truth.get(w, 0) + 1
print(f" {'run':<28}{'keys':>7}{'matches oracle':>17}{'retries':>9}")
for lbl, kw, comb in (("clean", 0, True), ("3 workers killed", 3, True),
("6 workers killed", 6, True), ("no combiner", 0, False)):
res, ret, moved = run_job(TEXT, kill_workers=kw, use_combiner=comb)
print(f" {lbl:<28}{len(res):>7}{str(res == truth):>17}{ret:>9}")
print("\n Identical output under every fault schedule. That is the ONLY")
print(" correctness criterion that matters, and it is possible because map and")
print(" reduce are pure functions of their input -- the framework may re-run")
print(" any task, anywhere, at any time.")
print("\n The restriction IS the feature. Let map read shared mutable state and")
print(" retry, speculation and rescheduling all become unsound at once.")
print("\n Built: splitting -> map/partition -> shuffle -> combiner -> reduce +")
print(" atomic commit -> straggler simulation.")
print(" Missing, on the project page: real processes and sockets (m3-m5),")
print(" coordinator checkpointing (m10), data locality (m11), the LATE")
print(" scheduler (E5, where naive speculation makes things WORSE), and E12 --")
print(" the hand-written comparison that is the report's thesis.")
Output:
Six blocks = a batch framework. Run word count, then kill workers.
run keys matches oracle retries
clean 7 True 0
3 workers killed 7 True 3
6 workers killed 7 True 6
no combiner 7 True 0
Identical output under every fault schedule. That is the ONLY
correctness criterion that matters, and it is possible because map and
reduce are pure functions of their input -- the framework may re-run
any task, anywhere, at any time.
The restriction IS the feature. Let map read shared mutable state and
retry, speculation and rescheduling all become unsound at once.
Built: splitting -> map/partition -> shuffle -> combiner -> reduce +
atomic commit -> straggler simulation.
Missing, on the project page: real processes and sockets (m3-m5),
coordinator checkpointing (m10), data locality (m11), the LATE
scheduler (E5, where naive speculation makes things WORSE), and E12 --
the hand-written comparison that is the report's thesis.
The design space
MapReduce is one point in the space of batch execution engines, and the axis that matters is how much of the dataflow graph the system can see at once.
| Engine | Model | Materialisation | Why it wins / loses |
|---|---|---|---|
| MapReduce | two fixed stages | every stage to disk | Maximum fault tolerance, maximum IO; a multi-stage job is many jobs |
| Dryad / Tez | arbitrary DAG | configurable | Avoids re-reading between stages |
| Spark | DAG of RDDs, lineage-based recovery | memory by default, spill to disk | Recomputes lost partitions from lineage instead of replicating |
| Flink (batch) | pipelined dataflow | streaming between operators | Lower latency, but a failure restarts more |
| MPI / collectives | explicit communication | none | Fastest, no fault tolerance at all |
The progression is a single trade being renegotiated: materialise more → recover cheaply but run slowly; pipeline more → run fast but recover expensively. MapReduce sits at the extreme materialising end because it was designed for clusters of thousands of unreliable commodity machines where a job would certainly experience failures during its run.
Spark's contribution was noticing that if the transformation is deterministic and its inputs are still available, you can recover by recomputation rather than replication — which is only sound because of the same purity restriction that makes MapReduce's task retries safe. The assembly's byte-identical output under three kill schedules is the demonstration that the restriction buys the property.
The shuffle is the system
Everything expensive in a batch engine is the all-to-all. With \(M\) mappers and \(R\) reducers there are \(M \times R\) transfers, and the data crosses the network exactly once but touches disk two to four times.
| Cost | Where it lands |
|---|---|
| Map output write | local disk, sequential |
| Sort / spill | disk, possibly multiple merge passes |
| Network transfer | bisection bandwidth |
| Reduce-side merge | disk + memory |
Two hardware facts set the shape:
- Bisection bandwidth. In an oversubscribed tree topology (4:1 is common), cross-rack bandwidth is a fraction of intra-rack. Data locality — scheduling a map task on a node that already holds its input block — was worth so much in the original paper precisely because of this. Modern Clos/fat-tree fabrics are much closer to non-blocking, which is why locality matters less than it did and disaggregated storage (S3) became viable.
- Sequential vs random IO. Sort-based shuffle writes one partitioned, sorted file per map task; hash-based shuffle writes \(R\) small files per map task. At \(M = R = 1000\) that is a million files and a million random writes, which is why Spark moved from hash to sort-based shuffle by default.
The combiner in block 4 attacks this directly, and its 1000× reduction there is not unrepresentative: for associative aggregations the map-side combine is usually the single largest win available.
Stragglers, and the arithmetic of maxima
A job finishes when its slowest task finishes, and maxima behave badly. If each task independently has probability \(p\) of being slow, a job of \(n\) tasks is slow with probability \(1 - (1-p)^n\): at \(p = 0.01\) and \(n = 100\) that is 63%. This is the same arithmetic as P10's multiple-comparisons block and P15's tail composition — one distribution, three contexts.
Block 6 measures it: 1% of tasks running 10× slow inflates the job 4.5×, and backup tasks recover nearly all of it. The counter-intuitive part, documented in the LATE paper, is that naive speculation makes things worse on heterogeneous clusters: a scheduler that speculates on "tasks furthest behind" will keep re-launching tasks on the slow machines that caused the problem, consuming capacity. LATE speculates on estimated finish time instead.
Dean & Barroso's The Tail at Scale generalises: hedged requests, tied requests, micro-partitioning (many more partitions than machines, so load balances naturally), and selective replication of hot partitions.
Advanced algorithms and alternatives
- External sorting is the core primitive: \(O(N \log_{M/B} (N/B))\) IOs in the external-memory model, and the reason the shuffle is sort-based.
- Distributed joins: broadcast (small side fits in memory), shuffle-hash, sort-merge, and skew-aware variants that split hot keys. Skew in a join key is the single most common cause of one reducer running for hours.
- Approximate aggregation: HyperLogLog for distinct counts, t-digest for quantiles, count-min for heavy hitters — all mergeable, which is exactly the property that lets them be computed in a combiner.
- Ring all-reduce is the ML analogue of the shuffle: bandwidth-optimal, \(2(N-1)/N\) of the data per node, and the reason data-parallel training scales where a parameter-server design bottlenecks on the server's NIC.
- Push vs pull shuffle: Magnet/Cosco push map output to reducers to convert many small random reads into few large sequential ones — a 2020s revisiting of exactly the problem the 2004 paper had.
Hardware: what changed since 2004
The original design assumed 100 Mb/s--1 Gb/s networks, spinning disks, and frequent machine failure. Today: 25--100 Gb/s NICs, NVMe at GB/s, and object storage where compute and data are deliberately separated. Consequences:
- Locality matters less; disaggregation (S3 + stateless compute) won because the network stopped being the bottleneck relative to disk.
- Memory is large enough that many "big data" jobs fit on one machine. A modern server with 1--2 TB of RAM makes a single-node engine (DuckDB, Polars) faster than a cluster for a large fraction of real workloads — the "COST" critique (Configuration that Outperforms a Single Thread).
- CPU became the bottleneck rather than IO, which is why columnar formats (Parquet, ORC), vectorised execution, and compression are now where the wins are.
How this connects to the rest of the track
- P07 is this system with the batch boundary removed; its checkpoint is this project's atomic commit applied continuously.
- P05 provides the fault-tolerant coordinator a real implementation needs.
- P04's sequential-write discipline and this project's sort-based shuffle are the same response to the same storage physics.
- P01's MoE routing and tensor-parallel all-reduce are the same all-to-all pattern at NVLink speed.
- P10 shares the maxima arithmetic that makes stragglers and false positives both inevitable at scale.
Failure modes at scale
- Skew. One key with 10% of the rows makes one reducer take 10% of the total work alone; no amount of parallelism helps. Detect with a per-partition size histogram before blaming the cluster.
- Small files. Millions of tiny outputs destroy the next job's planning time and the namenode/metadata store.
- Speculation feedback loops, as above.
- Non-deterministic map functions — a UDF that reads the clock, a random seed, or an external service — silently break the retry guarantee. The output is then a function of the failure schedule, which is exactly what the assembly tests for.
- Coordinator as a single point of failure, which is why m10 on the project page is coordinator checkpointing.
Primary sources
- Dean & Ghemawat, MapReduce: Simplified Data Processing on Large Clusters (OSDI 2004) — §3.6 on backup tasks is what block 6 reproduces.
- Zaharia et al., Resilient Distributed Datasets (NSDI 2012).
- Zaharia et al., Improving MapReduce Performance in Heterogeneous Environments (LATE, OSDI 2008).
- Dean & Barroso, The Tail at Scale (CACM 2013).
- McSherry, Isard & Murray, Scalability! But at what COST? (HotOS 2015).
- Ghemawat, Gobioff & Leung, The Google File System (SOSP 2003) — the storage assumptions the whole design rests on.
Running it
python3 handson/h06_mapreduce.py # every block, then the assembly
python3 handson/h06_mapreduce.py --block 3 # just block 3 and its prerequisites
python3 handson/h06_mapreduce.py --quiet # the assembly only
What to do with this
Replace the in-process tasks with real subprocesses and a coordinator that detects a dead worker by timeout rather than by return value. The moment tasks can fail silently rather than by raising, the design pressure changes completely --- and that is the environment the original paper was written for.
Milestones, experiments, readings and exit criteria for this project: P06 — MapReduce-Style Framework.
P07 — Stream-Processing System
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P07 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 88 hours · Weeks 76–83 · Stage 3 · Go
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Compute continuously over an input that never ends, with events that arrive late, out of order, and sometimes twice |
| 2. Constraints | Unbounded input. Bounded memory. Events carry their own timestamps, which do not match arrival order. Failures happen mid-computation |
| 3. Naive design | Yours. People invent: a loop over a queue with a dict of counters; tumbling windows keyed on arrival time; "just batch every 5 minutes" |
| 4. Predicted failure | Your design has a correctness bug involving late events. Find it on paper. Then find the memory leak |
| 5. Minimal implementation | Partitioned log, offsets, one consumer, one stateful operator, processing-time windows |
| 6. Correctness | Output identical to a batch job over the same events — the reference oracle |
| 7. Instrumentation | Consumer lag, watermark position, state size, checkpoint duration, records dropped as late |
| 8. Baseline | A batch job over the same data. It is the ground truth |
| 9. Bottleneck | Is throughput bound by deserialisation, state access, checkpointing, or the downstream sink? |
| 10. Hypothesis | Checkpoint interval has an optimum trading steady-state overhead against recovery time. Predict it |
| 11. Modification | Incremental checkpointing |
| 12. Experiment | Interval sweep × failure rate × state size |
| 13. Failure analysis | Every duplicate or lost output traced to an interleaving |
| 14. Report | Why "exactly once" is a claim about effects, not about delivery |
Why This Project Matters
Batch processing has an easy definition of correct: the output is a function of the input, and the input is finite. Streaming has no such luxury. When the input never ends, you must decide what "the answer" means before you can compute it, and every streaming system is a set of answers to that question.
A watermark is not a feature. It is a formal admission that you are giving up on completeness in exchange for the ability to emit a result at all. Once you have built one, you can never again read "exactly-once semantics" on a marketing page without asking exactly-once with respect to what?
You already operate Kinesis and Flink-class systems. This project turns your operational knowledge into design knowledge, and it does so on the specific question that matters most in a news-recommendation context: how fresh can the system be, and what does freshness cost in correctness?
Prerequisites
- P05 complete — the replicated log becomes the event log; the fault injector is reused
- P06 helpful — the scheduler and worker pool generalise
- P04 helpful — operator state is an LSM in every real system
Duration and Size
Medium, 88 hours, 8 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Partitioned append-only log with offsets, producer/consumer, consumer groups with rebalancing, one stateful operator, tumbling processing-time windows, at-least-once with offset commit. | 40 |
| Standard | + event time, watermarks, allowed lateness, sliding and session windows, checkpointing with recovery, exactly-once effects via idempotent/transactional sink, backpressure, incremental checkpoints. | 88 |
| Extension | Chandy–Lamport aligned barrier snapshots across a multi-operator DAG (the Flink design), with a measured comparison against the simple stop-the-world approach. | +35–50 |
Central Technical Questions
- What is the difference between event time and processing time, and what breaks if you use the wrong one? Give a concrete wrong answer your system would produce.
- What is a watermark, formally? It is an assertion — state it as one, including what happens when the assertion is false.
- Why is exactly-once delivery impossible, and what is achievable instead?
- What does a checkpoint have to capture for recovery to be correct? The answer includes something people forget: input positions.
- What is backpressure, and why is dropping data sometimes the correct response?
- How large can operator state get, and what happens when it exceeds memory?
Event time vs processing time — the concrete failure
A user reads an article at 23:58 on a phone that is offline. The phone syncs at 00:07. Your "articles read per day" job, keyed on processing time, attributes that read to the wrong day. Run it on a month of data and every daily number is wrong by the size of the offline-sync population — a systematic bias, not noise, and one that correlates with exactly the users you care about.
Keyed on event time, the read lands in the right day, but now the 23:00–00:00 window cannot be closed at 00:00, because more events may still arrive. You have traded a wrong answer for a late answer. That trade is the whole subject of this project, and the watermark is the dial.
Architecture
Write your naive design first.
producers ──► ┌──────────── log ─────────────┐
│ partition 0: [0][1][2][3]... │ append-only, offset-addressed
│ partition 1: [0][1][2]... │ retention by time or size
│ partition 2: [0][1][2][3]... │
└──────────────┬───────────────┘
│ consumer group: one partition → one consumer
▼
┌──────────────── operator ─────────────────┐
│ deserialize ─► assign event time │
│ ─► watermark tracker (min over partitions)│
│ ─► window assigner (tumbling/sliding/session)
│ ─► state store (keyed, LSM-backed) │
│ ─► trigger on watermark ─► emit │
│ ─► allowed lateness ─► late-firing / drop│
└───────────────┬───────────────────────────┘
▼
sink (idempotent by (window,key) OR transactional)
│
checkpoint: {operator state, input offsets, watermarks} ──► durable store
Watermarks, stated precisely
A watermark \(W(t)\) emitted at processing time \(t\) is the assertion:
No event with event time \(\le W(t)\) will arrive after this point.
Three consequences follow immediately, and they are the entire design space:
- The assertion can be wrong. An event arriving with event time below the current watermark is late. The system must have a policy: drop it, fire the window again with a correction, or route it to a side output. There is no fourth option and no option that is free.
- A watermark is a heuristic in any real system. Perfect watermarks require knowing the maximum possible delay, which you do not. Typical implementations use \(W(t) = \max(\text{observed event time}) - \delta\) for a chosen \(\delta\), and \(\delta\) is a completeness/latency dial with no correct setting.
- Watermarks must be the minimum across all inputs. One idle partition holds the watermark back forever and every window stalls. This is the single most common operational failure in streaming systems, and it has a standard fix (idle-partition detection with a timeout) that itself weakens the guarantee.
Your E3 measures the \(\delta\) frontier: completeness (fraction of events included in their correct window) against latency (time from window end to result emission).
Exactly-once, disassembled
The phrase means three different things and only two are achievable:
| Claim | Achievable? | Why |
|---|---|---|
| Exactly-once delivery | No | The two-generals problem. A sender cannot know whether a lost ack means the message arrived |
| Exactly-once processing | Yes, internally | Checkpoint state and input offsets atomically, and replay from the checkpoint |
| Exactly-once effect at the sink | Yes, with conditions | Requires an idempotent sink (keyed upsert) or a transactional one (two-phase commit with the checkpoint) |
The mechanism for the middle row: a checkpoint must contain operator state and the input offsets that produced it, written atomically. Recovering means restoring state and rewinding the input to the checkpointed offsets. If you checkpoint state and offsets separately, you get either duplicates or gaps depending on the order — which is a bug you should deliberately introduce and observe in E9.
This is the same idea as P06's atomic rename and P03's atomic segment flush. Three projects, one pattern: make the state transition and the position advance atomic. Say that in the report.
Showcase — Do This Before You Start
W4 · walkthroughs/w4_watermarks.py · ~45 minutes
A working miniature of this project: the same events counted three ways, and the completeness/latency frontier with its dead zone.
cd walkthroughs && python3 w4_watermarks.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Partitioned append-only log: segments, offsets, retention | 8 | Reuses P04's segment machinery; offsets survive restart |
| 2 | Producer/consumer with offset commit; consumer groups + rebalancing | 8 | A consumer joining/leaving redistributes partitions with no loss |
| 3 | Operator runtime: source → operator → sink, with backpressure signalling | 8 | A slow sink slows the source rather than growing a queue |
| 4 | Keyed state store backed by P04's engine | 8 | State survives operator restart |
| 5 | Processing-time tumbling windows | 5 | Correct against a batch oracle for in-order data |
| 6 | Event time + watermark tracker (min across partitions, idle detection) | 10 | Watermark advances correctly with one idle partition |
| 7 | Sliding and session windows | 8 | Session gap semantics correct with out-of-order input |
| 8 | Allowed lateness: late firing, side output, drop policy | 6 | All three policies work and are measured |
| 9 | Checkpointing: state + offsets, atomically | 10 | Recovery produces batch-identical output |
| 10 | Exactly-once effects: idempotent sink and a transactional sink | 8 | Duplicate delivery causes no duplicate effect |
| 11 | Incremental checkpointing | 6 | Checkpoint duration decoupled from total state size |
| 12 | Experiments + report | 3 | All rows filled |
Concepts To Study
- Log abstraction: append-only, offset-addressed, retention; why a log is the right primitive for both messaging and state
- Partitions and keys: partitioning determines parallelism and ordering guarantees; ordering is per-partition only
- Consumer groups and rebalancing; the stop-the-world rebalance problem
- Event time, processing time, ingestion time — three clocks, all different
- Watermarks: the assertion, heuristic generation, the min-across-inputs rule, idle sources
- Window types: tumbling, sliding, session; and why session windows need merging
- Triggers and allowed lateness; the Dataflow model's separation of what, where, when, how
- State backends: in-memory vs LSM-backed; keyed vs operator state
- Checkpointing: stop-the-world vs Chandy–Lamport barriers; aligned vs unaligned
- Delivery semantics: at-most-once, at-least-once, exactly-once effects
- Backpressure: credit-based flow control vs blocking; why unbounded queues are the enemy
- Consumer lag as the primary operational metric
Primary-Source Readings
Budget: 12 hours.
| Reading | Why | Hours |
|---|---|---|
| Akidau, T. et al. The Dataflow Model. VLDB 2015 | The single most important paper here. The what/where/when/how decomposition | 3 |
| Akidau, T. Streaming 101 / 102. O'Reilly, 2015 | The clearest explanation of watermarks in print | 2 |
| Carbone, P. et al. Lightweight Asynchronous Snapshots for Distributed Dataflows. arXiv:1506.08603, 2015 | Flink's barrier snapshotting; the extension | 2 |
| Chandy, K. M., Lamport, L. Distributed Snapshots: Determining Global States of Distributed Systems. ACM TOCS 3(1), 1985 | The original algorithm underneath it | 1.5 |
| Kreps, J., Narkhede, N., Rao, J. Kafka: a Distributed Messaging System for Log Processing. NetDB 2011 | The log as a primitive | 1 |
| Kreps, J. The Log: What every software engineer should know about real-time data's unifying abstraction. 2013 | The conceptual essay; changes how you see storage generally | 1 |
| Zaharia, M. et al. Discretized Streams. SOSP 2013 | The micro-batch alternative, and its honest trade-offs | 1.5 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Event time vs processing time | on data with a realistic delay distribution | Quantify the daily-attribution error from the example above |
| E2 | Out-of-order severity | delay distribution: none / exponential / heavy-tailed | Completeness vs watermark delay |
| E3 | Watermark delay δ | {0, 1 s, 10 s, 60 s, 5 min} | The completeness/latency frontier. Predict both endpoints |
| E4 | Allowed lateness policy | drop / late-fire / side-output | Correctness and output volume |
| E5 | Checkpoint interval | {1 s, 10 s, 60 s, 300 s} × failure rate | Steady-state overhead vs recovery time: predict the optimum |
| E6 | Incremental vs full checkpoint | state ∈ {10 MB, 1 GB, 10 GB} | Where does incremental start to matter? |
| E7 | Recovery time | vs state size and vs checkpoint age | Predict linear in both; check the constants |
| E8 | Consumer lag under burst | 10× input spike for 60 s | Recovery time to zero lag; predict from throughput headroom |
| E9 | Duplicate delivery | at-least-once vs exactly-once sink | Count duplicate effects; must be zero for the latter |
| E10 | Backpressure | sink slowed 10× | Lag grows, memory does not. Verify the second part |
| E11 | Key skew | Zipfian keys | Per-partition lag imbalance |
| E12 | State size growth | session windows with no timeout | It grows without bound. Measure the leak, then fix it |
| E13 | Slow downstream consumer | one consumer in a group at 10× latency | Does it stall the group? |
E3 is the project's headline. Plot completeness (fraction of events counted in their true window) against emission latency, one point per δ. That curve is the streaming version of P02's recall/QPS curve, and it makes the same point: you choose an operating point on a frontier; there is no correct answer, only a stated one.
E12 is the trap worth falling into on purpose. Session windows with no timeout accumulate state for every key ever seen. Watch memory grow, then implement state TTL and watch it stop. Unbounded state is the production failure of streaming systems.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Throughput (events/s) | Sustained, not peak; state the state size |
| End-to-end latency p50/p95/p99 | Event time → result emitted. p99 is what SLOs are written against |
| Consumer lag | Records and seconds, per partition. The primary operational metric |
| Watermark lag | Wall clock minus watermark; distinct from consumer lag and often more informative |
| Completeness | Fraction of events included in their correct window |
| Late-event rate | By how late, as a distribution |
| Checkpoint duration and size | Full and incremental |
| Checkpoint overhead | % throughput lost to checkpointing |
| Recovery time | Failure to caught-up |
| State size | Per operator, over time — plot it; leaks are visible as slope |
| Duplicate effect count | Must be zero for exactly-once |
| Memory under backpressure | Must be bounded. Plot it |
Correctness Tests
- Batch equivalence. For any finite prefix of the stream, streaming output with a sufficiently large watermark delay equals the batch job's output. This is the oracle; everything else is a special case of it.
- Idempotent recovery: kill and restart at 20 random points; final output unchanged.
- No lost events at any watermark setting — every event either lands in a window, fires late, or is explicitly counted as dropped. The three must sum to the input.
- Offset/state atomicity: after recovery, the state matches exactly the events before the committed offset.
- Window boundary correctness: an event exactly on a boundary lands in exactly one window. Test both boundaries.
- Session merge: overlapping sessions merge correctly when a late event bridges them — the hardest window semantics to get right.
- Watermark monotonicity: the watermark never goes backwards.
- Idle-partition handling: watermark advances when one partition is silent.
- Rebalance safety: no event processed twice, none skipped, across a group rebalance.
- Bounded memory under sustained backpressure.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Kill an operator mid-window | Recovers from checkpoint; output matches batch |
| Kill during a checkpoint | Old checkpoint still valid; no corruption |
| Duplicate every event | Exactly-once sink produces no duplicate effect |
| Reorder within a partition | Event time handles it up to δ; beyond δ they are late |
| Events 1 hour late | Policy applied and counted, not silently dropped |
| One partition idle for 10 minutes | Watermark still advances (with idle detection) |
| Sink unavailable for 60 s | Backpressure; bounded memory; no data loss |
| Consumer joins mid-stream | Rebalance without loss or duplication |
| Clock skew across nodes | Event-time results unaffected |
| State store disk full | Clean failure, recoverable |
| 10× input burst | Lag grows and recovers; no OOM |
Expected Difficulties
- Session windows are the hardest semantics in the project. A late event can bridge two existing sessions, requiring a merge and a retraction of previously emitted results. Budget real time for milestone 7.
- Watermark propagation across operators is subtle: each operator's output watermark is a function of its input watermarks and its own buffering. Get it wrong and windows fire early — silently.
- "Exactly once" will tempt you into over-claiming. Be precise in the report about which of the three claims you implemented and under what sink assumptions.
- Testing streaming is harder than testing batch because time is an input. Make the clock injectable from milestone 1 — every test drives time explicitly. Retrofitting this is a rewrite.
- State growth is silent until it is fatal. Plot state size in every experiment from the start.
- Backpressure that "works" by buffering is not backpressure. Test bounded memory explicitly (E10), not throughput.
Scope Boundaries
In scope: single-node or few-process, a partitioned log, one operator DAG of modest depth, event time and watermarks, three window types, checkpointing, backpressure, exactly-once effects.
Out of scope: a distributed scheduler with dynamic rescaling; SQL over streams; a query optimiser; multi-DAG multi-tenancy; a replicated log with consensus (P05 already did that — reuse or simulate); machine learning on streams; a web UI.
Deliverables
streamproc/— Go, with the log, runtime, windowing, and checkpointingREPORT.mdcentred on the E3 completeness/latency frontier- The batch-equivalence test harness — reusable and genuinely valuable
- Notebook entries for E3, E5, E9, E12
- A state-size-over-time plot for every experiment (the leak detector)
Exit Criteria
- Batch equivalence holds for all window types at sufficient watermark delay
- Recovery from ≥20 random kill points produces batch-identical output
- E3 complete: the completeness/latency frontier plotted across five δ values
- E5 complete: checkpoint-interval optimum identified and explained
- E9 complete: exactly-once effects verified with zero duplicates under duplicate delivery
- E10 complete: memory bounded under sustained backpressure, plotted
- E12 complete: unbounded state observed, then fixed with TTL, both measured
- Late-event policy implemented in all three variants and counted
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Chandy–Lamport barrier snapshots across a multi-operator DAG, compared against stop-the-world. Measure the throughput impact of alignment — and of unaligned checkpoints under backpressure, which is the modern Flink answer.
- Retractions: emit corrections when late data changes a previously emitted result, and handle the downstream consequences.
- Watermark-delay auto-tuning from the observed lateness distribution. A research direction.
- Streaming joins with two watermarks and state expiry on both sides.
Connections
Backward: P05's replicated log becomes the event log; the fault injector is reused. P04 backs the state store. P06 supplies the worker/scheduler patterns.
Forward:
- → P08/P09: real-time interaction ingestion; the EMA user profile is a stateful streaming operator, and computing it here rather than in a batch job changes the freshness/complexity trade materially
- → P15: the ingestion layer. The research question "how do storage and indexing choices affect recommendation freshness?" is answered largely here
References
- Akidau, T. et al. The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing. VLDB 8(12), 2015.
- Akidau, T. Streaming 101: The world beyond batch and Streaming 102. O'Reilly Radar, 2015.
- Carbone, P., Fóra, G., Ewen, S., Haridi, S., Tzoumas, K. Lightweight Asynchronous Snapshots for Distributed Dataflows. arXiv:1506.08603, 2015.
- Chandy, K. M., Lamport, L. Distributed Snapshots: Determining Global States of Distributed Systems. ACM TOCS 3(1), 1985.
- Kreps, J., Narkhede, N., Rao, J. Kafka: a Distributed Messaging System for Log Processing. NetDB 2011.
- Kreps, J. The Log: What every software engineer should know about real-time data's unifying abstraction. LinkedIn Engineering, 2013.
- Zaharia, M., Das, T., Li, H., Hunter, T., Shenker, S., Stoica, I. Discretized Streams: Fault-Tolerant Streaming Computation at Scale. SOSP 2013.
- Carbone, P. et al. Apache Flink: Stream and Batch Processing in a Single Engine. IEEE Data Engineering Bulletin 38(4), 2015.
- Abadi, D. J. et al. The Design of the Borealis Stream Processing Engine. CIDR 2005.
- Kleppmann, M. Designing Data-Intensive Applications, ch. 11. O'Reilly, 2017.
P07 hands-on — Stream processing, block by block
Event time, watermarks, and the accuracy/latency dial made explicit.
Source:
handson/h07_streaming.py--- run it withpython3 handson/h07_streaming.py
Full project spec: P07 — Stream-Processing System
The hardest idea in stream processing is that correctness is a parameter. A batch job is either right or wrong; a streaming job is right as of a certain completeness assumption, and the assumption is yours to choose.
This file builds the machinery that makes the choice explicit. Two clocks, window assignment that depends only on event time, watermarks as a falsifiable promise about completeness, and triggers that emit the same window repeatedly as more data arrives. The assembly then runs one pipeline under three policies --- dashboard, alerting, billing --- and shows the same code producing 93.6% accuracy in two seconds or 100% in a hundred.
The checkpoint block is what makes any of it survivable, and it is smaller than most people expect: state and input offset advance together, or not at all.
Contents
- Block 1 — Two clocks
- Block 2 — Windowing
- Block 3 — Watermarks
- Block 4 — Late data
- Block 5 — Triggers
- Block 6 — Checkpoint + replay
- Block 7 — The accuracy/latency frontier
- The assembly
- The design space
- Watermarks: what the promise costs
- State: the part nobody budgets for
- Exactly-once, precisely
- Advanced algorithms and data structures
- Hardware and operational reality
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Two clocks
Teaches: event time is the data's; processing time is the machine's
The problem. Streaming has two clocks, and almost every bug in the domain comes from conflating them. This block measures how badly they disagree, so the rest of the design has a number to work against.
@block(1, "Two clocks", "event time is the data's; processing time is the machine's")
def b1(s, show):
st = make_stream()
delays = [p - e for e, p, _ in st]
if show:
inv = sum(1 for i in range(1, len(st)) if st[i][0] < st[i-1][0])
print(f" {len(st)} events over a {600_000/1000:.0f}s event-time span")
print(f" delay: p50={statistics.median(delays)/1000:>6.2f}s "
f"p99={sorted(delays)[int(.99*len(delays))]/1000:>6.2f}s "
f"max={max(delays)/1000:>6.2f}s")
print(f" {inv}/{len(st)-1} adjacent pairs arrive out of EVENT-time order "
f"({100*inv/(len(st)-1):.0f}%)")
print(" There is no ordering to exploit. Any design that assumes 'mostly")
print(" sorted' fails on the 8% tail -- which is where the interesting")
print(" events live (mobile clients, retries, a partition healing).")
return {"stream": st}
Reading the implementation
- Event time is when the thing happened, carried in the record.
- Processing time is when the system saw it.
The generator uses a mixture — 92% of events with an exponential delay around 2 s, 8% with delays up to 90 s — because that is the shape real telemetry has. Mobile clients reconnect, retries fire, a partition heals and dumps a backlog. The tail is not noise; it is where the interesting events are, which is precisely why dropping it is a decision rather than an oversight.
Sorting by processing time before iterating is the honest simulation: the system never sees event-time order, only arrival order.
What the numbers say
Output:
20000 events over a 600s event-time span
delay: p50= 1.57s p99= 79.29s max= 89.95s
9875/19999 adjacent pairs arrive out of EVENT-time order (49%)
There is no ordering to exploit. Any design that assumes 'mostly
sorted' fails on the 8% tail -- which is where the interesting
events live (mobile clients, retries, a partition healing).
Roughly half of all adjacent pairs arrive out of event-time order. There is no "mostly sorted" property to exploit, and any design that assumes bounded disorder must state the bound and handle its violation — which is blocks 3 and 4.
Beyond the toy
A third clock exists and matters in practice: ingestion time, stamped when the record enters the system. It is monotonic (unlike event time) and stable across reprocessing (unlike processing time), which makes it the pragmatic choice when the source's event times are untrustworthy — and untrustworthy event times are common, because they come from client devices whose clocks are wrong.
The general principle: any timestamp originating outside your trust boundary can be arbitrary. Systems that key windows on client-supplied event time need a sanity clamp, or one device with a clock set to 2038 creates a window that never closes and a state entry that never expires.
Block 2 — Windowing
Teaches: assignment is a pure function of event time -- never of arrival
The problem. Window assignment must be a pure function of event time and nothing else. Get that right and reprocessing is deterministic; get it wrong and the same data produces different answers on every replay.
@block(2, "Windowing", "assignment is a pure function of event time -- never of arrival")
def b2(s, show):
W = 60_000
def assign(et, w=W): return int(et // w)
def tumbling(stream, w=W):
out = defaultdict(int)
for et, _, k in stream: out[(assign(et, w), k)] += 1
return out
truth = tumbling(s["stream"])
if show:
wins = sorted({w for w, _ in truth})
print(f" 60s tumbling windows -> {len(wins)} windows x 4 keys")
print(f" ORACLE counts (window 0..3, key a): "
f"{[truth[(w,'a')] for w in range(4)]}")
print(" Assignment uses ONLY event time, so a replay in a different arrival")
print(" order produces identical windows. That is the whole reason the")
print(" Dataflow model separates 'what window' from 'when to emit'.")
return {"W": W, "assign": assign, "truth": truth}
Reading the implementation
int(event_time // window_size) — assignment depends only on the record's own
timestamp, so it is independent of arrival order, of parallelism, of which
operator instance sees the record, and of when the job runs. Replaying a week of
history produces byte-identical windows to the original run.
That determinism is the foundation the entire Dataflow model rests on, and it is why the model separates:
- What is computed (the aggregation),
- Where in event time (windowing — this block),
- When results are emitted (triggers — block 5),
- How refinements relate (accumulating vs discarding).
Conflating "where" and "when" is the mistake that makes streaming systems hard to reason about, and micro-batch systems make it by construction — their emission schedule is their window boundary.
What the numbers say
Output:
60s tumbling windows -> 10 windows x 4 keys
ORACLE counts (window 0..3, key a): [499, 509, 539, 473]
Assignment uses ONLY event time, so a replay in a different arrival
order produces identical windows. That is the whole reason the
Dataflow model separates 'what window' from 'when to emit'.
Beyond the toy
The window zoo, and what each costs:
| Window | Boundaries | State | Complication |
|---|---|---|---|
| Tumbling | fixed, disjoint | one aggregate per window | none — this block |
| Sliding | fixed, overlapping | each record in \(\text{size}/\text{slide}\) windows | state multiplied by the overlap factor |
| Session | gap-defined, data-dependent | per-key, dynamic | a late event can merge two emitted windows |
| Global + trigger | none | unbounded without eviction | needs an explicit eviction policy |
Session windows are the genuinely hard case, and worth building for that reason: because boundaries depend on the data, a late event arriving in the gap between two sessions merges them — which means retracting two already-emitted results and emitting one. Every convenience the tumbling case allows breaks, and the accumulating-with-retractions mode stops being optional.
Block 3 — Watermarks
Teaches: a claim about completeness, and every claim can be wrong
The problem. A watermark is a claim about completeness: "no event older than \(T\) will arrive". It is the only mechanism that lets a system decide a window is finished, and every strategy for generating one is wrong in a different way.
@block(3, "Watermarks", "a claim about completeness, and every claim can be wrong")
def b3(s, show):
def perfect(stream):
"""Oracle: knows the true max delay. Correct, and uselessly slow."""
m = max(p - e for e, p, _ in stream)
return lambda pt: pt - m
def fixed(lag): return lambda pt: pt - lag
def heuristic(q=0.95, window=2000):
hist = []
def wm(pt, delay=None):
if delay is not None:
hist.append(delay)
if len(hist) > window: hist.pop(0)
if not hist: return pt
return pt - sorted(hist)[int(q * (len(hist)-1))]
return wm
if show:
st = s["stream"]; m = max(p-e for e,p,_ in st)
print(f" perfect watermark lag = {m/1000:>7.2f}s (max observed delay)")
for lag in (5_000, 20_000, 60_000):
miss = sum(1 for e,p,_ in st if p - lag > e) # events already 'late'
print(f" fixed lag {lag/1000:>4.0f}s -> "
f"{100*miss/len(st):>5.1f}% of events fall behind it")
print(" A watermark is a PROMISE: 'no more events before T'. Fixed lag makes")
print(" the promise cheaply and breaks it often; the oracle keeps it always")
print(" and emits 90s late. Neither is a bug -- it is the dial.")
return {"perfect": perfect, "fixed": fixed, "heuristic": heuristic}
Reading the implementation
Three generators, three failure modes:
perfect— an oracle that knows the true maximum delay. Always correct, emits 90 s late, and cannot exist in production because it requires seeing the future.fixed(lag)— subtract a constant. Cheap, and wrong for exactly the fraction of events whose delay exceeds the lag. The table quantifies that fraction.heuristic(q)— track observed delays, set the lag to the \(q\)th percentile. Adapts to changing conditions and gives no guarantee whatsoever — a distribution shift makes it confidently wrong.
The critical property: a watermark is a promise, and breaking it is a data-loss event, not an error. Nothing throws. Events behind the watermark are simply dropped or diverted, and the metric quietly becomes wrong. That silence is why measuring the drop rate (block 4) is mandatory rather than diagnostic.
What the numbers say
Output:
perfect watermark lag = 89.95s (max observed delay)
fixed lag 5s -> 15.0% of events fall behind it
fixed lag 20s -> 6.3% of events fall behind it
fixed lag 60s -> 2.8% of events fall behind it
A watermark is a PROMISE: 'no more events before T'. Fixed lag makes
the promise cheaply and breaks it often; the oracle keeps it always
and emits 90s late. Neither is a bug -- it is the dial.
Beyond the toy
- Per-partition watermarks and the minimum rule. With a partitioned source like Kafka, each partition is ordered, so its watermark is exact. The operator's watermark is the minimum across inputs — which is why an idle partition stalls the entire pipeline: the minimum never advances, no window ever fires, and the system looks like it has no traffic rather than like it has a bug. Idle detection is a required feature, not a refinement.
- Watermarks propagate through the DAG and a shuffle takes the min across all upstream instances, so one slow parallel instance holds back every downstream window.
- The watermark is not a heartbeat. A pipeline with no data cannot distinguish "nothing happened" from "the source is broken", which is why sources emit periodic watermark-only messages.
Block 4 — Late data
Teaches: you cannot have completeness AND latency; you choose a point
The problem. You cannot have completeness and low latency. This block makes the trade explicit and measures both sides of it — because the usual alternative is to pick a lag by intuition and never learn what it cost.
@block(4, "Late data", "you cannot have completeness AND latency; you choose a point")
def b4(s, show):
def run(stream, wmf, w, allowed=0):
acc, fired, dropped, late = defaultdict(int), {}, 0, 0
for et, pt, k in stream:
wm = wmf(pt)
win = int(et // w)
if (win + 1) * w <= wm - allowed:
dropped += 1; continue # too late even for the grace
if (win + 1) * w <= wm:
late += 1 # late but inside allowance
acc[(win, k)] += 1
for key in [kk for kk in acc if (kk[0]+1)*w <= wm - allowed and kk not in fired]:
fired[key] = acc[key]
for key in acc:
if key not in fired: fired[key] = acc[key]
return fired, dropped, late
if show:
st, w, truth = s["stream"], s["W"], s["truth"]
print(f" {'watermark':<26}{'dropped':>9}{'wrong windows':>15}{'max error':>11}")
for lbl, wmf, al in (("fixed 5s", s["fixed"](5_000), 0),
("fixed 20s", s["fixed"](20_000), 0),
("fixed 20s + 60s grace", s["fixed"](20_000), 60_000),
("perfect (oracle)", s["perfect"](st), 0)):
got, dr, _ = run(st, wmf, w, al)
bad = [k for k in truth if got.get(k, 0) != truth[k]]
err = max((truth[k]-got.get(k,0) for k in truth), default=0)
print(f" {lbl:<26}{dr:>9}{len(bad):>15}{err:>11}")
print(" A 60s grace period on the same watermark drops the error to zero")
print(" here, because it buys back the tail. It costs 60s of retained state")
print(" per window -- the memory bill for correctness, made explicit.")
return {"run": run}
Reading the implementation
Three dispositions for a record arriving behind the watermark:
- Drop — cheapest, and the metric is silently biased.
- Allowed lateness (grace) — keep window state alive for an extra interval and re-fire on late arrivals. Costs memory proportional to the grace period × open windows.
- Side output — divert to a separate stream for reconciliation. The audit trail, and the only option that lets you quantify what you dropped.
The measurement here reports dropped counts, wrong windows, and maximum error separately, and that separation matters: a policy can drop many events and still be nearly unbiased (if lateness is uncorrelated with the metric) or drop few and be badly biased (if it is not).
What the numbers say
Output:
watermark dropped wrong windows max error
fixed 5s 1057 40 35
fixed 20s 724 40 30
fixed 20s + 60s grace 12 10 2
perfect (oracle) 0 0 0
A 60s grace period on the same watermark drops the error to zero
here, because it buys back the tail. It costs 60s of retained state
per window -- the memory bill for correctness, made explicit.
A 60 s grace on the same watermark drives the error to zero here, and it costs 60 s of retained state per window. That is the memory bill for correctness made explicit — which is the entire point of the block.
Beyond the toy
- State size = open windows × keys × per-key state, and grace multiplies the first term. At high key cardinality this is the dominant cost of the whole job, and it is why state TTL is mandatory.
- The bias question is the one to ask. Dropping is acceptable when lateness is independent of the measured quantity — P15's dashboard is within 0.04 pp of truth for exactly that reason. When slow requests are the failing requests, the same pipeline becomes systematically optimistic, and it is biased in the direction that hides incidents. Measure the correlation before trusting a fast estimate.
Block 5 — Triggers
Teaches: one window, many answers over time -- early, on-time, late
The problem. One window, many answers over time. Triggers decouple when to emit from what window — and the downstream consequences are where the most common exactly-once bug in production lives.
@block(5, "Triggers", "one window, many answers over time -- early, on-time, late")
def b5(s, show):
def panes(stream, key, win, w, wmf, every=15_000):
out, cnt, nxt, closed = [], 0, None, False
for et, pt, k in stream:
if int(et // w) == win and k == key:
cnt += 1
if nxt is None: nxt = pt + every
wm = wmf(pt)
if nxt and pt >= nxt and not closed:
out.append(("EARLY", pt, cnt)); nxt = pt + every
if not closed and (win+1)*w <= wm:
out.append(("ON-TIME", pt, cnt)); closed = True
elif closed and out and cnt != out[-1][2]:
out.append(("LATE", pt, cnt))
return out
if show:
p = panes(s["stream"], "a", 3, s["W"], s["fixed"](20_000))
print(f" window 3, key 'a'; oracle = {s['truth'][(3,'a')]}")
print(f" {'pane':<10}{'proc time':>12}{'value':>8}{'vs oracle':>11}")
for kind, pt, v in p[:4] + ([("...", 0, 0)] if len(p) > 6 else []) + p[-2:]:
if kind == "...": print(" ..."); continue
print(f" {kind:<10}{pt/1000:>10.1f}s{v:>8}"
f"{v - s['truth'][(3,'a')]:>+11}")
print(f" {len(p)} panes emitted for ONE window. Downstream must therefore")
print(" handle refinement: either accumulate-and-retract, or make the sink")
print(" idempotent on (window, key). A sink that just += every pane is the")
print(" single most common exactly-once bug in production pipelines.")
return {"panes": panes}
Reading the implementation
Three pane kinds for one window:
- EARLY — speculative, emitted before the watermark passes. Gives low-latency approximate answers.
- ON-TIME — emitted when the watermark passes the window end.
- LATE — emitted on arrivals within the allowed lateness.
The refinement mode decides what a pane contains: accumulating (the full value so far, so later panes supersede earlier ones) or discarding (only the delta since the last pane, so panes sum).
And here is the bug. A sink that does += on every pane is correct under
discarding mode and wrong by a factor of the pane count under accumulating
mode. This block emits 27 panes for one window; a naive accumulating sink
multiplies that window's value by roughly 27. It is the single most common
exactly-once defect in production streaming, it is a sink bug in a pipeline that
is otherwise correct, and it produces plausible-looking numbers.
The two defences: make the sink idempotent on (window, key) — upsert rather
than increment — or use retractions, where each refinement emits a negative
for the previous value.
What the numbers say
Output:
window 3, key 'a'; oracle = 473
pane proc time value vs oracle
EARLY 195.9s 96 -377
EARLY 211.0s 204 -269
EARLY 226.0s 325 -148
EARLY 241.0s 440 -33
...
LATE 311.3s 472 -1
LATE 324.1s 473 +0
27 panes emitted for ONE window. Downstream must therefore
handle refinement: either accumulate-and-retract, or make the sink
idempotent on (window, key). A sink that just += every pane is the
single most common exactly-once bug in production pipelines.
Beyond the toy
Trigger design is a product decision expressed as configuration: a dashboard wants early panes every few seconds; a billing pipeline wants exactly one on-time pane and no speculation; an alerting system wants early panes and a guarantee that the on-time pane can retract an alert. Beam's trigger language exists because those three cannot be served by one policy.
Block 6 — Checkpoint + replay
Teaches: exactly-once is about EFFECTS, not deliveries
The problem. Exactly-once is about effects, not deliveries. This block shows the whole mechanism, and it is smaller than the phrase suggests.
@block(6, "Checkpoint + replay", "exactly-once is about EFFECTS, not deliveries")
def b6(s, show):
class Job:
def __init__(self): self.state, self.off, self.ckpt = defaultdict(int), 0, None
def consume(self, stream, upto, w):
while self.off < upto:
et, _, k = stream[self.off]; self.state[(int(et//w), k)] += 1
self.off += 1
def snapshot(self): self.ckpt = (dict(self.state), self.off)
def restore(self):
st, off = self.ckpt; self.state = defaultdict(int, st); self.off = off
if show:
st, w = s["stream"], s["W"]
clean = Job(); clean.consume(st, len(st), w)
crash = Job()
for i in range(1, 6): # 5 crashes at 20% intervals
crash.consume(st, int(len(st)*i/6), w); crash.snapshot()
crash.consume(st, int(len(st)*i/6) + 800, w) # work past the checkpoint
crash.restore() # ... then die
crash.consume(st, len(st), w)
print(f" clean run: {len(clean.state)} groups, {sum(clean.state.values())} events")
print(f" 5 crashes: {len(crash.state)} groups, {sum(crash.state.values())} events")
print(f" identical: {dict(clean.state) == dict(crash.state)}")
print(" Events after the checkpoint were processed TWICE by the machine and")
print(" ONCE by the world. The state and the input offset move together or")
print(" not at all -- the same atomic-rename discipline as P06's commit.")
return {"Job": Job}
Reading the implementation
The invariant is one sentence: state and input offset advance atomically, or not at all.
The test drives it hard — five crashes, each processing 800 records past the checkpoint before dying and restoring. Those records were processed twice by the machine and once by the world, and the final state is identical to a clean run. That is the entire content of "exactly-once processing", and it is why the term is misleading: delivery is at-least-once and always will be.
This is P06's atomic commit applied continuously rather than at a batch boundary, and P03's snapshot-plus-offset with the same correctness requirement — if the state lands and the offset does not, replay re-applies; if the offset lands and the state does not, data is lost.
What the numbers say
Output:
clean run: 40 groups, 20000 events
5 crashes: 40 groups, 20000 events
identical: True
Events after the checkpoint were processed TWICE by the machine and
ONCE by the world. The state and the input offset move together or
not at all -- the same atomic-rename discipline as P06's commit.
Beyond the toy
- Chandy–Lamport barriers are how this scales to a DAG of parallel operators. The source injects a barrier into the stream; each operator snapshots when barriers from all its inputs have aligned, then forwards the barrier. No global pause, and the resulting snapshot is a consistent cut.
- Unaligned checkpoints are the refinement that matters under backpressure: waiting for alignment can stall as long as the slowest path, so Flink can instead snapshot the in-flight buffers themselves — a larger checkpoint for a bounded checkpoint duration.
- The sink is the hard part. Exactly-once end to end requires the external
system to participate: two-phase commit (pre-commit on checkpoint, commit on
checkpoint-complete), or an idempotent sink keyed by
(window, key), or transactional writes (Kafka transactions). A pipeline with exactly-once processing and an at-least-once sink is an at-least-once pipeline. - Recovery time = state size ÷ restore bandwidth. A 1 TB state at 1 GB/s is ~17 minutes of downtime, which is a design parameter you choose (via incremental checkpointing and local recovery), not a number you discover during an incident.
Block 7 — The accuracy/latency frontier
Teaches: measure the dial you built, do not argue about it
The problem. Having built the dial, measure it. The frontier is the deliverable — not a chosen setting, but the curve that lets someone else choose.
@block(7, "The accuracy/latency frontier", "measure the dial you built, do not argue about it")
def b7(s, show):
if show:
st, w, truth = s["stream"], s["W"], s["truth"]
print(f" {'lag':>7}{'grace':>8}{'emit delay':>17}{'windows wrong':>15}"
f"{'events lost':>13}")
for lag, gr in ((2_000,0),(10_000,0),(30_000,0),(90_000,0),
(10_000,30_000),(10_000,90_000)):
got, dr, _ = s["run"](st, s["fixed"](lag), w, gr)
bad = sum(1 for k in truth if got.get(k,0) != truth[k])
lost = sum(truth[k]-got.get(k,0) for k in truth)
print(f" {lag/1000:>5.0f}s{gr/1000:>7.0f}s{(lag+gr)/1000:>15.0f}s"
f"{bad:>15}{lost:>13}")
print(" Emit delay is exactly lag+grace by construction; the only question is")
print(" what accuracy it buys. 90s of lag and 10s+90s of grace reach the same")
print(" correctness -- but the second keeps a speculative answer available")
print(" after 10s. That asymmetry is why triggers exist.")
return {}
Reading the implementation
Emit delay is lag + grace by construction, so the only empirical question is
what accuracy each point buys. Sweeping both parameters independently is what
exposes the asymmetry:
90 s of lag and 10 s + 90 s of grace reach identical correctness, but the second has a speculative answer available after 10 s. Same final accuracy, radically different product. That asymmetry is precisely why triggers exist, and it is invisible unless you sweep both axes rather than one.
What the numbers say
Output:
lag grace emit delay windows wrong events lost
2s 0s 2s 40 1283
10s 0s 10s 40 923
30s 0s 30s 40 553
90s 0s 90s 0 0
10s 30s 40s 40 369
10s 90s 100s 0 0
Emit delay is exactly lag+grace by construction; the only question is
what accuracy it buys. 90s of lag and 10s+90s of grace reach the same
correctness -- but the second keeps a speculative answer available
after 10s. That asymmetry is why triggers exist.
Beyond the toy
The mature deliverable is not a configuration value but a labelled dial: ship each setting with its measured error and its emit delay, so the consumer chooses. P15 does this — reporting the dashboard's bias alongside its latency — and it converts an engineering parameter into a product decision that someone other than the engineer can make correctly.
The reason this matters more in streaming than elsewhere: correctness is a parameter here. A batch job is right or wrong. A streaming job is right as of a completeness assumption, and if that assumption is implicit, nobody downstream knows what the number means.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSeven blocks = a stream processor. One pipeline, three policies.\n")
st, w, truth = s["stream"], s["W"], s["truth"]
def pipeline(policy, lag, grace):
got, dropped, late = s["run"](st, s["fixed"](lag), w, grace)
wrong = sum(1 for k in truth if got.get(k,0) != truth[k])
lost = sum(truth[k]-got.get(k,0) for k in truth)
return (policy, (lag+grace)/1000, wrong, lost,
100*(1-lost/sum(truth.values())))
print(f" {'policy':<24}{'emit delay':>12}{'wrong':>8}{'lost':>7}{'accuracy':>11}")
for row in (pipeline("dashboard (fast)", 2_000, 0),
pipeline("alerting (balanced)", 10_000, 30_000),
pipeline("billing (correct)", 10_000, 90_000)):
print(f" {row[0]:<24}{row[1]:>10.0f}s{row[2]:>8}{row[3]:>7}{row[4]:>10.2f}%")
print("\n Same code, same stream, three configurations. The dashboard is 93.6%")
print(" right in 2 seconds; billing is exact in 100. Note what the middle row")
print(" buys: 40s of delay recovers two thirds of the loss but ZERO of the")
print(" windows -- every window is still off by something. Neither is 'the correct")
print(" system' -- correctness is a parameter here, and the framework's job is")
print(" to make the parameter explicit instead of accidental.")
print("\n Everything above ran on ONE thread over a list. Add the checkpoint")
print(" block and it survives crashes; that is genuinely all exactly-once is.")
print("\n Built: two clocks -> windowing -> watermarks -> late data -> triggers")
print(" -> checkpoint/replay -> the frontier.")
print(" Missing, on the project page: real sources and sinks (m2), session")
print(" windows (m6), keyed state with TTL (m7), a Chandy-Lamport barrier")
print(" across parallel operators (m9-m10), and E7 -- the experiment where you")
print(" induce a partition and watch the watermark stall instead of advance.")
Output:
Seven blocks = a stream processor. One pipeline, three policies.
policy emit delay wrong lost accuracy
dashboard (fast) 2s 40 1283 93.58%
alerting (balanced) 40s 40 369 98.16%
billing (correct) 100s 0 0 100.00%
Same code, same stream, three configurations. The dashboard is 93.6%
right in 2 seconds; billing is exact in 100. Note what the middle row
buys: 40s of delay recovers two thirds of the loss but ZERO of the
windows -- every window is still off by something. Neither is 'the correct
system' -- correctness is a parameter here, and the framework's job is
to make the parameter explicit instead of accidental.
Everything above ran on ONE thread over a list. Add the checkpoint
block and it survives crashes; that is genuinely all exactly-once is.
Built: two clocks -> windowing -> watermarks -> late data -> triggers
-> checkpoint/replay -> the frontier.
Missing, on the project page: real sources and sinks (m2), session
windows (m6), keyed state with TTL (m7), a Chandy-Lamport barrier
across parallel operators (m9-m10), and E7 -- the experiment where you
induce a partition and watch the watermark stall instead of advance.
The design space
Streaming systems differ along two axes: how state is checkpointed and how records flow between operators. Everything else is consequence.
| System | Execution | Checkpointing | Latency floor | Exactly-once via |
|---|---|---|---|---|
| Storm (original) | record-at-a-time | none (at-least-once acks) | ms | nothing — at-least-once only |
| Spark Structured Streaming | micro-batch | batch boundary | ~100 ms--seconds | idempotent batch commit |
| Flink | pipelined, record-at-a-time | Chandy–Lamport barriers | ms | barrier snapshot + 2PC sinks |
| Kafka Streams | record-at-a-time, per-partition | changelog topic | ms | Kafka transactions |
| Materialize / Differential Dataflow | incremental view maintenance | timely dataflow frontiers | ms | deterministic incremental computation |
Micro-batching buys simplicity: the batch boundary is the checkpoint, and exactly-once reduces to "commit the batch atomically". It costs you a latency floor equal to the batch interval. Flink's barrier snapshot removes that floor by injecting markers into the stream that flow with the data, letting each operator snapshot when its barriers align — the same idea as a distributed cut in Chandy–Lamport (1985), applied to dataflow.
Unaligned checkpoints are the refinement worth knowing: under backpressure, waiting for barriers to align can stall the pipeline for as long as the slowest path, so Flink can instead snapshot the in-flight buffers themselves. It trades a larger checkpoint for a bounded checkpoint duration.
Watermarks: what the promise costs
A watermark is a claim that no event older than \(T\) will arrive. It is always either too early (you lose data) or too late (you add latency), and the blocks above measure both sides. The three generation strategies:
- Bounded out-of-orderness: \(wm = \max(\text{event time}) - \delta\). Simple, and wrong by exactly the tail beyond \(\delta\).
- Percentile / heuristic: track observed delays and set \(\delta\) to the \(p\)th percentile. Adapts, but has no guarantee.
- Source-derived: Kafka partitions are ordered, so a per-partition watermark is exact for that partition, and the operator's watermark is the minimum across inputs. This is why an idle partition stalls the whole pipeline — the min never advances — and why idleness detection is a required feature rather than a nicety.
The accuracy/latency frontier in block 7 is the honest way to present this: emit
delay is exactly lag + grace by construction, and the only question is what
accuracy it buys. Note the asymmetry the blocks expose — 90 s of lag and
10 s + 90 s of grace reach the same correctness, but the second has a speculative
answer available after 10 s. That is what triggers are for.
State: the part nobody budgets for
Streaming state is the working set of every open window and every keyed aggregate, and it grows with cardinality, not with throughput.
| Backend | Where | Access cost | Checkpoint |
|---|---|---|---|
| Heap / in-memory | JVM heap | ~100 ns | full copy; GC pauses scale with state |
| RocksDB | local SSD | 1--100 µs | incremental (SST files) |
| Changelog topic | Kafka | replay | log-structured, external |
RocksDB as the default state backend means P04 is running inside P07: an LSM holding window aggregates, with compaction, Bloom filters, and write amplification all applying exactly as that project measured them. A slow streaming job is very often a compaction-stalled state backend, and the diagnosis requires the storage-engine mental model, not the streaming one.
State size sets the recovery time too: a 1 TB keyed state restored at 1 GB/s is ~17 minutes of downtime after a failure. This is why incremental checkpointing and local recovery exist, and why "how long to recover" is a design parameter you choose rather than a number you discover during an incident.
Exactly-once, precisely
The phrase is a misnomer. Messages are delivered at-least-once; what is exactly once is the effect. Three ways to get there:
- Idempotent writes — key the sink by (window, key) so a replay overwrites rather than accumulates. Cheapest, and requires the sink to support it.
- Transactional sinks / two-phase commit — pre-commit on checkpoint, commit on checkpoint-complete. Correct, and couples pipeline latency to the sink's transaction latency.
- Deterministic replay from an offset — the state and the input offset advance atomically, exactly as block 6 demonstrates; the world sees the effect once because the effect is derived from state, not from deliveries.
The classic bug the blocks warn about is a sink that does += per pane. With
triggers, one window emits many panes; a naive accumulating sink multiplies the
result by the number of firings. This is the most common exactly-once defect in
production, and it is a sink bug in a system that is otherwise correct.
Advanced algorithms and data structures
- Sliding-window aggregation in \(O(1)\) amortised: the two-stack trick (DABA, Reactive Aggregator) maintains a running aggregate under insert-and-evict without recomputation, for any associative operator.
- Sketches are what make unbounded streams tractable: HyperLogLog (distinct), count-min (frequency), t-digest / DDSketch (quantiles), Bloom (membership, again P04). All are mergeable, which is what lets them survive windowing and repartitioning.
- Punctuations and frontiers. Timely Dataflow generalises watermarks to multi-dimensional timestamps with a frontier per operator, which is what makes correct iterative streaming (loops in the dataflow graph) possible at all.
- Session windows are the first window type whose boundaries depend on the data, so a late event can merge two already-emitted windows — which forces retractions into the model and breaks every convenience the tumbling case allowed.
- Stream–table duality: a table is the integral of a change stream; a stream is the derivative of a table. Kafka's log compaction and materialised views are the same object viewed from the two directions.
Hardware and operational reality
- Backpressure is the control system that keeps a pipeline stable; credit- based flow control propagates it upstream so the source slows rather than buffers unboundedly. A pipeline without it fails by OOM.
- Network and disk: state access is local SSD (µs), shuffle is network (µs--ms), and the source is usually Kafka (page-cache-resident sequential reads, which is why Kafka is fast and also why the page-cache trap in numbers.md §14 applies to benchmarking it).
- GC pauses on JVM engines with large heap state can exceed the watermark lag and cause spurious lateness — a hardware-adjacent failure that looks like a data problem.
How this connects to the rest of the track
- P06 is this system with a batch boundary; the checkpoint here is that project's atomic commit applied continuously.
- P04 is literally the state backend.
- P05 provides the coordinator, and its linearizability and this project's exactly-once are two routes to "applied once".
- P10 consumes these windowed metrics; block 6 of P15 measures the bias a fast watermark introduces into an error rate.
- P09's event-time reasoning is the same clock discipline applied to simulation.
Failure modes at scale
- Watermark stall from an idle partition — the pipeline goes quiet and nothing fires, which looks like zero traffic rather than a bug.
- State leak: a keyed aggregate with unbounded key cardinality and no TTL grows until the job dies. Always set a TTL, and alert on state size.
- Checkpoint timeout under backpressure, where alignment cannot complete because a channel is blocked — hence unaligned checkpoints.
- Reprocessing skew: replaying a week of history pushes event time forward far faster than processing time, so windows fire in bursts and downstream sinks see 1000× normal write rate.
- Correlated lateness. The assembly's benign result — a 2 s dashboard within 0.04 pp of truth — holds only because lateness is independent of the metric. When slow requests are the failing ones, the same pipeline becomes systematically optimistic, and the error is in the direction that hides incidents.
Primary sources
- Akidau et al., The Dataflow Model (VLDB 2015) — the what/where/when/how framing this project follows.
- Carbone et al., Lightweight Asynchronous Snapshots for Distributed Dataflows (Flink barriers, 2015).
- Chandy & Lamport, Distributed Snapshots (TOCS 1985).
- Murray et al., Naiad: A Timely Dataflow System (SOSP 2013).
- Kreps, The Log: What every software engineer should know about real-time data's unifying abstraction (2013) — stream–table duality.
- Tangwongsan et al., General Incremental Sliding-Window Aggregation (VLDB 2015).
Running it
python3 handson/h07_streaming.py # every block, then the assembly
python3 handson/h07_streaming.py --block 3 # just block 3 and its prerequisites
python3 handson/h07_streaming.py --quiet # the assembly only
What to do with this
Add a session window. It is the first window type whose boundaries depend on the data, so a late event can merge two windows that were already emitted --- which forces retractions into the design rather than leaving them optional. Every comfortable assumption from the tumbling-window case breaks, and the exercise is the fastest way to understand why the Dataflow model separates windowing from triggering.
Milestones, experiments, readings and exit criteria for this project: P07 — Stream-Processing System.
P08 — End-to-End Recommendation System
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P08 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 66 hours · Weeks 84–89 · Stage 4 · Python
The bar is higher here. This is your professional domain. A project that would be a strong result in Stage 1 is a mediocre one in Stage 4. Score yourself against what a specialist would expect, not against what a newcomer would achieve.
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Given a user's history and a catalogue that turns over daily, choose \(k\) items they will engage with — where "engage" is a proxy for a thing you cannot measure |
| 2. Constraints | Cold items appear constantly (news). Cold users are the majority. Feedback is implicit, biased, and delayed. Latency budget is tens of milliseconds |
| 3. Naive design | Yours. Most people build: average the embeddings of clicked items, ANN search, return top-k |
| 4. Predicted failure | Predict what breaks first. Candidates: popularity collapse, duplicate stories, staleness, filter bubble, cold start |
| 5. Minimal implementation | Exactly the naive design above, measured |
| 6. Correctness | No already-seen item; no duplicates; every returned item exists and passes filters |
| 7. Instrumentation | Per-stage latency, candidate-set overlap, exposure distribution, freshness distribution |
| 8. Baseline | Four baselines: popularity, recency, content-similarity, random. Random is not a joke — it calibrates every other number |
| 9. Bottleneck | Is quality limited by retrieval recall, by ranking, or by the user representation? Design an experiment that separates them |
| 10. Hypothesis | EMA profile beats mean profile for users with drifting interests, above a drift rate you specify |
| 11. Modification | EMA with a swept decay rate |
| 12. Experiment | Decay sweep × user segment × drift rate |
| 13. Failure analysis | Which users got worse? Segment before concluding |
| 14. Report | Including the metric that improved while the system got worse |
Why This Project Matters
You build these professionally. So the value here is not "learn recommenders" — it is learn to evaluate one honestly, which is a genuinely different and rarer skill.
The central hazard: almost every accuracy metric can be improved by recommending popular items, because popular items are popular. A system that quietly collapses onto the head of the catalogue will show a rising NDCG and a falling product. The only defence is a metric suite that includes coverage, novelty, and exposure concentration alongside accuracy, and the discipline to report all of them every time.
The second reason is specific to news, and to you: a news recommender is a system where the catalogue turns over faster than the user model converges. That makes cold start the normal case rather than the exception, and it makes freshness a first-class metric rather than a tie-breaker. Most recommender literature assumes a stable catalogue and does not transfer.
Prerequisites
- P02 complete — retrieval calls your index, not a library's
- P01/P13 helpful — you can generate embeddings and reason about their geometry
- From
math.md: §Ranking Metrics (2 h), §Implicit Feedback and Bias (2 h)
Duration and Size
Medium, 66 hours, 6 weeks. Shorter than comparable projects because you start with domain knowledge. That is deliberate, and the exit bar compensates.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Item embeddings, mean user profile, ANN retrieval, score-and-sort ranking, seen-filtering, and the four baselines with a full metric suite. | 32 |
| Standard | + EMA profiles at multiple decay rates, hybrid retrieval, a feature-based reranker, freshness and diversity terms, near-duplicate detection, cold-start handling, calibration measurement. | 66 |
| Extension | A two-tower retrieval model trained on your interactions; or a bandit explore/exploit layer with measured regret. | +30–45 |
Central Technical Questions
- What is a user's interest, as a mathematical object? A point, a distribution, a set of points, a trajectory? Each choice makes different failures possible.
- How fast should a profile forget? Derive the relationship between decay rate and effective memory before you sweep it.
- Where does recall actually get lost — retrieval or ranking? Most teams optimise the wrong stage because they never separate them.
- What does diversity cost in accuracy, and is the trade favourable? Measure it; do not assume.
- How do you evaluate a recommender offline when the logs were produced by a different policy? This is the hardest question in the project and the one with no clean answer.
- Which of your improvements is just popularity bias in disguise?
The EMA profile, derived
An exponentially-weighted profile updates as \(u_t = \alpha x_t + (1-\alpha) u_{t-1}\), giving item \(i\) interactions ago a weight \(\alpha(1-\alpha)^i\). The half-life — interactions until an item's weight halves — is \(h = \ln(0.5)/\ln(1-\alpha)\):
| α | half-life (interactions) | effective window 1/α | weight on last 10 interactions |
|---|---|---|---|
| 0.02 | 34.31 | 50.0 | 18.3% |
| 0.05 | 13.51 | 20.0 | 40.1% |
| 0.10 | 6.58 | 10.0 | 65.1% |
| 0.20 | 3.11 | 5.0 | 89.3% |
| 0.30 | 1.94 | 3.3 | 97.2% |
| 0.50 | 1.00 | 2.0 | 99.9% |
At α=0.5 the profile is essentially "the last two things you clicked". At α=0.02 it is a slow-moving average that will not notice a genuine interest change for a month. The mean profile is the α→0 limit with equal weights, and it can never adapt at all.
Choose α from a stated assumption about how fast interests drift, then test that assumption. In P09 you will simulate populations with known drift rates and can finally check whether your chosen α was right — which is the single best reason to build the simulator.
Recall propagation through the pipeline
Retrieval-then-ranking is a funnel, and the funnel has a ceiling:
\[ \text{recall}_{\text{end-to-end}}@k \le \text{recall}_{\text{retrieval}}@K \]
If retrieval recall@K is 0.90, no ranker — however good — can exceed 0.90 end-to-end. This is obvious once stated and routinely ignored: teams spend quarters on ranking models while the retrieval stage silently caps them.
Your ANN index's recall from P02 is therefore a hard ceiling on your recommender's quality, and E5 measures exactly how much of it you are losing. That linkage between two of your own projects is one of the most satisfying measurements in the journey.
Popularity concentration
If item popularity follows a Zipf distribution with exponent α, the head's share of total engagement mass is:
| Zipf α | top 1% of items | top 10% of items |
|---|---|---|
| 0.5 | 9.4% | 31.1% |
| 0.8 | 30.0% | 57.1% |
| 1.0 | 53.0% | 76.5% |
| 1.2 | 75.1% | 90.3% |
At α=1.0, recommending only the top 1% of the catalogue captures 53% of all
engagement. A trivial bestseller list will beat a mediocre personalised model on
accuracy metrics, and it will do so while covering 1% of the catalogue. This is why
the popularity baseline is mandatory and why coverage is reported alongside NDCG,
always. tools/metrics.py computes both.
Architecture
interactions ──► profile builder ──┬─ mean vector
├─ EMA vector (α swept)
└─ multi-interest: cluster history, keep top-c centroids
│
catalogue ──► embeddings ──► ANN index (P02)│
▼
┌──── retrieval (candidates, K≈500) ────┐
│ ANN by profile · ANN per interest │
│ recency pool · popularity pool │
└──────────────┬────────────────────────┘
│ union, dedupe by id
▼
filters: seen · blocked · region · age
▼
ranking: score = w1·sim + w2·freshness
+ w3·quality − w4·redundancy
▼
diversity pass (MMR) ──► near-duplicate collapse ──► top-k
Freshness for news is not a tie-breaker. Model it explicitly, e.g. an exponential decay \(f(a) = e^{-a/\tau}\) in article age \(a\), with \(\tau\) a swept parameter. A relevance-only ranker on a news corpus surfaces last month's best article over today's good one, forever.
MMR (maximal marginal relevance) selects greedily: \(\arg\max_i [\lambda \cdot \text{rel}(i) - (1-\lambda)\max_{j \in S}\text{sim}(i,j)]\). One parameter, one line, and it produces the accuracy/diversity frontier of E7.
Showcase — Do This Before You Start
W6 · walkthroughs/w6_popularity.py · ~40 minutes
A working miniature of this project: a bestseller list beating every personalised recommender on NDCG by 2.5x while covering 0.5% of the catalogue.
cd walkthroughs && python3 w6_popularity.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Dataset: real or realistic articles with timestamps, categories, text; interaction log generator or real logs | 6 | Documented, with its popularity Zipf α measured |
| 2 | Embeddings + the P02 index over the catalogue | 4 | Index recall@K measured on this corpus |
| 3 | The four baselines: random, popularity, recency, content-similarity | 5 | All four scored on the full metric suite. Do this before anything clever |
| 4 | Mean-profile retrieval + ranking | 5 | Beats (or fails to beat) the baselines — report either way |
| 5 | EMA profiles, α ∈ {0.02…0.5} | 5 | Half-life table reproduced empirically |
| 6 | Full metric suite via tools/metrics.py | 4 | recall, precision, NDCG, MRR, coverage, novelty, Gini, ILD, freshness, calibration |
| 7 | Filters: seen, blocked, region, max age | 4 | Correct and measured for selectivity — links straight to P03's E3 |
| 8 | Near-duplicate detection and collapse | 6 | Measured duplicate rate before/after |
| 9 | Freshness term with swept τ | 4 | Freshness/accuracy frontier plotted |
| 10 | MMR diversity with swept λ | 5 | Accuracy/diversity frontier plotted |
| 11 | Feature-based reranker (GBDT over sim, freshness, popularity, category match) | 8 | Beats score-and-sort, or is honestly reported as not doing so |
| 12 | Cold-start paths: new user, new item | 5 | Both measured separately from the warm case |
| 13 | Experiments + report | 5 | All rows filled |
Concepts To Study
- Implicit feedback: clicks are not ratings; absence of a click is not a negative
- Position and presentation bias: the strongest predictor of a click is where the item was shown
- Two-stage retrieval/ranking and the recall ceiling
- User representation: mean, EMA, multi-interest clustering, sequence models
- Content-based vs collaborative vs hybrid, and why news is content-heavy
- Cold start: item cold start dominates in news; user cold start dominates in acquisition
- Freshness modelling: decay functions, and half-life as a product decision
- Diversity: MMR, determinantal point processes (know they exist), intra-list distance
- Filter bubbles and feedback loops: the recommender shapes the data that trains its successor
- Ranking metrics: NDCG's discount as a modelling assumption, MAP, MRR — and which fits a feed
- Beyond-accuracy metrics: coverage, novelty, serendipity, Gini
- Calibration: does the topic mix of recommendations match the user's history mix?
- Off-policy evaluation: IPS, capped IPS, doubly robust — enough to know why naive offline replay is biased
Primary-Source Readings
Budget: 10 hours.
| Reading | Why | Hours |
|---|---|---|
| Covington, P., Adams, J., Sargin, E. Deep Neural Networks for YouTube Recommendations. RecSys 2016 | Two-stage architecture; the "example age" feature is the freshness lesson | 1.5 |
| Steck, H. Calibrated Recommendations. RecSys 2018 | Why accuracy-optimal recommendations are miscalibrated, with a fix | 1.5 |
| Carbonell, J., Goldstein, J. The Use of MMR... SIGIR 1998 | MMR, in four pages | 0.5 |
| Chaney, A. J. B., Stewart, B. M., Engelhardt, B. E. How Algorithmic Confounding in Recommendation Systems Increases Homogeneity and Decreases Utility. RecSys 2018 | The feedback loop, simulated. Sets up P09 directly | 2 |
| Wu, F. et al. MIND: A Large-scale Dataset for News Recommendation. ACL 2020 | News-specific evaluation and its pitfalls | 1.5 |
| Cañamares, R., Castells, P. Should I Follow the Crowd? A Probabilistic Analysis of the Effectiveness of Popularity in Recommender Systems. SIGIR 2018 | Why popularity baselines are so hard to beat, analysed | 1.5 |
| Hu, Y., Koren, Y., Volinsky, C. Collaborative Filtering for Implicit Feedback Datasets. ICDM 2008 | The implicit-feedback formulation | 1.5 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Baselines | random / popularity / recency / content-sim | Predict the popularity baseline's NDCG. You will underestimate it |
| E2 | Profile type | mean vs EMA(α ∈ {0.02…0.5}) | Optimal α, overall and by user segment |
| E3 | Profile type × history length | × {1–3, 4–10, 11–50, 50+ interactions} | An interaction: predict where mean beats EMA |
| E4 | Candidate-set size K | {50,100,500,1000,5000} | Where does more retrieval stop helping? |
| E5 | Retrieval recall ceiling | ANN efSearch ∈ {16…256} | End-to-end quality vs retrieval recall — the linkage to P02 |
| E6 | Freshness weight τ | {1 h, 6 h, 24 h, 7 d, ∞} | Accuracy/freshness frontier |
| E7 | Diversity λ | {0, 0.3, 0.5, 0.7, 1.0} | Accuracy/ILD frontier; predict the elbow |
| E8 | Deduplication | on/off | Duplicate rate, and its effect on perceived quality |
| E9 | Reranker | score-and-sort vs GBDT | Lift, and which feature carries it |
| E10 | Cold start | new users (<3 interactions), new items (<1 h old) | Measured separately; the aggregate hides both |
| E11 | Popularity-bias audit | every configuration above | Coverage and Gini per config. Which "wins" are just head collapse? |
| E12 | Calibration | topic distribution of recs vs history | Predict the direction of the miscalibration |
| E13 | Latency budget | per stage: retrieval / filter / rank / diversify | Predict which stage dominates. It is usually not the one you think |
| E14 | Stability | same user, consecutive requests | How much does the list churn? Excessive churn is a real product bug |
E11 is the experiment that makes this a Stage 4 project. For every configuration you evaluate, record coverage and Gini next to NDCG. Then find at least one config where NDCG improved and coverage collapsed, and write it up. That is the honest- evaluation skill the project exists to build.
E5 is the linkage experiment. Sweep your ANN's efSearch, measure retrieval recall and end-to-end NDCG at each point, and plot them together. You will find a knee beyond which better retrieval buys nothing — that knee is your correct operating point, and almost nobody computes it.
Benchmarks and Metrics
All computed by tools/metrics.py. Report the whole suite for
every configuration. A table with only NDCG is a rejected result.
| Family | Metrics |
|---|---|
| Accuracy | recall@k, precision@k, NDCG@k, MRR — k always stated |
| Catalogue | coverage, Gini of exposure, novelty (bits) |
| List quality | intra-list diversity, duplicate rate, freshness distribution |
| Fit | calibration error between recommendation and history topic mixes |
| Serving | p50/p95/p99 end-to-end and per stage |
| Stability | rank correlation between consecutive requests for an unchanged user |
| Segmented | every accuracy metric, split by history length and by user activity decile |
Segmented reporting is not optional. An aggregate metric on a Zipf-distributed user population is dominated by heavy users. A change that helps the top decile and harms everyone else looks like a win in aggregate, and it is the most common way a recommender gets worse while its dashboard improves.
Correctness Tests
- Never recommend a seen item. Zero, across the full evaluation.
- No duplicate ids within one list.
- Every returned item exists and satisfies every active filter.
- Exactly k items returned, or fewer with an explicit reason logged.
- Determinism: same user state + same seed → same list.
- Metric implementations verified against hand-computed examples — the worked cases
in
metrics.py's demo. - No future leakage: an item published after the request time can never appear. This is the label-leakage bug of recommenders and it inflates offline metrics enormously.
- Empty history produces a sensible cold-start list, not a crash.
- Empty candidate set after filtering degrades gracefully.
- Profile update is order-dependent for EMA and order-independent for mean. Test both properties — getting this backwards is a real bug.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Embedding service returns zeros for 10% of items | Detected, not silently ranked at the origin |
| Item published in the future (clock skew) | Rejected by the freshness filter |
| User with 10,000 interactions | Profile build stays within the latency budget |
| Catalogue with 90% near-duplicates | Dedup keeps the list usable |
| All candidates filtered out | Graceful fallback, logged |
| Interaction log with duplicated events | Profile not double-weighted |
| Stale index (30 minutes behind) | Freshness degrades measurably — quantify it; it is P15's question |
| One category comprising 80% of the catalogue | Diversity and calibration must respond |
| Adversarial engagement (a bot clicking one topic) | Profile poisoning; measure how fast, then bound it |
Expected Difficulties
- You will beat the baselines by less than you expect, or not at all. See the Zipf table. This is the expected outcome and it is a legitimate result; report it and diagnose it rather than tuning until the number looks better.
- Offline evaluation is biased by the logging policy. Your logs record what the old system showed. Items never shown have no positives and score as failures. Read the off-policy material, state the bias in the report, and note that P09 exists precisely to escape it.
- Metric selection will tempt you. Decide the primary metric before running the sweep and write it down.
- Segment or be fooled. Always.
- Freshness and accuracy fight, and the accuracy metric always wins offline because offline data cannot express "this was stale when shown". Note the limitation explicitly; it is another P09 motivation.
- The domain-expertise trap: you will be tempted to jump to the sophisticated design. Build the naive one and measure it first — the whole method depends on it, and knowing the answer in advance is exactly when the discipline matters most.
Scope Boundaries
In scope: content-based and hybrid retrieval, profile construction, ranking, diversity, freshness, dedup, cold start, offline evaluation, a full metric suite.
Out of scope: training a large neural ranker; real user traffic; a production serving stack; multi-objective optimisation beyond a weighted sum; sequence models (extension); a feature store; real-time profile updates (P07 does that, and P15 integrates it).
Deliverables
recsys/— pipeline, four baselines, metric suite, sweep runnerREPORT.mdcentred on E11 (the popularity-bias audit) and E5 (the recall ceiling)- A comparison matrix: every configuration × every metric, one table. This is the portfolio artifact
- Notebook entries for E2, E5, E11
- A written statement of the offline-evaluation bias, and what P09 will do about it
Exit Criteria
- All four baselines implemented and scored on the full suite
- Your system compared against all four, with an honest verdict per metric
- E2 + E3 complete: EMA vs mean, swept, segmented by history length
- E5 complete: retrieval recall ceiling measured, knee identified
- E11 complete: at least one configuration documented where accuracy rose and coverage fell
- All metrics reported segmented, not only aggregate
- Cold-start paths measured separately for new users and new items
- Latency budget measured per stage
-
REPORT.mdwritten with a falsified prediction and the offline-bias limitation stated
Extension Ideas
- Two-tower retrieval trained on your interactions, compared against content embeddings at equal latency.
- Bandit exploration (Thompson sampling or LinUCB) with measured regret and coverage effects.
- Sequence model (GRU4Rec-style) as the profile, compared against EMA. The direct test of "is a learned sequence better than an exponential average", which is a question with a real answer in your domain.
- Off-policy evaluation with capped IPS, compared against naive replay — quantifying the bias you flagged.
Connections
Backward: P02 is retrieval and sets the recall ceiling. P03 provides filtered search and the selectivity regime. P01/P13 provide embeddings.
Forward:
- → P09: the simulator evaluates this pipeline under known ground truth, escaping offline bias. Keep the pipeline's interface stable and swappable
- → P10: A/B testing over the same pipeline
- → P15: the serving path of the integrated system
References
- Covington, P., Adams, J., Sargin, E. Deep Neural Networks for YouTube Recommendations. RecSys 2016.
- Steck, H. Calibrated Recommendations. RecSys 2018.
- Carbonell, J., Goldstein, J. The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries. SIGIR 1998.
- Chaney, A. J. B., Stewart, B. M., Engelhardt, B. E. How Algorithmic Confounding in Recommendation Systems Increases Homogeneity and Decreases Utility. RecSys 2018.
- Cañamares, R., Castells, P. Should I Follow the Crowd? SIGIR 2018.
- Hu, Y., Koren, Y., Volinsky, C. Collaborative Filtering for Implicit Feedback Datasets. ICDM 2008.
- Wu, F. et al. MIND: A Large-scale Dataset for News Recommendation. ACL 2020.
- Joachims, T., Swaminathan, A., Schnabel, T. Unbiased Learning-to-Rank with Biased Feedback. WSDM 2017.
- Ricci, F., Rokach, L., Shapira, B. (eds.) Recommender Systems Handbook, 3rd ed. Springer, 2022.
- Kunaver, M., Požrl, T. Diversity in recommender systems — A survey. Knowledge-Based Systems 123, 2017.
P08 hands-on — Recommender system, block by block
Three of four models lose to popularity. This page is about why.
Source:
handson/h08_recsys.py--- run it withpython3 handson/h08_recsys.py
Full project spec: P08 — End-to-End Recommendation System
This page is mostly a record of being wrong, which is why it is the most useful of the fifteen.
The plan was straightforward: build matrix factorisation, beat the popularity baseline, show the two-stage architecture. What the measurements said instead was that an under-regularised model scores below popularity, that copying word2vec's negative-sampling constant makes it worse still, that more training makes it worse rather than better, and that the whole question of how much a model can win is decided by a property of the data rather than by the model.
Every one of those findings is in the file, with the experiment that produced it
and, where a mechanism was proposed, a prediction tested against a measurement.
Block 6 is the clearest case: a theory about p(i|u)/q(i), a prediction it
implies, and a verdict of partially confirmed with the residual explained.
Contents
- Block 1 — Synthesise a world whose answer you know
- Block 2 — The split decides the number
- Block 3 — The baseline that embarrasses you
- Block 4 — BPR: rank, don't predict
- Block 5 — Regularisation: the cliff I fell off
- Block 6 — Negative sampling: a prediction, then a test
- Block 7 — Two-stage: the ceiling you cannot re-rank past
- Block 8 — How much headroom exists at all
- The assembly
- The design space
- What the blocks actually found
- Latency, memory and the embedding-table problem
- Advanced algorithms and evaluation
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Synthesise a world whose answer you know
Teaches: you cannot debug a recommender on real data
The problem. You cannot debug a recommender on real data, because every bug looks like "the model is bad". Synthesise a world whose ground truth you know, and the same symptom becomes diagnosable.
@block(1, "Synthesise a world whose answer you know", "you cannot debug a recommender on real data")
def b1(s, show):
NU, ev = generate()
if show:
n = sum(len(v) for v in ev.values()); c = counts(ev); top = np.sort(c)[::-1]
print(f" {n} interactions, {NU} users, {NI} items, true latent dim {D}")
print(f" head mass: top 1% of items = {100*top[:12].sum()/n:>4.1f}% of events;"
f" top 10% = {100*top[:120].sum()/n:.1f}%")
print(f" {int((c==0).sum())} items ({100*(c==0).mean():.0f}%) never touched -- "
"the cold tail is the default state")
print(" Ground truth U, V and the popularity mixture beta are KNOWN here. On")
print(" MovieLens they are not, so every bug looks like 'the model is bad'.")
return {"NU": NU, "ev": ev}
Reading the implementation
The generator has one parameter that matters: β, the fraction of choice mass that is pure popularity rather than taste. At β=0.5, half of every user's selections come from a global Zipf popularity distribution and half from their latent affinity. That single knob turns out to determine how much a model can possibly win (block 8), which is why it is a parameter rather than a constant.
Zipf with exponent ~0.9 is not arbitrary — it is the shape of essentially every observed catalogue interaction distribution, and it is why the head-mass numbers below look extreme but are conservative relative to real platforms.
Sampling without replacement per user is a deliberate simplification with a real consequence: as a user's history grows, the remaining candidates are drawn from a depleted head, so very active users have systematically harder held-out items. That artefact is visible if you sweep events-per-user, and it is the kind of thing that would be invisible on real data.
What the numbers say
Output:
28961 interactions, 800 users, 1200 items, true latent dim 8
head mass: top 1% of items = 13.8% of events; top 10% = 39.9%
0 items (0%) never touched -- the cold tail is the default state
Ground truth U, V and the popularity mixture beta are KNOWN here. On
MovieLens they are not, so every bug looks like 'the model is bad'.
The line that matters most is the cold tail: a large fraction of the catalogue is never touched. That is the default state of a catalogue, not an edge case, and any evaluation that only scores items with interaction history is measuring a subset of the problem.
Beyond the toy
Real datasets and what each hides: MovieLens is pre-filtered to users with ≥20 ratings, which removes the cold-start population that dominates production; Amazon reviews are extremely sparse and heavily biased toward extremes; Criteo is the only large public dataset with realistic feature sparsity. Every public benchmark is filtered in a way that makes the problem easier than production, which is why the offline/online gap is not a subtle effect.
Block 2 — The split decides the number
Teaches: a random split leaks the future and inflates everything
The problem. The single largest lever on a reported recommender metric is not the model. It is the split — and a random split leaks the future in a way that inflates every number.
@block(2, "The split decides the number", "a random split leaks the future and inflates everything")
def b2(s, show):
def split(ev, mode="temporal", seed=3):
rng = np.random.default_rng(seed)
tr, te = defaultdict(set), {}
for u, xs in ev.items():
j = len(xs) - 1 if mode == "temporal" else int(rng.integers(0, len(xs)))
te[u] = xs[j]; tr[u] = {x for k, x in enumerate(xs) if k != j}
return tr, te
tr, te = split(s["ev"], "temporal")
rtr, rte = split(s["ev"], "random")
if show:
print(" Two splits of the SAME data, one held-out event per user:")
print(" temporal — hold out each user's LAST event (honest)")
print(" random — hold out a uniformly chosen event (leaks the future)")
print(f" train sizes are identical ({sum(map(len,tr.values()))} vs "
f"{sum(map(len,rtr.values()))} pairs), so any difference in score is")
print(" purely the leak. Block 7 measures how big it is.")
return {"split": split, "tr": tr, "te": te, "rtr": rtr, "rte": rte}
Reading the implementation
Two splits of identical data, holding out one event per user:
- Temporal — hold out the user's last event. Honest: the model sees only the past, as it would in production.
- Random — hold out a uniformly chosen event. The training set then contains events that happened after the one being scored.
The training set sizes are identical, so any difference in score is purely the leak. That control is what makes block 7's inflation measurement meaningful.
The leak is subtler than "the model sees the answer". It sees the user's later behaviour, which reveals their preferences more completely than the past does, and it sees items in a co-occurrence pattern that includes the future. Both make the held-out item easier to predict for reasons that will not exist at serving time.
What the numbers say
Output:
Two splits of the SAME data, one held-out event per user:
temporal — hold out each user's LAST event (honest)
random — hold out a uniformly chosen event (leaks the future)
train sizes are identical (28161 vs 28161 pairs), so any difference in score is
purely the leak. Block 7 measures how big it is.
Beyond the toy
Even temporal-per-user is generous. The strictest and most realistic protocol is a global time split: pick a timestamp, train on everything before, evaluate on everything after. It is harsher because it includes users with no history at all (true cold start) and items that did not exist during training, and it is the only protocol that matches how the model will actually be deployed.
Other leaks that survive a correct split, in rough order of frequency: features computed over the full dataset (a popularity count that includes the test period), negative sampling from the future, hyperparameter tuning on the test set, and early stopping on the test set. The general rule is that anything derived from the whole dataset before splitting is a leak, and normalisation statistics are the classic offender.
Block 3 — The baseline that embarrasses you
Teaches: popularity is not a strawman
The problem. Popularity is not a strawman. It costs one
bincount, it requires no training, and it beats a substantial fraction of published models. Any system that does not compute this row does not know whether its model works.
@block(3, "The baseline that embarrasses you", "popularity is not a strawman")
def b3(s, show):
def evaluate(score, tr, te, k=20):
rec = ndcg = 0.0
for u in te:
order = [i for i in score(u) if i not in tr[u]][:k]
if te[u] in order:
rec += 1; ndcg += 1 / np.log2(order.index(te[u]) + 2)
return rec / len(te), ndcg / len(te)
pop_order = np.argsort(-counts(s["tr"]))
r, n = evaluate(lambda u: pop_order, s["tr"], s["te"])
if show:
print(f" popularity, temporal split: recall@20={r:.4f} ndcg@20={n:.4f}")
print(f" uniform random guessing: recall@20={20/NI:.4f}")
print(f" Popularity is {r/(20/NI):.0f}x random and costs one bincount. Any model")
print(" that does not clear this line has learned popularity and nothing")
print(" else -- and you only find out by computing this row.")
return {"evaluate": evaluate, "pop_order": pop_order}
Reading the implementation
Rank all items by interaction count, exclude what the user has seen, return the top k. That is the whole baseline.
The evaluation function is worth reading carefully because two details decide whether the numbers mean anything:
- Excluding already-seen items (
if i not in tr[u]). Without this, a recommender that returns the user's own history scores brilliantly and is useless. - The denominator is \(k\), so a method returning fewer than \(k\) results is correctly penalised — the failure mode of naive post-filtering (P03) and of over-aggressive business-rule filters.
NDCG is reported alongside recall because they answer different questions: recall asks "was it in the list", NDCG asks "how high". A change can improve one and worsen the other, and a system that only tracks one will ship it.
What the numbers say
Output:
popularity, temporal split: recall@20=0.1775 ndcg@20=0.0908
uniform random guessing: recall@20=0.0167
Popularity is 11x random and costs one bincount. Any model
that does not clear this line has learned popularity and nothing
else -- and you only find out by computing this row.
Popularity is over 10× random. That ratio, not the absolute number, is the reference point for everything that follows — and in the assembly it turns out to be the largest single jump in the entire system.
Beyond the toy
Stronger non-personalised baselines worth beating before claiming a model works: recent-popularity (a trailing window, which handles trends), item-item collaborative filtering (no training, often within a few percent of deep models on public data), and most-popular-in-the-user's-last-category. Dacrema et al. found that most published neural recommenders failed to beat properly tuned versions of these — which is a claim about evaluation discipline, not about neural networks.
Block 4 — BPR: rank, don't predict
Teaches: the loss must match the task, or the metric punishes you
The problem. The loss function decides what the model optimises, and the metric decides how it is graded. When they disagree, the model does exactly what you asked and scores near random.
@block(4, "BPR: rank, don't predict", "the loss must match the task, or the metric punishes you")
def b4(s, show):
NU, tr, te, ev = s["NU"], s["tr"], s["te"], s["evaluate"]
P, Q = train_bpr(NU, tr); Pm, Qm = train_mse(NU, tr)
if show:
rp, np_ = ev(lambda u: s["pop_order"], tr, te)
rm, nm = ev(lambda u: np.argsort(-(Pm[u] @ Qm.T)), tr, te)
rb, nb = ev(lambda u: np.argsort(-(P[u] @ Q.T)), tr, te)
print(f" {'model':<28}{'recall@20':>11}{'ndcg@20':>10}{'vs popularity':>15}")
for lbl, r, n in (("popularity", rp, np_), ("MF + squared error", rm, nm),
("MF + BPR (pairwise)", rb, nb)):
print(f" {lbl:<28}{r:>11.4f}{n:>10.4f}{r/rp:>14.2f}x")
print(" Same architecture, same d, same data, same epochs. Only the loss")
print(" differs, and squared error lands near random. It asks 'what score?';")
print(" BPR asks 'which of these two?' -- the question recall@20 grades.")
return {"P": P, "Q": Q}
Reading the implementation
Two losses, same architecture, same dimensions, same data, same epochs:
- Squared error treats every observed interaction as a target of 1.0. It asks "what score?", and with only positive examples the trivial optimum is to predict 1.0 for everything — which carries no ranking information at all.
- BPR samples an unobserved item \(j\) and maximises \(\log \sigma(\hat{x}{ui} - \hat{x}{uj})\). It asks "which of these two?", which is precisely the question recall@k grades.
The gradient makes the difference concrete. BPR's update is \(\sigma(-x_{uij}) \cdot \partial(\hat{x}{ui}-\hat{x}{uj})/\partial\theta\), so correctly ordered pairs produce almost no gradient and the model spends its capacity on the pairs it currently gets wrong. Squared error weights every observation equally regardless of whether the ranking is already right.
Implementation note: the minibatch version computes all three gradients from the
batch-start parameters (Pu, Qi, Qj are read before any add.at). That is
standard Hogwild-style staleness and is fine at this batch size; it is worth
knowing it is a deliberate approximation rather than an oversight.
What the numbers say
Output:
model recall@20 ndcg@20 vs popularity
popularity 0.1775 0.0908 1.00x
MF + squared error 0.0175 0.0137 0.10x
MF + BPR (pairwise) 0.1900 0.0867 1.07x
Same architecture, same d, same data, same epochs. Only the loss
differs, and squared error lands near random. It asks 'what score?';
BPR asks 'which of these two?' -- the question recall@20 grades.
Squared error lands near random — a 10× gap from the same architecture. This is the clearest demonstration in the track that the loss is not a detail.
Beyond the toy
The ranking-loss family, and when each applies: pointwise (predict a score — appropriate only for explicit ratings), pairwise (BPR, WARP, RankNet — the right default for implicit feedback), and listwise (ListNet, LambdaRank, softmax cross-entropy over the catalogue — optimises the whole list and is what sampled-softmax retrieval models use). WARP is worth knowing specifically: it samples until it finds a violating negative and weights the update by how many attempts that took, which approximates optimising precision@k directly.
Block 5 — Regularisation: the cliff I fell off
Teaches: an under-regularised MF scores BELOW popularity
The problem. This block exists because the first version of this file shipped with
reg=0.01and concluded that matrix factorisation cannot beat popularity. That was not a fact about matrix factorisation.
@block(5, "Regularisation: the cliff I fell off", "an under-regularised MF scores BELOW popularity")
def b5(s, show):
NU, tr, te, ev = s["NU"], s["tr"], s["te"], s["evaluate"]
if show:
rp, _ = ev(lambda u: s["pop_order"], tr, te)
print(f" popularity baseline = {rp:.4f}. Recall@20 as training proceeds:")
print(f" {'epochs':>8}{'reg=0.01':>11}{'reg=0.05':>11}")
for epochs in (10, 30, 60, 150):
row = []
for reg in (0.01, 0.05):
P, Q = train_bpr(NU, tr, epochs=epochs, reg=reg)
row.append(ev(lambda u: np.argsort(-(P[u] @ Q.T)), tr, te)[0])
print(f" {epochs:>8}{row[0]:>11.4f}{row[1]:>11.4f}"
f"{' <-- below baseline' if row[0] < rp else ''}")
print(" This block exists because the first version of this file shipped")
print(" reg=0.01 and concluded 'MF cannot beat popularity'. It was not a")
print(" fact about matrix factorisation; it was one hyperparameter. With 32")
print(" free parameters per user fit from ~35 events, the model memorises")
print(" the training set and pushes every unobserved item down -- including")
print(" the held-out one. MORE training makes it WORSE, which is the")
print(" signature of overfitting and not of a bad architecture.")
return {}
Reading the implementation
32 free parameters per user, fit from ~35 observations. The model has roughly as many degrees of freedom as data points, so it memorises the training set — and because BPR's objective pushes every unobserved item down, including the held-out one, memorisation actively harms the metric.
The signature is unmistakable once you know it: more training makes it worse. Recall peaks early and declines. That is overfitting, not a bad model class, and the fix is a hyperparameter rather than an architecture.
The general lesson is about attribution. A model that underperforms has many possible causes — wrong loss (block 4), wrong regularisation (here), wrong negative distribution (block 6), or genuinely insufficient signal (block 8) — and they are distinguishable only by controlled experiment. Concluding "MF does not work here" after one configuration is the most common analytical error in applied ML, and it is expensive because it redirects months of effort.
What the numbers say
Output:
popularity baseline = 0.1775. Recall@20 as training proceeds:
epochs reg=0.01 reg=0.05
10 0.1713 0.1737 <-- below baseline
30 0.1487 0.1900 <-- below baseline
60 0.1200 0.1725 <-- below baseline
150 0.1275 0.1675 <-- below baseline
This block exists because the first version of this file shipped
reg=0.01 and concluded 'MF cannot beat popularity'. It was not a
fact about matrix factorisation; it was one hyperparameter. With 32
free parameters per user fit from ~35 events, the model memorises
the training set and pushes every unobserved item down -- including
the held-out one. MORE training makes it WORSE, which is the
signature of overfitting and not of a bad architecture.
Beyond the toy
Regularisation in recommenders is unusual in one respect: the right amount depends on per-user support, which varies by orders of magnitude across the user base. A single global \(\lambda\) is simultaneously too strong for heavy users and too weak for light ones. The principled fixes are weighted regularisation (\(\lambda \cdot n_u\), as in ALS-WR), hierarchical Bayesian priors, or simply fewer dimensions for low-support users — which is what mixed-dimension embeddings do at industrial scale for a memory reason and get the regularisation benefit for free.
Block 6 — Negative sampling: a prediction, then a test
Teaches: BPR's optimum ranks by p(i|u)/q(i)
The problem. A theory, a prediction it implies, and a test that could refute it. This block is the 14-step loop compressed into one page — and the verdict is partially confirmed, which is more instructive than a clean win.
@block(6, "Negative sampling: a prediction, then a test", "BPR's optimum ranks by p(i|u)/q(i)")
def b6(s, show):
if show:
print(" Theory: BPR with negatives drawn from q converges to a ranking by")
print(" p(i|u)/q(i) -- the same importance-weighting that makes NCE work.")
print(" PREDICTION: sampling negatives proportional to popularity DIVIDES OUT")
print(" the popularity signal. If the truth is popularity-heavy that should")
print(" be catastrophic; if the truth has no popularity component (beta=0)")
print(" it should be harmless.\n")
print(f" {'beta (popularity mass)':<24}{'uniform q':>11}{'q ~ pop^0.75':>14}"
f"{'damage':>9}")
rows = {}
for beta in (0.5, 0.2, 0.0):
NU, ev_ = generate(beta=beta)
tr, te = s["split"](ev_, "temporal")
P1, Q1 = train_bpr(NU, tr) # uniform negatives
P2, Q2 = train_bpr(NU, tr, alpha=0.75) # q ~ popularity^0.75
po = np.argsort(-counts(tr))
a = s["evaluate"](lambda u: np.argsort(-(P1[u] @ Q1.T)), tr, te)[0]
b = s["evaluate"](lambda u: np.argsort(-(P2[u] @ Q2.T)), tr, te)[0]
pr = s["evaluate"](lambda u: po, tr, te)[0]
rows[beta] = (pr, a, b)
print(f" {beta:<24.1f}{a:>11.4f}{b:>14.4f}{b/a:>8.2f}x")
print(" VERDICT: partially confirmed. The damage shrinks monotonically as the")
print(" popularity mass falls (0.42x -> 0.68x -> 0.75x), exactly as predicted,")
print(" but it does not vanish at beta=0. A second mechanism is also present:")
print(" under q ~ pop, tail items are almost never sampled as negatives, so")
print(" their embeddings stay near random initialisation and rank spuriously.")
print(" word2vec uses pop^0.75 because there discounting frequency is the")
print(" GOAL. Copying the constant into a recommender inverts its purpose.")
s["beta_rows"] = rows
return {}
Reading the implementation
Theory. BPR with negatives drawn from distribution \(q\) converges to a ranking by \(p(i|u)/q(i)\) — the same importance weighting that makes noise- contrastive estimation work. The sampling distribution does not merely affect convergence speed; it changes what the optimum is.
Prediction. Sampling \(q \propto \text{pop}^{0.75}\) therefore divides out the popularity signal. If the truth is popularity-heavy (β=0.5) that should be catastrophic; if the truth has no popularity component (β=0) it should be harmless.
Test. Sweep β and measure the damage ratio.
Implementation detail worth noting: the sampler uses cumsum + searchsorted
rather than rng.choice(p=...). The latter is \(O(N)\) per call and made the
experiment take minutes; the former is \(O(\log N)\) and made it seconds. Same
distribution, verified by the results matching exactly.
What the numbers say
Output:
Theory: BPR with negatives drawn from q converges to a ranking by
p(i|u)/q(i) -- the same importance-weighting that makes NCE work.
PREDICTION: sampling negatives proportional to popularity DIVIDES OUT
the popularity signal. If the truth is popularity-heavy that should
be catastrophic; if the truth has no popularity component (beta=0)
it should be harmless.
beta (popularity mass) uniform q q ~ pop^0.75 damage
0.5 0.1900 0.0800 0.42x
0.2 0.2000 0.1350 0.68x
0.0 0.3362 0.2525 0.75x
VERDICT: partially confirmed. The damage shrinks monotonically as the
popularity mass falls (0.42x -> 0.68x -> 0.75x), exactly as predicted,
but it does not vanish at beta=0. A second mechanism is also present:
under q ~ pop, tail items are almost never sampled as negatives, so
their embeddings stay near random initialisation and rank spuriously.
word2vec uses pop^0.75 because there discounting frequency is the
GOAL. Copying the constant into a recommender inverts its purpose.
Verdict: partially confirmed. The damage shrinks monotonically as popularity mass falls (0.42× → 0.68× → 0.75×), exactly as predicted — but it does not vanish at β=0. A second mechanism is present: under \(q \propto \text{pop}\), tail items are almost never sampled as negatives, so their embeddings stay near random initialisation and can rank spuriously high.
A prediction that lands directionally but incompletely, with the residual explained, is a working model. One that lands exactly is usually a coincidence you have not noticed yet.
Beyond the toy
word2vec uses \(0.75\) because in language modelling, discounting frequency is the goal — you want "the" to stop dominating the objective. Copying the constant into a recommender inverts its purpose, because there popularity is signal rather than nuisance. This is the general failure mode the whole track keeps hitting: importing a result without importing the conditions that made it true.
The correct production practice is mixed sampling (some uniform, some popularity-proportional) with the ratio tuned, or logQ correction — explicitly subtracting \(\log q(i)\) from the logits so the bias is removed analytically rather than by choosing \(q\) carefully. The latter is what large-scale sampled-softmax retrieval models do.
Block 7 — Two-stage: the ceiling you cannot re-rank past
Teaches: stage 2 can only reorder what stage 1 returned
The problem. Serving cannot score a million items per request. The two-stage architecture is forced by latency — and it introduces a hard ceiling that is the first thing to check when quality is bad.
@block(7, "Two-stage: the ceiling you cannot re-rank past", "stage 2 can only reorder what stage 1 returned")
def b7(s, show):
NU, tr, te, P, Q = s["NU"], s["tr"], s["te"], s["P"], s["Q"]
pop_order = s["pop_order"]
def two_stage(u, C, k=20):
cands = [int(i) for i in pop_order[:C] if i not in tr[u]] # cheap, no user model
sc = P[u] @ Q[cands].T # expensive, per-user
return [cands[j] for j in np.argsort(-sc)][:k]
if show:
full = s["evaluate"](lambda u: np.argsort(-(P[u] @ Q.T)), tr, te)[0]
print(f" stage 1 = popularity top-C (one bincount, shared by all users)")
print(f" stage 2 = the BPR model, scoring only those C items\n")
print(f" {'C':>6}{'stage-1 ceiling':>17}{'after re-rank':>15}"
f"{'scores/user':>13}")
for C in (20, 50, 200, 600, NI):
ceil = sum(1 for u in te if te[u] in set(int(i) for i in pop_order[:C])) / len(te)
hit = sum(1 for u in te if te[u] in two_stage(u, C)) / len(te)
print(f" {C:>6}{ceil:>17.4f}{hit:>15.4f}{C:>13}")
print(f" single-stage full scan: {full:.4f} using {NI} scores per user")
print(" Re-ranking never exceeds the ceiling -- it is a hard cap, not a")
print(" tendency. Before blaming the ranker for a miss, check whether the")
print(" item was in the candidate set at all. This is also the join to P02:")
print(" swap popularity top-C for an HNSW query and the ceiling becomes")
print(" recall@C of the index, which is the number that project measured.")
return {"two_stage": two_stage}
Reading the implementation
Stage 1 is deliberately made cheap and user-independent (popularity top-C, one
bincount shared by all users) so the ceiling actually binds. Stage 2 is the BPR
model scoring only those C items.
The measurement is set up to make one point unmissable: re-ranking recall tracks the stage-1 ceiling exactly and can never exceed it. It is a hard cap, not a tendency. Doubling the ranker's quality changes nothing if the item was not in the candidate set.
The practical diagnostic that follows: before investigating a ranking model for missed recommendations, check whether the item was in the candidates. In production this means logging the candidate set, which is expensive and almost always worth it — the alternative is optimising a stage that is not the bottleneck.
What the numbers say
Output:
stage 1 = popularity top-C (one bincount, shared by all users)
stage 2 = the BPR model, scoring only those C items
C stage-1 ceiling after re-rank scores/user
20 0.1512 0.1512 20
50 0.2425 0.1850 50
200 0.4688 0.1875 200
600 0.7975 0.1900 600
1200 1.0000 0.1900 1200
single-stage full scan: 0.1900 using 1200 scores per user
Re-ranking never exceeds the ceiling -- it is a hard cap, not a
tendency. Before blaming the ranker for a miss, check whether the
item was in the candidate set at all. This is also the join to P02:
swap popularity top-C for an HNSW query and the ceiling becomes
recall@C of the index, which is the number that project measured.
Beyond the toy
- Multi-source retrieval is the standard production answer: union several candidate generators (ANN on embeddings, recent-popularity, same-category, collaborative-filtering neighbours, editorial), each covering a different failure mode of the others. Recall is the union's, and no single generator has to be good at everything.
- The connection to P02 is direct: swap popularity top-C for an HNSW query and the ceiling becomes recall@C of the index, which is exactly the number that project measures. The index's recall and the recommender's recall compose multiplicatively.
- Latency budget decides C. At 50 ms for ranking and ~50 µs per item scored by a deep model, C ≈ 1000. That arithmetic, not model quality, is what sets the candidate count in most production systems.
Block 8 — How much headroom exists at all
Teaches: the data, not the model, sets the ceiling
The problem. How much can a model possibly win? This block answers it with the same model and the same hyperparameters on three different worlds — and the answer is that the data, not the model, sets the ceiling.
@block(8, "How much headroom exists at all", "the data, not the model, sets the ceiling")
def b8(s, show):
if show:
print(f" {'beta (popularity mass)':<24}{'popularity':>12}{'MF+BPR':>9}"
f"{'model uplift':>14}")
for beta in (0.5, 0.2, 0.0):
pr, mf, _ = s["beta_rows"][beta]
print(f" {beta:<24.1f}{pr:>12.4f}{mf:>9.4f}{mf/pr:>13.2f}x")
print(" Identical model, identical hyperparameters, three worlds. When half")
print(" the choices are pure popularity there is a 1.07x model to be won;")
print(" when none are, there is a 6x one. Personalisation uplift is a")
print(" property of the DOMAIN. Before a quarter of modelling work, estimate")
print(" the head mass -- it tells you the size of the prize.")
return {}
Reading the implementation
Identical model, identical hyperparameters, three values of β. The only thing that changes is how much of user behaviour is popularity-driven versus taste-driven.
What the numbers say
Output:
beta (popularity mass) popularity MF+BPR model uplift
0.5 0.1775 0.1900 1.07x
0.2 0.1325 0.2000 1.51x
0.0 0.0550 0.3362 6.11x
Identical model, identical hyperparameters, three worlds. When half
the choices are pure popularity there is a 1.07x model to be won;
when none are, there is a 6x one. Personalisation uplift is a
property of the DOMAIN. Before a quarter of modelling work, estimate
the head mass -- it tells you the size of the prize.
1.07× when half the choice mass is popularity; 6.11× when none of it is. Personalisation uplift is a property of the domain, and it is measurable before any modelling work: estimate the head mass, and you have estimated the size of the prize.
That reframes the assembly's headline. The tuned model winning by 1.07× looks unimpressive until you see the same model win 6.11× on data with no popularity mass. The model was never the limiting factor.
Beyond the toy
Domains ordered roughly by available personalisation uplift: news and trending video (very low — recency and popularity dominate, and personalisation mostly helps with diversity), general e-commerce (moderate), music and long-tail retail (high — taste is idiosyncratic and catalogues are enormous), and dating or job matching (highest, and effectively unsolvable by popularity).
The practical recommendation: spend a day estimating head mass before a quarter
of modelling work. It is one bincount, it is the cheapest analysis in this
curriculum, and if the answer is that popularity explains most behaviour, the
correct engineering decision may be to ship the bincount and work on something
else.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = a recommender. One table, temporal split, honest rows.\n")
NU, tr, te, ev = s["NU"], s["tr"], s["te"], s["evaluate"]
P, Q = s["P"], s["Q"]
Pm, Qm = train_mse(NU, tr)
Pu, Qu = train_bpr(NU, tr, reg=0.01, epochs=150)
Pa, Qa = train_bpr(NU, tr, alpha=0.75)
base = ev(lambda u: s["pop_order"], tr, te)
rows = [("uniform random", (20/NI, 0.0)),
("popularity", base),
("MF, squared error", ev(lambda u: np.argsort(-(Pm[u]@Qm.T)), tr, te)),
("MF, BPR, reg=0.01, 150ep", ev(lambda u: np.argsort(-(Pu[u]@Qu.T)), tr, te)),
("MF, BPR, q ~ pop^0.75", ev(lambda u: np.argsort(-(Pa[u]@Qa.T)), tr, te)),
("MF, BPR, tuned", ev(lambda u: np.argsort(-(P[u]@Q.T)), tr, te))]
hit = sum(1 for u in te if te[u] in s["two_stage"](u, 600)) / len(te)
rows.append((" served two-stage, C=600", (hit, float("nan"))))
print(f" {'system':<28}{'recall@20':>11}{'ndcg@20':>10}{'vs popularity':>15}")
for lbl, (r, n) in rows:
nn = " -- " if n != n else f"{n:.4f}"
print(f" {lbl:<28}{r:>11.4f}{nn:>10}{r/base[0]:>14.2f}x")
rt = ev(lambda u: np.argsort(-(P[u]@Q.T)), tr, te)[0]
Pr, Qr = train_bpr(NU, s["rtr"]); rr = ev(lambda u: np.argsort(-(Pr[u]@Qr.T)),
s["rtr"], s["rte"])[0]
print(f"\n the same tuned model, scored on the RANDOM split: {rr:.4f} "
f"({rr/rt:.2f}x)")
print(" Nothing changed but which event was hidden. A number reported without")
print(" naming its split is not comparable to anything.")
print("\n Read the table top to bottom. The largest single jump is random ->")
print(" popularity, and it required no model at all. Three of the four MF rows")
print(" score BELOW that baseline -- one for the wrong loss, one for weak")
print(" regularisation, one for copying word2vec's sampling constant. The tuned")
print(" row wins by 1.07x -- an unimpressive number until block 8, where the")
print(" IDENTICAL model and hyperparameters win 6.11x on data with no popularity")
print(" mass. The model was never the limiting factor here; the domain was.")
print("\n Built: synthetic ground truth -> split discipline -> popularity ->")
print(" loss choice -> regularisation -> negative-sampling distribution ->")
print(" two-stage retrieval -> headroom analysis.")
print(" Missing, on the project page: real MovieLens/Amazon ingest (m1), item")
print(" and user features for cold start (m7), a served HNSW index in place of")
print(" popularity top-C (m9, reusing P02), latency budgets under load (m11),")
print(" and E9 -- the diversity/accuracy tradeoff where recall@20 goes DOWN and")
print(" the system gets better.")
Output:
Eight blocks = a recommender. One table, temporal split, honest rows.
system recall@20 ndcg@20 vs popularity
uniform random 0.0167 0.0000 0.09x
popularity 0.1775 0.0908 1.00x
MF, squared error 0.0175 0.0137 0.10x
MF, BPR, reg=0.01, 150ep 0.1275 0.0530 0.72x
MF, BPR, q ~ pop^0.75 0.0800 0.0352 0.45x
MF, BPR, tuned 0.1900 0.0867 1.07x
served two-stage, C=600 0.1900 -- 1.07x
the same tuned model, scored on the RANDOM split: 0.2300 (1.21x)
Nothing changed but which event was hidden. A number reported without
naming its split is not comparable to anything.
Read the table top to bottom. The largest single jump is random ->
popularity, and it required no model at all. Three of the four MF rows
score BELOW that baseline -- one for the wrong loss, one for weak
regularisation, one for copying word2vec's sampling constant. The tuned
row wins by 1.07x -- an unimpressive number until block 8, where the
IDENTICAL model and hyperparameters win 6.11x on data with no popularity
mass. The model was never the limiting factor here; the domain was.
Built: synthetic ground truth -> split discipline -> popularity ->
loss choice -> regularisation -> negative-sampling distribution ->
two-stage retrieval -> headroom analysis.
Missing, on the project page: real MovieLens/Amazon ingest (m1), item
and user features for cold start (m7), a served HNSW index in place of
popularity top-C (m9, reusing P02), latency budgets under load (m11),
and E9 -- the diversity/accuracy tradeoff where recall@20 goes DOWN and
the system gets better.
The design space
Recommenders are a pipeline, and each stage has a different cost model. Confusing the stages is the most common architectural mistake.
| Stage | Candidates | Latency budget | Model class | What it optimises |
|---|---|---|---|---|
| Retrieval | \(10^6\text{--}10^9 \to 10^2\text{--}10^3\) | 1--10 ms | two-tower, ANN (P02), popularity | recall@C |
| Filtering | \(10^3 \to 10^3\) | <1 ms | business rules, dedup, seen-list | correctness |
| Ranking | \(10^3 \to 10^2\) | 10--50 ms | GBDT, DLRM, cross-attention | pointwise/pairwise accuracy |
| Re-ranking | \(10^2 \to 10\) | 1--10 ms | diversity (MMR, DPP), calibration, business objectives | slate value |
The critical property, measured in block 7, is that stage \(n\) cannot exceed stage \(n-1\)'s ceiling. A ranker that is blamed for a miss usually never saw the item. This is the same hard-ceiling relationship as P03's planner and P02's recall@C, and it means the first diagnostic for any recommender quality problem is retrieval recall, not ranking metrics.
Model families, and what each is really for
| Family | Example | Strength | Cost |
|---|---|---|---|
| Neighbourhood | item-item CF | strong baseline, interpretable, no training | \(O(N^2)\) similarity, cold items excluded |
| Matrix factorisation | ALS, BPR | dense, fast, good with implicit feedback | no features, cold start fails |
| Factorisation machines | FM, FFM | feature interactions with shared embeddings | quadratic in fields |
| Deep + embeddings | DLRM, DCN, Wide&Deep | arbitrary features, cross terms | embedding tables dominate memory |
| Sequential | GRU4Rec, SASRec, BERT4Rec | models order and intent drift | expensive at serve time |
| Graph | PinSage, LightGCN | propagates signal to cold nodes | neighbourhood sampling is the bottleneck |
| Two-tower | YouTube retrieval | user and item encode independently → ANN-able | no cross features, weaker than a ranker |
The two-tower/ranker split is forced by latency: a cross-feature model must score each candidate against the user, which is \(O(C)\) forward passes; a two-tower model encodes the user once and the items offline, so retrieval is one ANN query. The architecture is a consequence of the latency budget, not of model quality.
What the blocks actually found
Three of the four models here lose to a bincount, and the reasons generalise:
- Loss mismatch. Squared error on implicit feedback scores near random, because it answers "what value?" while recall@k grades "which of these two?".
- Regularisation, not architecture. 32 free parameters per user fit from ~35 events memorises the training set and pushes every unobserved item down — including the held-out one. More training makes it worse, which is the signature of overfitting and is easy to mistake for a bad model class.
- The negative-sampling distribution changes what is learned. BPR with negatives from \(q\) converges to a ranking by \(p(i|u)/q(i)\), the same importance weighting that makes NCE work. Sampling \(q \propto \text{pop}^{0.75}\) therefore divides out the popularity signal. word2vec uses that exponent because discounting frequency is the goal there; copying the constant into a recommender inverts its purpose.
And the structural finding from block 8: uplift is a property of the domain. Identical model and hyperparameters win 1.07× when half the choice mass is popularity and 6.11× when none of it is. Estimating head mass costs one bincount and tells you the size of the prize before any modelling work.
Latency, memory and the embedding-table problem
Industrial recommenders are dominated by embedding tables, not by compute.
| Component | Typical size | Bound by |
|---|---|---|
| Embedding tables | 100 GB--10 TB | memory capacity and random-access bandwidth |
| MLP layers | 10--100 MB | compute |
| Serving p99 budget | 50--200 ms end to end | the whole pipeline |
An embedding lookup is a random gather: 100 lookups × 128 dims × 4 B = 51 KB, but scattered — so ~100 DRAM round trips at 121 ns ≈ 12 µs of pure latency for one example's features. Batching turns those into parallel gathers, which is why inference batch size matters as much here as in P01, for the same memory-level-parallelism reason as P02.
At scale the tables exceed one machine, and the standard answers are: hashing tricks (mod the ID space into a fixed table, accepting collisions), mixed- dimension embeddings (frequent IDs get more dimensions), quantisation (int8/int4 rows), and sharding across hosts — which turns a lookup into a network round trip and makes the recommender a distributed system.
Hardware note: TPUs have dedicated embedding hardware (SparseCore) precisely because the gather pattern is hostile to a systolic array, and NVIDIA's Merlin/HugeCTR exists for the same reason on GPU. This is one of the few ML workloads where the memory system, not the matmul unit, defines the chip.
Advanced algorithms and evaluation
- Sampled softmax with logQ correction — the retrieval-side analogue of the \(p/q\) argument above; without the correction the model learns popularity inverted.
- MMR and DPPs for diversity: a determinantal point process scores a set by the volume its item vectors span, which is the principled version of "do not show ten near-duplicates". Expect recall@k to go down when it works.
- Calibration: a ranker's scores must be probabilities if downstream business logic (bidding, thresholds) uses them. Isotonic regression or Platt scaling, checked with a reliability diagram, not with AUC.
- Position bias correction in training labels — see P09; training on raw clicks teaches the model the previous ranker's layout.
- Evaluation discipline: temporal split, not random. The blocks measure the inflation directly, and the same model scored on a random split looks better by a factor that has nothing to do with users.
- Offline/online gap. Offline metrics are computed on logged data collected under the old policy, so they systematically favour models that agree with it. This is why P10 exists and why offline recall is a hypothesis rather than a result.
How this connects to the rest of the track
- P02 is the retrieval index; its recall@C is this system's ceiling.
- P09 simulates the feedback loop this system creates once deployed.
- P10 is the only instrument that can tell you whether a model change helped users.
- P07 computes the real-time features and counters this consumes.
- P14 explains why embedding lookups are memory-bound and batching is the only lever.
Failure modes at scale
- Feedback loops — the model trains on data it generated (P09 block 4 makes this visible: 599 items alive → 96 in sixty days).
- Popularity collapse in the candidate generator, so the ranker only ever sees the head.
- Training/serving skew: a feature computed one way in the batch pipeline and another way at serve time. The single most common production defect, and the reason feature stores exist.
- Stale embeddings for new items — cold start is not an edge case; block 1 measures that a large fraction of the catalogue is never touched.
- Metric gaming: optimising CTR produces clickbait, optimising watch time produces long boring content. The metric is a proxy and the system will find its gap.
Primary sources
- Rendle et al., BPR: Bayesian Personalized Ranking from Implicit Feedback (UAI 2009) — the loss in block 4.
- Hu, Koren & Volinsky, Collaborative Filtering for Implicit Feedback Datasets (ICDM 2008).
- Covington, Adams & Sargin, Deep Neural Networks for YouTube Recommendations (RecSys 2016) — the two-stage architecture.
- Naumov et al., DLRM (2019) — the embedding-table cost model.
- Dacrema, Cremonesi & Jannach, Are We Really Making Much Progress? (RecSys 2019) — the paper that showed most reported gains vanish against tuned baselines, which is what blocks 3--5 reproduce in miniature.
- Chen et al., Bias and Debias in Recommender System: A Survey (2020).
Running it
python3 handson/h08_recsys.py # every block, then the assembly
python3 handson/h08_recsys.py --block 3 # just block 3 and its prerequisites
python3 handson/h08_recsys.py --quiet # the assembly only
What to do with this
Run block 8 on your own data. Estimate the head mass --- what fraction of interactions go to the top 1% of items --- before writing any modelling code. It tells you the size of the prize, and it is the single cheapest analysis in this entire curriculum. If the answer is that popularity explains most of the behaviour, the correct engineering decision may be to ship the bincount.
Milestones, experiments, readings and exit criteria for this project: P08 — End-to-End Recommendation System.
P09 — Recommendation Simulator and Experimentation Platform
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P09 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 66 hours · Weeks 90–95 · Stage 4 · Python
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- The User Model
- Showcase — The Check That Must Always Pass
- Implementation Milestones
- Scenarios
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Evaluate recommendation algorithms without users, escaping the logging-policy bias that makes P08's offline numbers untrustworthy |
| 2. Constraints | The simulator's users are fake. Everything it tells you is conditional on a model you wrote. That conditionality is the whole design problem |
| 3. Naive design | Yours. Most people build: sample a topic vector per user, click if cosine similarity exceeds a threshold |
| 4. Predicted failure | Predict which real phenomenon your naive model cannot produce. There are at least four |
| 5. Minimal implementation | Personas, a click model, a session loop, one algorithm |
| 6. Correctness | Known-answer tests: an oracle recommender must win; a random one must lose |
| 7. Instrumentation | Everything — it is a simulator; there is no cost to observing it |
| 8. Baseline | The same four baselines as P08, now evaluated under known ground truth |
| 9. Bottleneck | Not performance. The bottleneck is validity: which conclusions survive changes to the user model? |
| 10. Hypothesis | The simulator's ranking of algorithms matches P08's offline ranking — or does not, and the disagreement is informative |
| 11. Modification | Vary a user-model parameter and see whether the ranking flips |
| 12. Experiment | Sensitivity analysis over the user model itself |
| 13. Failure analysis | Any conclusion that flips under a plausible parameter change is not a conclusion |
| 14. Report | What this simulator can and cannot be used to decide. Be strict |
Why This Project Matters
P08 ended with an admission: offline evaluation is biased by the policy that produced the logs. Items your old system never showed have no recorded positives, so any new algorithm that would have surfaced them is penalised for it. You cannot fix this with better metrics, because the information is not in the data.
A simulator escapes the bias by generating the counterfactual: it can tell you what a user would have done with a list they were never shown. That is genuinely the only way to answer the question offline.
The price is that you have replaced a biased measurement with a model-dependent one. Everything the simulator says is conditional on assumptions you wrote down. So the project's real subject is not simulation — it is validity: knowing which of your conclusions are properties of recommender algorithms and which are properties of your click model.
That skill — separating a result from the harness that produced it — is the most research-like thing in this journey, and it is why this project comes after eight others rather than first.
Prerequisites
- P08 complete — the pipeline under test, with a stable swappable interface
- From
math.md: §Probability Distributions (2 h), §Confidence Intervals and Bootstrap (2 h) - The honesty to accept that a simulator that always confirms your preferred algorithm is broken
Duration and Size
Medium, 66 hours, 6 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Persona generation, a position-aware click model, dwell time, session loop, profile updates, and an A/B harness comparing two algorithms with confidence intervals. | 32 |
| Standard | + preference drift, fatigue, repeated-exposure decay, delayed feedback, all twelve scenarios, seeded reproducibility, repeated trials, a metric dashboard, result export, experiment tracking. | 66 |
| Extension | Calibrate the simulator against real interaction logs and report a validation score; or run a sensitivity analysis across the full user-model parameter space. | +30–45 |
Central Technical Questions
- What must a simulated user do for algorithm comparisons to be meaningful? Not "be realistic" — be ordinally faithful: rank algorithms the way real users would.
- How do you validate a simulator when the thing it simulates is what you lack?
- Which of your conclusions are robust to the user model, and which are artifacts?
- What does the feedback loop do? The recommender shapes the profile which shapes the recommender. Simulators make this visible; production hides it for months.
- How many simulated users and sessions do you need for a given effect size? This is a power calculation and you should do it before running anything.
- What can this simulator never tell you? Write the list before you build it, and again after.
Architecture
┌── content pool ──┐ ┌──── population ────┐
│ sampled / synth │ │ personas: │
│ + embeddings │ │ topic prefs π_u │
│ + timestamps │ │ drift rate │
│ + popularity │ │ fatigue params │
└────────┬─────────┘ │ session behaviour │
│ │ activity level │
│ └─────────┬──────────┘
▼ ▼
┌───────────────── session loop ──────────────────┐
│ for each user, for each session: │
│ algorithm.recommend(profile, pool, k) │
│ ──► user model examines position by │
│ position (with position bias) │
│ ──► click? dwell? skip? abandon session? │
│ ──► fatigue and exposure counters update │
│ ──► feedback emitted (possibly delayed) │
│ ──► profile updated from feedback │
│ drift preferences between sessions │
└───────────────────────┬─────────────────────────┘
▼
experiment harness: seeds · arms · trials · CIs · export
The User Model
This is the project. Every component below is a modelling decision that changes your conclusions, so each must be a named, swept parameter — never a hard-coded constant.
Latent preference. User \(u\) has a topic-preference vector \(\pi_u\) over the embedding space. Base affinity for item \(i\) is \(a_{ui} = \langle \pi_u, e_i \rangle\).
Position bias. Examination probability decays with rank. The standard form is \(P(\text{examine at rank } r) = r^{-\gamma}\) with \(\gamma \approx 0.7\text{–}1.0\) from eye-tracking studies. This single parameter dominates everything — with strong position bias, any algorithm that puts something plausible in slot 1 looks good.
Click. \(P(\text{click}) = P(\text{examine}) \cdot P(\text{attract} \mid \text{examine})\), the position-based model. The separation matters: a skip at rank 9 is weak evidence of dislike because the user probably never looked.
Dwell time. Log-normal, with median scaling in affinity. Gives you a graded signal and lets you model "clicked but bounced", which is a real and important negative.
Repeated exposure and fatigue. Attraction multiplied by \(\beta^{n_{ui}}\) where \(n_{ui}\) is prior exposures of item \(i\) — and a topic-level fatigue term that suppresses a topic after several consecutive items from it. Without topic fatigue, your simulator will conclude that diversity is worthless, because a simulated user with no fatigue is perfectly happy to read fifteen articles about the same thing.
Session boundaries and abandonment. \(P(\text{abandon after rank } r)\) rising with consecutive skips. Session length becomes an outcome, which makes it a metric — and a much better one than click count, because it responds to list quality rather than to a single good slot.
Drift. Between sessions, \(\pi_u \leftarrow \text{normalise}(\pi_u + \eta \epsilon)\) with \(\eta\) the drift rate. This is the parameter that decides P08's EMA-vs-mean question, and it is the reason the two projects are adjacent.
Delayed feedback. Some interactions arrive minutes or hours later. Directly tests whether an algorithm depending on immediate profile updates degrades.
Showcase — The Check That Must Always Pass
Twenty minutes, before you build a persona generator. Two things: the sanity check that runs on every change forever, and a first look at whether your conclusions survive the parameter you cannot measure.
# P09 -- the sanity check that must run on every change, and the parameter that
# quietly decides your conclusions.
import random, math
random.seed(3)
N_ITEMS, N_USERS, K = 500, 800, 10
topic = [random.randrange(6) for _ in range(N_ITEMS)]
def simulate(recommend, gamma):
"""gamma = position-bias exponent: P(examine at rank r) = r^-gamma."""
clicks = 0
for _ in range(N_USERS):
fav = random.randrange(6)
for rank, item in enumerate(recommend(fav)[:K], start=1):
p_examine = rank ** (-gamma)
p_attract = 0.8 if topic[item] == fav else 0.05
if random.random() < p_examine * p_attract:
clicks += 1
return clicks / N_USERS
oracle = lambda fav: [i for i in range(N_ITEMS) if topic[i]==fav]
random_rec = lambda fav: random.sample(range(N_ITEMS), K)
half = lambda fav: ([i for i in range(N_ITEMS) if topic[i]==fav][:K//2]
+ random.sample(range(N_ITEMS), K//2))
print(f"{'position bias':>14}{'oracle':>9}{'half-good':>11}{'random':>9}{'oracle/random':>15}")
for gamma in (0.0, 0.7, 1.5, 3.0):
o, h, r = simulate(oracle,gamma), simulate(half,gamma), simulate(random_rec,gamma)
print(f"{gamma:>14.1f}{o:>9.2f}{h:>11.2f}{r:>9.2f}{o/max(r,1e-9):>14.1f}x")
print("\\nCHECK 1: the oracle must beat random at every gamma. If it ever does not,")
print("the simulator is broken and every conclusion drawn from it is void.")
print("\\nCHECK 2, and it is good news. Absolute engagement collapses 8.7x as gamma")
print("goes 0 -> 3 (8.09 -> 0.93 clicks/user), but the oracle/random RATIO barely")
print("moves (4.5x -> 4.8x). The ORDINAL conclusion -- which recommender is better --")
print("is robust to a parameter you cannot measure precisely.")
print("\\nThat is exactly the property a simulator needs, and exactly the claim P09")
print("must verify rather than assume. Absolute simulated numbers are worthless;")
print("relative rankings may be trustworthy. E11 is where you find out which of your")
print("conclusions are in which category.")
position bias oracle half-good random oracle/random
0.0 8.09 4.91 1.81 4.5x
0.7 3.27 2.39 0.72 4.6x
1.5 1.65 1.47 0.34 4.8x
3.0 0.93 0.91 0.21 4.5x
\nCHECK 1: the oracle must beat random at every gamma. If it ever does not,
the simulator is broken and every conclusion drawn from it is void.
\nCHECK 2, and it is good news. Absolute engagement collapses 8.7x as gamma
goes 0 -> 3 (8.09 -> 0.93 clicks/user), but the oracle/random RATIO barely
moves (4.5x -> 4.8x). The ORDINAL conclusion -- which recommender is better --
is robust to a parameter you cannot measure precisely.
\nThat is exactly the property a simulator needs, and exactly the claim P09
must verify rather than assume. Absolute simulated numbers are worthless;
relative rankings may be trustworthy. E11 is where you find out which of your
conclusions are in which category.
Run the oracle check on every commit. It is the cheapest possible detector of a simulator that has quietly started rewarding your preferred algorithm — which is this project's central hazard.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Content pool: sampling, synthetic generation, embeddings, timestamps, Zipf popularity | 5 | Pool statistics match a stated target distribution |
| 2 | Persona generation with all parameters exposed and seeded | 5 | Same seed → identical population |
| 3 | Click model: position bias + attraction, with γ swept | 5 | Position-bias curve reproduces the target shape |
| 4 | Dwell, skip, abandonment, session loop | 5 | Sessions terminate sensibly; length distribution plotted |
| 5 | Fatigue: item-level and topic-level | 5 | Repeated topics measurably suppressed |
| 6 | Profile updates from simulated feedback (reusing P08's builders) | 4 | Closed loop runs end to end |
| 7 | Preference drift between sessions | 4 | Drift rate → measured profile-tracking error |
| 8 | Delayed and failed feedback | 4 | Configurable delay distribution and drop rate |
| 9 | Known-answer validation: oracle, random, and adversarial recommenders | 6 | Oracle wins, random loses, by margins you predicted |
| 10 | Experiment harness: arms, seeds, repeated trials, bootstrap CIs, export | 7 | A full comparison runs from one config file |
| 11 | The twelve scenarios | 7 | Each runs, each has a documented expected outcome |
| 12 | Sensitivity analysis over the user model | 6 | For each conclusion: does it survive parameter changes? |
| 13 | Dashboard + report | 3 | Metrics plotted per arm with CIs |
Scenarios
Twelve, each a configuration plus a documented expectation. Write the expected outcome before running each one — a scenario whose result you cannot predict is testing your simulator, not your algorithm.
| # | Scenario | Configuration | Expected |
|---|---|---|---|
| S1 | Stable interests | drift η=0 | Mean profile ≈ EMA; long-memory should not lose |
| S2 | Rapidly changing interests | η high | EMA with large α wins; this is P08's E2 answered properly |
| S3 | Breaking news | a burst of high-affinity items for all users | Recency and freshness terms dominate |
| S4 | Sparse-history users | 1–3 interactions | Content and popularity fallbacks; personalisation should not help |
| S5 | Multi-interest users | π_u is a mixture of 3 topics | Single-centroid profiles underperform multi-interest retrieval |
| S6 | Popularity bias | Zipf α=1.2 catalogue | Popularity baseline is hard to beat; coverage collapses |
| S7 | Embedding noise | Gaussian noise added to item vectors | Quality degrades; measure the sensitivity slope |
| S8 | Stale content | pool not refreshed for 24 h | Freshness metrics fall; quantify the cost of staleness |
| S9 | Duplicate stories | 30% near-duplicates | Dedup is load-bearing; without it, list quality collapses |
| S10 | Adversarial engagement | 1% of users are bots clicking one topic | Profile poisoning and its blast radius |
| S11 | Delayed ingestion | new items appear 30 min late | Interacts with S3; freshness advantage disappears |
| S12 | Failed embedding generation | 10% of items get zero vectors | Must be detected, not silently ranked at the origin |
Concepts To Study
- Click models: position-based, cascade, dynamic Bayesian network; what each assumes
- Position bias and its measurement (interleaving, randomisation)
- Examination vs attraction — the decomposition that makes skips interpretable
- Simulation validity: face validity, construct validity, predictive validity (the only one that matters here)
- Feedback loops and algorithmic confounding
- Variance reduction: common random numbers across arms — the single highest-value technique in this project
- Bootstrap confidence intervals for non-normal metrics
- Statistical power and minimum detectable effect (implemented in
tools/metrics.py) - Sensitivity analysis and one-factor-at-a-time vs global methods
- Reproducibility: seed discipline and the difference between a global seed and per-component streams
Common random numbers
Use the same seeds for population, content, and noise across all arms, varying only
the algorithm. Then a difference between arms cannot be caused by a different
population. This routinely reduces variance by an order of magnitude and is the
difference between needing 1,000 simulated users and needing 100,000. Implement it in
milestone 10 as a hard requirement, not a nicety — separate numpy Generator streams
per component, all derived from one root seed.
Primary-Source Readings
Budget: 9 hours.
| Reading | Why | Hours |
|---|---|---|
| Chaney, Stewart & Engelhardt. How Algorithmic Confounding... RecSys 2018 | The closest published work to this project | 2 |
| Ie, E. et al. RecSim: A Configurable Simulation Platform for Recommender Systems. arXiv:1909.04847, 2019 | Read the design decisions, then make your own | 1.5 |
| Chuklin, A., Markov, I., de Rijke, M. Click Models for Web Search. Morgan & Claypool, 2015 | Chapters 3–4. The definitive treatment | 2.5 |
| Craswell, N. et al. An Experimental Comparison of Click Position-Bias Models. WSDM 2008 | Where the position-bias exponent comes from | 1 |
| Jeunen, O. Revisiting Offline Evaluation for Implicit-Feedback Recommender Systems. RecSys 2019 (doctoral) | Why offline evaluation fails, stated cleanly | 1 |
| Rohde, D. et al. RecoGym: A Reinforcement Learning Environment for the problem of Product Recommendation. arXiv:1808.00720, 2018 | A second simulator design to compare against | 1 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Known-answer validation | oracle / random / P08 pipeline | Predict the oracle's margin. If your pipeline beats the oracle, the simulator is broken |
| E2 | Ordinal agreement with P08 | rank all P08 configurations both ways | Do the two rankings agree? Where they diverge is the finding |
| E3 | Drift rate × profile type | η × {mean, EMA(α)} | The answer to P08's open question. Predict optimal α per η |
| E4 | Position-bias sensitivity | γ ∈ {0, 0.5, 0.7, 1.0, 1.5} | Which conclusions survive? |
| E5 | Fatigue sensitivity | topic fatigue on/off | Predict: diversity looks worthless without it |
| E6 | Feedback loop | 50 sessions of closed-loop operation | Coverage over time; predict monotone collapse |
| E7 | Delayed feedback | delay ∈ {0, 5 min, 1 h, 1 day} | Which algorithms are delay-sensitive? |
| E8 | Population size and power | users ∈ {100…100,000} | CI width vs n; check against the power formula |
| E9 | Common random numbers | on/off | Predict the variance reduction factor |
| E10 | Twelve scenarios | all of S1–S12 | Each with its written expectation |
| E11 | Global sensitivity analysis | all user-model parameters ±50% | Which conclusions are robust? |
| E12 | Simulator vs reality (extension) | calibrate against real logs | A validation score, honestly reported |
E2 is the project's headline result. You have two evaluation methods — P08's biased offline replay and this simulator — and they will disagree on some algorithm pairs. Neither is ground truth. The disagreement itself is the finding, and diagnosing which mechanism causes each disagreement is exactly the kind of analysis that makes research-quality work.
E11 is what separates a toy from a tool. For every conclusion in your report, vary each user-model parameter by ±50% and record whether the conclusion flips. Publish the table. Conclusions that survive are about recommenders; conclusions that flip are about your click model, and saying so plainly is the most credible thing in the report.
Benchmarks and Metrics
| Family | Metrics |
|---|---|
| Simulated engagement | CTR, dwell per session, session length, sessions per user, return rate |
| Standard suite | The whole P08 suite, now against known ground truth |
| Long-run health | Coverage and Gini over 50 sessions — the feedback-loop signal |
| User-model diagnostics | Profile-tracking error vs true π_u; fatigue and exposure distributions |
| Statistical | CI width per metric, effect size, achieved power, trials to significance |
| Runtime | Sessions/second — you need millions of sessions, so this matters |
| Robustness | Fraction of conclusions surviving E11 |
Because ground truth is known, you can compute something impossible in production: regret — the gap between the algorithm's achieved engagement and the oracle's. It is the cleanest single number the simulator produces and should be the headline metric.
Correctness Tests
- Oracle wins. A recommender with access to true \(\pi_u\) must beat everything. If it does not, the simulator is broken. Run this on every change.
- Random loses, by a predicted margin.
- Determinism: same root seed → bit-identical outcomes, including every arm.
- Seed independence: different seeds → different outcomes with overlapping CIs.
- No information leakage: the algorithm cannot see \(\pi_u\), future items, or another user's state. Enforce by interface, not by discipline.
- Click model calibration: with a uniform-random ranking, observed CTR by position matches the configured position-bias curve.
- Fatigue monotonicity: repeated exposure never increases click probability.
- Drift correctness: measured \(\pi_u\) displacement over n sessions matches \(\eta\sqrt{n}\).
- Conservation: impressions = clicks + skips + unexamined. They must sum.
- Power check: with a synthetic known effect, the harness detects it at approximately the predicted sample size.
Test 1 and test 6 are the two you run constantly. An oracle that stops winning is the single best signal that a change broke the simulator.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Algorithm returns fewer than k items | Handled and counted |
| Algorithm returns duplicates | Rejected by the harness, not silently scored |
| Algorithm throws | Arm fails cleanly; other arms unaffected |
| Zero-vector embeddings for 10% of items | S12 |
| A persona with all-zero preferences | Degenerate but not crashing |
| Content pool empty mid-run | Clean error |
| Extreme parameters (γ=0, γ=5, η=1) | No NaN, no infinite loop |
| 10⁶ users × 100 sessions | Completes; memory bounded |
Expected Difficulties
- You will accidentally build a simulator that rewards your preferred algorithm. This is the central hazard. Mitigations: write scenario expectations before running; run E11; and have the oracle test always on.
- Validating a simulator is genuinely unsolved. The honest position is: this simulator produces hypotheses and relative rankings under stated assumptions, not predictions of production lift. Say exactly that in the report.
- Parameter count explodes. A dozen parameters is an unsearchable space. Mitigation: fix most from literature (position bias from Craswell et al.), sweep the three that matter, document every fixed value with its source.
- Runtime becomes the constraint. Millions of sessions × a ranking pipeline is slow. Vectorise the user model; profile the harness at milestone 10.
- Seeding is harder than it looks. One global seed means adding a parameter shifts every subsequent random draw and all your arms move. Use independent per-component streams from a root seed.
- It is tempting to make the simulator realistic rather than useful. Realism is unbounded; ordinal faithfulness is the actual requirement. Every additional mechanism must earn its place by changing an algorithm ranking.
Scope Boundaries
In scope: persona-based simulation, click/dwell/skip/abandon models, drift, fatigue, delayed feedback, the twelve scenarios, an experiment harness with CIs, sensitivity analysis.
Out of scope: learned user models trained on real data (extension); RL agents as users; a UI beyond plots; real-time serving; multi-armed bandit algorithms as the subject (that is P08's extension); network effects between users; economic modelling.
Deliverables
recsim/— simulator, personas, click models, scenarios, harnessUSER-MODEL.md— every parameter, its default, its source, and which conclusions depend on it. This is the artifact that makes the simulator trustworthyREPORT.mdcentred on E2 (simulator vs offline disagreement) and E11 (sensitivity)- A written list titled "What This Simulator Cannot Tell You" — required, scored
- Notebook entries for E2, E3, E11
- The experiment harness as a standalone reusable tool (P10 extends it)
Exit Criteria
- Oracle beats all real algorithms; random loses. Both by predicted margins
- Full determinism under a root seed, verified
- All twelve scenarios run, each with a pre-written expectation and a recorded outcome
- E2 complete: simulator vs P08 offline ranking compared, disagreements diagnosed
- E3 complete: drift × profile type, with P08's EMA question answered
- E9 complete: common random numbers implemented, variance reduction measured
- E11 complete: sensitivity analysis over all user-model parameters, with a table of which conclusions survive
- "What This Simulator Cannot Tell You" written and specific
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Calibrate against real logs: fit the click model to real interaction data, then report how well the simulator predicts held-out behaviour. The strongest possible answer to the validity question.
- Learned user model (a sequence model as the user) compared against the hand-written one — does the conclusion change?
- Global sensitivity analysis with Sobol indices rather than one-factor-at-a-time.
- Multi-stakeholder simulation: publishers as agents responding to the recommender.
Connections
Backward: P08 is the system under test and supplies the offline ranking that E2 compares against.
Forward:
- → P10: the A/B platform assigns and analyses this population; the harness is the foundation
- → P15: "can simulated users predict the relative performance of ranking algorithms?" is one of the strongest candidate research questions, and this project is half of the answer
References
- Chaney, A. J. B., Stewart, B. M., Engelhardt, B. E. How Algorithmic Confounding in Recommendation Systems Increases Homogeneity and Decreases Utility. RecSys 2018.
- Ie, E. et al. RecSim: A Configurable Simulation Platform for Recommender Systems. arXiv:1909.04847, 2019.
- Chuklin, A., Markov, I., de Rijke, M. Click Models for Web Search. Morgan & Claypool, 2015.
- Craswell, N., Zoeter, O., Taylor, M., Ramsey, B. An Experimental Comparison of Click Position-Bias Models. WSDM 2008.
- Rohde, D., Bonner, S., Dunlop, T., Vasile, F., Karatzoglou, A. RecoGym. arXiv:1808.00720, 2018.
- Jeunen, O. Revisiting Offline Evaluation for Implicit-Feedback Recommender Systems. RecSys 2019.
- Law, A. M. Simulation Modeling and Analysis, 5th ed. McGraw-Hill, 2014. Chapters on validation and variance reduction — the standard reference for common random numbers.
- Saltelli, A. et al. Global Sensitivity Analysis: The Primer. Wiley, 2008.
- Efron, B., Tibshirani, R. An Introduction to the Bootstrap. Chapman & Hall, 1993.
P09 hands-on — Recsys simulator, block by block
Feedback loops, position bias, and a bandit that loses for a findable reason.
Source:
handson/h09_simulator.py--- run it withpython3 handson/h09_simulator.py
Full project spec: P09 — Recommendation Simulator
A simulator exists to answer the question an A/B test cannot: what would have happened under a policy nobody ran? Its answers are only as good as its user model, so the model has to be stated at the top and stress-tested at the bottom.
Between those two, this file demonstrates the feedback loop that gives recommender systems their characteristic pathology --- a greedy policy narrows its own catalogue from 599 items to 96 while every individual step is locally optimal --- and then measures what exploration costs to prevent it.
The best block is the one where the textbook answer fails. Thompson sampling loses badly to greedy here; the file forms a hypothesis about why, derives a prediction from it, tests the prediction with a modified policy, and confirms it --- and then block 7 independently confirms the same mechanism from a completely different direction.
Contents
- Block 1 — A user model you can interrogate
- Block 2 — Position bias makes logs lie
- Block 3 — Inverse propensity scoring
- Block 4 — Feedback loops
- Block 5 — Exploration as insurance
- Block 6 — When the bandit loses
- Block 7 — The counterfactual question
- The assembly
- The design space
- Position bias, and why logs cannot be read naively
- What the blocks found that the textbook does not say
- Advanced algorithms
- Calibration: the only thing that makes a simulator citable
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — A user model you can interrogate
Teaches: the simulator's assumptions ARE its results
The problem. A simulator's assumptions are its results. State them in three lines at the top, where they can be argued with, rather than burying them in an appendix where they cannot.
@block(1, "A user model you can interrogate", "the simulator's assumptions ARE its results")
def b1(s, show):
rng = np.random.default_rng(9)
U = rng.normal(0, 1, (NU, D)); V = rng.normal(0, 1, (NI, D))
quality = rng.normal(0, 0.6, NI)
util = U @ V.T + quality # true utility of item i to user u
def click(u, ranked, rng, pos_bias=True):
"""Examine top-k with position-dependent probability; click if utility wins."""
clicks = []
for r, i in enumerate(ranked):
exam = 1.0 / (1 + r) ** 1.0 if pos_bias else 1.0
if rng.random() < exam and rng.random() < 1 / (1 + np.exp(-util[u, i])):
clicks.append(i)
return clicks
if show:
print(f" {NU} users x {NI} items, utility = <u,v> + item quality")
print(f" utility spread: p10={np.quantile(util,.1):+.2f} "
f"p50={np.quantile(util,.5):+.2f} p90={np.quantile(util,.9):+.2f}")
print(f" examination P(look at rank r) = 1/(1+r): "
f"rank0={1.0:.2f} rank4={1/5:.2f} rank19={1/20:.3f}")
print(" Every number this simulator later produces is a consequence of these")
print(" three lines. State them at the top of the report, not in an appendix:")
print(" a simulator is an argument, and these are its premises.")
return {"util": util, "click": click, "quality": quality}
Reading the implementation
The user model is exactly three statements:
- Utility is \(\langle u, v \rangle + \text{quality}_i\) — a latent taste term plus a global item-quality term.
- Examination probability at rank \(r\) is \(1/(1+r)\).
- A click happens if the item is examined and \(\sigma(\text{utility})\) fires.
That last line is the examination hypothesis: click = examine × relevant, with the two independent. It is the assumption underlying essentially all learning-to-rank debiasing, and it is falsifiable — real users' examination depends on what they have already seen (the cascade effect), which this model omits.
Separating quality from taste is not cosmetic. It creates items that are good for everyone (which popularity finds) and items that are good for specific people (which only personalisation finds), and that separation is what makes the exploration results in later blocks interpretable.
What the numbers say
Output:
400 users x 600 items, utility = <u,v> + item quality
utility spread: p10=-3.13 p50=+0.01 p90=+3.18
examination P(look at rank r) = 1/(1+r): rank0=1.00 rank4=0.20 rank19=0.050
Every number this simulator later produces is a consequence of these
three lines. State them at the top of the report, not in an appendix:
a simulator is an argument, and these are its premises.
Beyond the toy
The missing dynamics, roughly in order of how much they would change the conclusions: satiation (a user who has seen an item does not want it again), drift (preferences move over sixty days), arrival and churn (the user population is not fixed), and social influence (what others click changes what I click). Each is a few lines to add and each can reverse a policy comparison, which is precisely why block 7's sensitivity analysis is not optional.
Block 2 — Position bias makes logs lie
Teaches: the log measures the ranker, not the user
The problem. Click logs measure the ranker at least as much as they measure the user. This block quantifies it under the cleanest possible conditions — random serving, so item quality and position are independent by construction.
@block(2, "Position bias makes logs lie", "the log measures the ranker, not the user")
def b2(s, show):
rng = np.random.default_rng(10)
util = s["util"]
if show:
ctr_by_rank = np.zeros(20); shown = np.zeros(20)
for u in range(NU):
order = rng.permutation(NI)[:20] # RANDOM ranking: no confound
cl = set(s["click"](u, order, rng))
for r, i in enumerate(order):
shown[r] += 1; ctr_by_rank[r] += i in cl
obs = ctr_by_rank / shown
print(" Serving a RANDOM ranking, so item quality is independent of position:")
print(f" {'rank':>6}{'observed CTR':>15}{'1/(1+r) prediction':>21}")
for r in (0, 1, 4, 9, 19):
print(f" {r:>6}{obs[r]:>15.4f}{obs[0]/(1+r):>21.4f}")
print(f" CTR at rank 0 is {obs[0]/max(obs[19],1e-9):.1f}x rank 19 for items chosen")
print(" UNIFORMLY AT RANDOM. Naively training on click logs teaches the model")
print(" 'items at rank 0 are good', which is a fact about the old ranker.")
return {}
Reading the implementation
Serving a random ranking is the key experimental design. Under any real ranker, position and quality are confounded: good items are shown high, so high positions get more clicks for two reasons and you cannot separate them. Randomise, and the only remaining reason is examination.
This is exactly why result randomisation is the gold-standard method for estimating propensities in production — and why it is expensive: you are deliberately showing users worse results to learn how position affects them.
What the numbers say
Output:
Serving a RANDOM ranking, so item quality is independent of position:
rank observed CTR 1/(1+r) prediction
0 0.5100 0.5100
1 0.2425 0.2550
4 0.1025 0.1020
9 0.0550 0.0510
19 0.0250 0.0255
CTR at rank 0 is 20.4x rank 19 for items chosen
UNIFORMLY AT RANDOM. Naively training on click logs teaches the model
'items at rank 0 are good', which is a fact about the old ranker.
CTR at rank 0 is many times rank 19 for items chosen uniformly at random. Any model trained on raw clicks learns "items at rank 0 are good", which is a fact about the previous ranker. Deploy that model and it reinforces the previous ranker's choices — the feedback loop block 4 measures.
Beyond the toy
The click-model family, in increasing realism: position-based (this block — examination depends only on rank), cascade (the user scans top-down and stops at the first click, so items below a click are not examined), dependent click (cascade with a continuation probability), and dynamic Bayesian network (cascade plus a satisfaction probability after the click, which distinguishes "saw and liked" from "saw and bounced").
Estimating propensities without randomisation is a research area in itself: intervention harvesting exploits natural rank variation of the same item across queries, and regression-EM jointly fits relevance and examination. Both are cheaper than randomisation and both make assumptions that randomisation does not.
Block 3 — Inverse propensity scoring
Teaches: divide out the mechanism you know
The problem. If the mechanism producing the bias is known, you can divide it out. Inverse propensity scoring is that idea, and this block shows both that it works and why it is fragile.
@block(3, "Inverse propensity scoring", "divide out the mechanism you know")
def b3(s, show):
rng = np.random.default_rng(11)
if show:
true_rate = np.zeros(NI); naive = np.zeros(NI); ips = np.zeros(NI)
shown = np.zeros(NI)
for u in range(NU):
order = rng.permutation(NI)[:20]
cl = set(s["click"](u, order, rng))
for r, i in enumerate(order):
p = 1.0 / (1 + r) # KNOWN propensity
shown[i] += 1
naive[i] += i in cl
ips[i] += (i in cl) / p
true_rate[i] += 1 / (1 + np.exp(-s["util"][u, i]))
m = shown > 8
def corr(a, b): return float(np.corrcoef(a[m] / shown[m], b[m] / shown[m])[0, 1])
print(f" correlation with true click propensity, over {int(m.sum())} items:")
print(f" naive CTR estimate r = {corr(naive, true_rate):.4f}")
print(f" IPS-corrected estimate r = {corr(ips, true_rate):.4f}")
print(" IPS is unbiased when the propensity is known exactly -- which is true")
print(" in a simulator and never true in production. There you estimate the")
print(" propensity, and the variance of 1/p_hat at small p_hat is what")
print(" destroys the estimator. Clipping p at 0.01-0.1 is the standard trade:")
print(" accept a little bias to stop the variance from exploding.")
return {}
Reading the implementation
Weight each observation by \(1/p(\text{examined})\), so an event that was unlikely to be observed counts for more:
\[ \hat{V}_{\text{IPS}} = \frac{1}{n}\sum_i \frac{\mathbb{1}[\text{click}_i]}{p_i} \]
This is unbiased when the propensities are exact and non-zero everywhere. The proof is one line of expectation algebra, and the estimator is the foundation of counterfactual learning-to-rank.
The fragility is in the variance, not the bias. \(\mathrm{Var}(1/p)\) grows as \(p \to 0\), so a single observation at \(p = 0.001\) contributes weight 1000 and can dominate the entire estimate. In a simulator \(p\) is known exactly; in production it is estimated, and the variance of \(1/\hat{p}\) at small \(\hat{p}\) is what destroys the estimate in practice.
What the numbers say
Output:
correlation with true click propensity, over 551 items:
naive CTR estimate r = 0.3221
IPS-corrected estimate r = 0.2592
IPS is unbiased when the propensity is known exactly -- which is true
in a simulator and never true in production. There you estimate the
propensity, and the variance of 1/p_hat at small p_hat is what
destroys the estimator. Clipping p at 0.01-0.1 is the standard trade:
accept a little bias to stop the variance from exploding.
Beyond the toy
The standard repairs, and what each trades:
- Clipping weights at \(M\): bounded variance, introduces bias. Usual choice.
- Self-normalised IPS (SNIPS): divide by the sum of weights. Consistent rather than unbiased, and much lower variance — usually strictly better in practice.
- Doubly robust: combine a reward model \(\hat{r}\) with IPS on the residual. Unbiased if either the model or the propensities are correct, which is why it is the production default.
- Overlap / positivity: if the new policy puts mass where the logging policy put none, no amount of reweighting helps. This is a hard limit, not a variance problem, and it is why off-policy evaluation cannot assess a radically different policy — which is exactly the gap a simulator fills.
Block 4 — Feedback loops
Teaches: the ranker trains on data the ranker created
The problem. The ranker trains on data the ranker created. This block shows the loop closing, and the disturbing part is that every individual step is locally optimal.
@block(4, "Feedback loops", "the ranker trains on data the ranker created")
def b4(s, show):
def simulate(policy, days=T, explore=0.0, seed=12, k=10, warmup=2, order_fn=None):
"""warmup days of RANDOM serving seed the estimates; without it every
item ties at CTR 0 and 'greedy' just locks onto item ids 0..k-1, which
would make the feedback loop look like an artefact of argsort."""
rng = np.random.default_rng(seed)
clicks = np.zeros(NI); impr = np.ones(NI)
hist = []
for d in range(days):
served, got = np.zeros(NI), 0
for u in range(NU):
if d < warmup or (explore and rng.random() < explore):
order = rng.permutation(NI)[:k]
elif order_fn is not None:
order = order_fn(clicks, impr, rng, k)
else:
order = np.argsort(-policy(clicks, impr, rng))[:k]
cl = s["click"](u, order, rng)
for i in order: impr[i] += 1; served[i] += 1
for i in cl: clicks[i] += 1
got += len(cl)
hist.append((got / NU, int((served > 0).sum())))
return hist, clicks, impr
greedy = lambda c, im, rng: c / im
if show:
h, c, im = simulate(greedy)
print(f" 2 days of random serving, then greedy 'rank by observed CTR':")
print(f" {'day':>5}{'clicks/user':>13}{'distinct items shown':>22}")
for d in (0, 1, 2, 4, 19, 39, T-1):
tag = " <- random warm-up" if d < 2 else ""
print(f" {d:>5}{h[d][0]:>13.3f}{h[d][1]:>22}{tag}")
print(f" catalogue collapsed from {h[1][1]} items on day 2 to {h[-1][1]} "
f"on day {T} ({h[1][1]//max(h[-1][1],1)}x narrower)")
print(" Nothing broke. Every step was locally optimal: show what performed")
print(" well, observe it perform well, show it more. The feedback loop is")
print(" not a bug in the policy -- it is the policy, iterated.")
print(" Note the width is NOT monotone (185 -> 300 -> 313 -> 90). Straight")
print(" after warm-up, greedy chases items whose CTR was over-estimated by")
print(" noise; as they accumulate impressions their estimates regress and")
print(" other items overtake them, so the served set churns before it")
print(" freezes. That is the winner's curse, visible as a bump in a width")
print(" plot -- and it is why 'the ranking looks unstable' early in a launch")
print(" is expected rather than alarming.")
return {"simulate": simulate, "greedy": greedy}
Reading the implementation
The warm-up is the methodological point. Without two days of random serving, every
item has CTR 0, argsort returns the first \(k\) indices, and "greedy" locks
onto items 0--9 forever. That would be a property of argsort, not of the policy,
and reporting it as a feedback-loop result would be measuring the harness.
With the warm-up, the collapse is real: from 599 distinct items served on day 2 down to 96 on day 60. Nothing broke. Every step maximised expected clicks given current estimates. The feedback loop is not a bug in the policy — it is the policy, iterated.
What the numbers say
Output:
2 days of random serving, then greedy 'rank by observed CTR':
day clicks/user distinct items shown
0 1.393 600 <- random warm-up
1 1.407 599 <- random warm-up
2 2.007 185
4 1.905 300
19 2.005 313
39 2.100 90
59 2.007 96
catalogue collapsed from 599 items on day 2 to 96 on day 60 (6x narrower)
Nothing broke. Every step was locally optimal: show what performed
well, observe it perform well, show it more. The feedback loop is
not a bug in the policy -- it is the policy, iterated.
Note the width is NOT monotone (185 -> 300 -> 313 -> 90). Straight
after warm-up, greedy chases items whose CTR was over-estimated by
noise; as they accumulate impressions their estimates regress and
other items overtake them, so the served set churns before it
freezes. That is the winner's curse, visible as a bump in a width
plot -- and it is why 'the ranking looks unstable' early in a launch
is expected rather than alarming.
The non-monotonicity is worth pausing on: width goes 185 → 300 → 313 → 90. Straight after warm-up, greedy chases items whose CTR was over-estimated by noise; as those accumulate impressions their estimates regress toward truth and other items overtake them, so the served set churns before it freezes. That is the winner's curse, visible as a bump in a width plot — and it is why "the ranking looks unstable" early in a launch is expected rather than alarming.
Beyond the toy
The same loop appears wherever a model's outputs become its future training data: ad auctions (bids shape the data that trains the bidder), content moderation (enforcement shapes what is reported), credit scoring (denials mean no repayment data for the denied — the classic selective labels problem), and predictive policing. The general name is performativity, and the general defence is the same: keep a randomised holdout so you always have unbiased data about the actions your policy does not take.
Block 5 — Exploration as insurance
Teaches: epsilon buys catalogue coverage with clicks
The problem. Exploration is usually framed as a cost paid for future information. This block measures the curve, and the framing turns out to be wrong at the near end.
@block(5, "Exploration as insurance", "epsilon buys catalogue coverage with clicks")
def b5(s, show):
if show:
print(f" {'policy':<28}{'clicks/user d60':>17}{'distinct items':>16}"
f"{'cumulative':>12}")
rows = []
for lbl, ex in (("greedy (eps=0)", 0.0), ("eps=0.02", 0.02),
("eps=0.10", 0.10), ("eps=0.30", 0.30)):
h, c, im = s["simulate"](s["greedy"], explore=ex)
cum = sum(x for x, _ in h)
rows.append((lbl, h[-1][0], h[-1][1], cum))
print(f" {lbl:<28}{h[-1][0]:>17.3f}{h[-1][1]:>16}{cum:>12.1f}")
best = max(rows, key=lambda r: r[3])
print(f" highest cumulative clicks: {best[0]}")
print(" Exploration is NOT a pure cost here. eps=0.02 beats pure greedy on")
print(" BOTH axes -- more clicks (126.8 vs 120.5) and a live estimate for")
print(" items greedy abandoned. The cost only appears further along the")
print(" curve: eps=0.30 gives up 6% of clicks to keep 522 items alive.")
print(" The optimum is interior, so it has to be found by measurement; both")
print(" 'exploration is overhead' and 'more exploration is safer' are wrong.")
return {}
Reading the implementation
ε-greedy: with probability ε serve a random slate, otherwise serve the greedy one. Sweep ε and measure both cumulative clicks and catalogue coverage.
The cumulative column, not the day-60 column, is the one to optimise — the whole point of exploration is that it pays later, so a snapshot metric systematically undervalues it.
What the numbers say
Output:
policy clicks/user d60 distinct items cumulative
greedy (eps=0) 2.007 96 120.5
eps=0.02 2.277 88 126.8
eps=0.10 2.013 331 119.0
eps=0.30 1.857 522 113.3
highest cumulative clicks: eps=0.02
Exploration is NOT a pure cost here. eps=0.02 beats pure greedy on
BOTH axes -- more clicks (126.8 vs 120.5) and a live estimate for
items greedy abandoned. The cost only appears further along the
curve: eps=0.30 gives up 6% of clicks to keep 522 items alive.
The optimum is interior, so it has to be found by measurement; both
'exploration is overhead' and 'more exploration is safer' are wrong.
ε=0.02 beats pure greedy on both axes — more clicks and more of the catalogue alive. That is a strict improvement, available for free, and it contradicts the standard framing of exploration as a tax. The cost only appears further along the curve: ε=0.30 gives up ~6% of clicks to keep 522 items alive.
The optimum is interior, so it must be found by measurement. Both "exploration is overhead" and "more exploration is safer" are wrong.
Beyond the toy
Why a little exploration is free: greedy's estimates are wrong, and the items it abandoned early include some genuinely good ones (the winner's curse in reverse). A small ε corrects those errors cheaply. The marginal value of the next unit of exploration falls as estimates improve, while its marginal cost is constant — hence an interior optimum, and hence the standard practice of decaying ε over time.
Block 6 — When the bandit loses
Teaches: a prediction, a test, and a policy that fixes it
The problem. The textbook says Thompson sampling beats ε-greedy. Here it loses badly. This block is a full hypothesis-prediction-test loop on why, and the answer generalises to every slate-based system.
@block(6, "When the bandit loses", "a prediction, a test, and a policy that fixes it")
def b6(s, show):
def thompson(c, im, rng):
return rng.beta(1 + c, 1 + np.maximum(im - c, 0))
def ucb(c, im, rng):
return c / im + np.sqrt(2 * np.log(max(im.sum(), 2)) / im)
def slot_aware(c, im, rng, k):
"""Exploit the top slot; let Thompson have the rest."""
ts = np.argsort(-rng.beta(1 + c, 1 + np.maximum(im - c, 0)))
best = int(np.argmax(c / im))
return [best] + [int(i) for i in ts if i != best][:k - 1]
if show:
print(f" {'policy':<38}{'cum clicks/user':>17}{'distinct':>10}")
for lbl, pol, ex in (("greedy", s["greedy"], 0.0),
("eps-greedy 0.02", s["greedy"], 0.02),
("UCB1", ucb, 0.0),
("Thompson sampling", thompson, 0.0)):
h, _, _ = s["simulate"](pol, explore=ex)
print(f" {lbl:<38}{sum(x for x,_ in h):>17.1f}{h[-1][1]:>10}")
print(" The bandits LOSE, badly, and the textbook answer ('Thompson beats")
print(" epsilon-greedy') does not survive contact with this environment.\n")
ex = np.array([1 / (1 + r) for r in range(10)])
print(" HYPOTHESIS: examination is 1/(1+r), so attention is concentrated:")
print(" share by rank: " + " ".join(f"{x:.0%}" for x in ex / ex.sum()))
print(f" rank 0 alone carries {ex[0]/ex.sum():.0%} of all examination.")
print(" Thompson randomises ALL TEN slots, so it spends its most valuable")
print(" slot on an uncertain item every single impression.")
print(" PREDICTION: explore only in ranks 1-9 and most of the lost clicks")
print(" come back, while coverage stays near Thompson's.\n")
h, _, _ = s["simulate"](None, order_fn=slot_aware)
g, _, _ = s["simulate"](s["greedy"])
ts, _, _ = s["simulate"](thompson)
cg, ct, ch = (sum(x for x,_ in z) for z in (g, ts, h))
print(f" {'Thompson in ranks 1-9, greedy at rank 0':<38}{ch:>17.1f}"
f"{h[-1][1]:>10}")
print(f" VERDICT: confirmed. {ch/cg:.0%} of greedy's clicks "
f"(vs {ct/cg:.0%} for full Thompson),")
print(f" with {h[-1][1]} distinct items against Thompson's {ts[-1][1]}. The exploration")
print(" budget was never the problem -- WHERE it was spent was. This is why")
print(" production rankers explore in the tail of the slate and why a bandit")
print(" benchmarked without a position model reports the wrong winner.")
return {"thompson": thompson, "ucb": ucb, "slot_aware": slot_aware}
Reading the implementation
First the measurement: greedy 120.5 cumulative clicks, Thompson 97.8. The textbook result does not survive contact with this environment.
Hypothesis. Examination is \(1/(1+r)\), so attention is extremely concentrated — rank 0 alone carries 34% of all examination. Thompson randomises all ten slots, so it spends its single most valuable slot on an uncertain item on every impression.
Prediction. Explore only in ranks 1--9 and most of the lost clicks return while coverage stays near Thompson's.
Test. slot_aware puts the greedy best at rank 0 and lets Thompson have the
rest.
What the numbers say
Output:
policy cum clicks/user distinct
greedy 120.5 96
eps-greedy 0.02 126.8 88
UCB1 100.7 600
Thompson sampling 97.8 544
The bandits LOSE, badly, and the textbook answer ('Thompson beats
epsilon-greedy') does not survive contact with this environment.
HYPOTHESIS: examination is 1/(1+r), so attention is concentrated:
share by rank: 34% 17% 11% 9% 7% 6% 5% 4% 4% 3%
rank 0 alone carries 34% of all examination.
Thompson randomises ALL TEN slots, so it spends its most valuable
slot on an uncertain item every single impression.
PREDICTION: explore only in ranks 1-9 and most of the lost clicks
come back, while coverage stays near Thompson's.
Thompson in ranks 1-9, greedy at rank 0 112.0 532
VERDICT: confirmed. 93% of greedy's clicks (vs 81% for full Thompson),
with 532 distinct items against Thompson's 544. The exploration
budget was never the problem -- WHERE it was spent was. This is why
production rankers explore in the tail of the slate and why a bandit
benchmarked without a position model reports the wrong winner.
Verdict: confirmed. 93% of greedy's clicks (against 81% for full Thompson) with 532 distinct items against Thompson's 544. The exploration budget was never the problem — where it was spent was.
Beyond the toy
The generalisable statement: a bandit benchmarked without a position model reports the wrong winner. Standard bandit theory treats an impression as one action with one reward. A slate is ten actions with wildly unequal observation probabilities, and the regret analysis does not transfer.
This is why production rankers explore in the tail of the slate, why "exploration budget" is measured in attention rather than in impressions, and why slate bandits are a distinct research area (Swaminathan et al.'s pseudo-inverse estimator exists precisely because the combinatorial action space breaks naive off-policy evaluation).
Block 7 confirms the same mechanism from a completely different direction, which is the strongest form of evidence available here.
Block 7 — The counterfactual question
Teaches: a simulator's only real job
The problem. A simulator's only real job is the counterfactual — what would have happened under a policy nobody ran. That answer is only as good as the user model, so the last block attacks the user model.
@block(7, "The counterfactual question", "a simulator's only real job")
def b7(s, show):
if show:
print(" A/B tests answer 'which of these two shipped policies wins?'.")
print(" Simulators answer 'what would have happened under a policy nobody")
print(" ran?' -- and that answer is only as good as the user model.\n")
print(f" {'user model perturbation':<34}{'greedy':>9}{'Thompson':>11}"
f"{'winner':>10}{'margin':>9}")
base = None
for lbl, mult in (("as specified", 1.0), ("position bias 2x steeper", 2.0),
("position bias flat (no bias)", 0.0)):
orig = s["click"]
def click(u, ranked, rng, _m=mult):
out = []
for r, i in enumerate(ranked):
exam = 1.0 if _m == 0 else 1.0 / (1 + r) ** _m
if rng.random() < exam and rng.random() < 1/(1+np.exp(-s["util"][u,i])):
out.append(i)
return out
s["click"] = click
g = sum(x for x, _ in s["simulate"](s["greedy"])[0])
t = sum(x for x, _ in s["simulate"](s["thompson"])[0])
s["click"] = orig
print(f" {lbl:<34}{g:>9.1f}{t:>11.1f}"
f"{('Thompson' if t > g else 'greedy'):>10}"
f"{max(g,t)/min(g,t)-1:>8.0%}")
print(" The WINNER is stable across all three user models even though the")
print(" absolute numbers move by 7x. That is the claim a simulator can")
print(" support: ordinal, not cardinal. Quote 120.5 clicks/user to a")
print(" stakeholder and you are quoting your own assumptions back at them.")
print(" But read the margin column, because it is doing more work than the")
print(" winner column. Greedy's edge is 23% under the specified bias, 20%")
print(" when the bias doubles -- and 1% when it is removed entirely. That is")
print(" an INDEPENDENT confirmation of block 6: greedy wins here because")
print(" exploration is expensive at rank 0, so deleting position bias very")
print(" nearly deletes greedy's advantage. Two blocks, two methods, one")
print(" mechanism. A sensitivity table is not defensive paperwork; it is")
print(" where the causal claim actually gets tested.")
return {}
Reading the implementation
Three user models: as specified, position bias twice as steep, and position bias removed entirely. Same policies, same data-generating process otherwise.
The margin column is doing more work than the winner column, and adding it was the point of the revision: greedy's edge is 23% under the specified bias, 20% at double, and 1% with bias removed.
What the numbers say
Output:
A/B tests answer 'which of these two shipped policies wins?'.
Simulators answer 'what would have happened under a policy nobody
ran?' -- and that answer is only as good as the user model.
user model perturbation greedy Thompson winner margin
as specified 120.5 97.8 greedy 23%
position bias 2x steeper 61.9 51.5 greedy 20%
position bias flat (no bias) 419.2 415.3 greedy 1%
The WINNER is stable across all three user models even though the
absolute numbers move by 7x. That is the claim a simulator can
support: ordinal, not cardinal. Quote 120.5 clicks/user to a
stakeholder and you are quoting your own assumptions back at them.
But read the margin column, because it is doing more work than the
winner column. Greedy's edge is 23% under the specified bias, 20%
when the bias doubles -- and 1% when it is removed entirely. That is
an INDEPENDENT confirmation of block 6: greedy wins here because
exploration is expensive at rank 0, so deleting position bias very
nearly deletes greedy's advantage. Two blocks, two methods, one
mechanism. A sensitivity table is not defensive paperwork; it is
where the causal claim actually gets tested.
That is an independent confirmation of block 6. If greedy wins because exploration is expensive at rank 0, then deleting position bias should very nearly delete greedy's advantage — and it does. Two blocks, two methods, one mechanism. A sensitivity table is not defensive paperwork; it is where the causal claim gets tested.
Beyond the toy
What a simulator can and cannot support:
- Can: ordinal claims. "Policy A beats policy B across every plausible user model" is a defensible statement, and the sensitivity table is the evidence.
- Cannot: cardinal claims. The absolute numbers move 7× across these three models. Quoting "120.5 clicks per user" to a stakeholder is quoting your own assumptions back at them.
The calibration protocol that makes a simulator citable: state the model at the top, fit its free parameters to a real log, verify it reproduces a held-out period it was not fit on, and report the sensitivity sweep alongside every result. Without the held-out check, a simulator is a hypothesis-generating toy; with it, it is evidence.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSeven blocks = a simulator. Run four policies for 60 days.\n")
print(f" {'policy':<24}{'cum clicks/user':>17}{'day-60 rate':>13}"
f"{'distinct items':>16}{'gini':>7}")
def gini(x):
x = np.sort(x[x >= 0]); n = len(x)
return float((2*np.arange(1, n+1) - n - 1) @ x / (n * max(x.sum(), 1e-9)))
for lbl, pol, ex in (("greedy", s["greedy"], 0.0),
("eps-greedy 0.02", s["greedy"], 0.02),
("eps-greedy 0.10", s["greedy"], 0.10),
("UCB1", s["ucb"], 0.0),
("Thompson", s["thompson"], 0.0)):
h, c, im = s["simulate"](pol, explore=ex)
print(f" {lbl:<24}{sum(x for x,_ in h):>17.1f}{h[-1][0]:>13.3f}"
f"{h[-1][1]:>16}{gini(im):>7.3f}")
h, c, im = s["simulate"](None, order_fn=s["slot_aware"])
print(f" {'Thompson, ranks 1-9':<24}{sum(x for x,_ in h):>17.1f}{h[-1][0]:>13.3f}"
f"{h[-1][1]:>16}{gini(im):>7.3f}")
print("\n The gini column is exposure inequality across the catalogue: 0 = every")
print(" item shown equally, 1 = one item takes everything. Read the two ends")
print(" first: greedy earns the most clicks of the pure policies and starves the")
print(" catalogue (gini 0.91, 96 items alive); UCB1 keeps all 600 items alive and")
print(" pays 16% of the clicks for it. There is no free lunch on that axis.")
print(" The interesting rows are the two in between. eps=0.02 beats greedy on")
print(" clicks AND on coverage -- a strict improvement, available for free. And")
print(" the last row buys 5.5x greedy's catalogue for 7% of its clicks, because")
print(" it explores in the slots nobody looks at. Exposure fairness is not")
print(" bought at a fixed exchange rate; the rate depends on where you spend.")
print(" None of this is measurable with an A/B test, because no one runs a")
print(" knowingly worse policy for sixty days to find the shape of a curve.")
print("\n Built: user model -> position bias -> IPS -> feedback loop -> epsilon ->")
print(" Thompson/UCB -> counterfactual sensitivity.")
print(" Missing, on the project page: user arrival and churn dynamics (m4),")
print(" a two-sided marketplace with supplier utility (m8), off-policy")
print(" evaluation against logged data with estimated propensities (m9-m10),")
print(" and E6 -- the calibration experiment where you fit the simulator to a")
print(" real log and check whether it reproduces a held-out week.")
Output:
Seven blocks = a simulator. Run four policies for 60 days.
policy cum clicks/user day-60 rate distinct items gini
greedy 120.5 2.007 96 0.913
eps-greedy 0.02 126.8 2.277 88 0.874
eps-greedy 0.10 119.0 2.013 331 0.740
UCB1 100.7 1.675 600 0.242
Thompson 97.8 1.667 544 0.415
Thompson, ranks 1-9 112.0 1.960 532 0.448
The gini column is exposure inequality across the catalogue: 0 = every
item shown equally, 1 = one item takes everything. Read the two ends
first: greedy earns the most clicks of the pure policies and starves the
catalogue (gini 0.91, 96 items alive); UCB1 keeps all 600 items alive and
pays 16% of the clicks for it. There is no free lunch on that axis.
The interesting rows are the two in between. eps=0.02 beats greedy on
clicks AND on coverage -- a strict improvement, available for free. And
the last row buys 5.5x greedy's catalogue for 7% of its clicks, because
it explores in the slots nobody looks at. Exposure fairness is not
bought at a fixed exchange rate; the rate depends on where you spend.
None of this is measurable with an A/B test, because no one runs a
knowingly worse policy for sixty days to find the shape of a curve.
Built: user model -> position bias -> IPS -> feedback loop -> epsilon ->
Thompson/UCB -> counterfactual sensitivity.
Missing, on the project page: user arrival and churn dynamics (m4),
a two-sided marketplace with supplier utility (m8), off-policy
evaluation against logged data with estimated propensities (m9-m10),
and E6 -- the calibration experiment where you fit the simulator to a
real log and check whether it reproduces a held-out week.
The design space
A simulator is a claim about counterfactuals, and there are only three ways to make one. They differ in what they assume and what they can be wrong about.
| Approach | Assumes | Answers | Fails when |
|---|---|---|---|
| Off-policy evaluation (IPS, SNIPS, DR) | logging policy's propensities are known and non-zero everywhere | "what would policy \(\pi\) have scored on logged data?" | \(\pi\) differs a lot from the logger (variance explodes) |
| Simulation | a generative user model | "what happens over months under \(\pi\)?" | the user model is wrong in a way that matters |
| A/B test (P10) | nothing about users | "did \(\pi\) beat \(\pi_0\) for real users?" | you need to run it, on real users, for real time |
The three are complementary and ordered by cost and by trustworthiness in opposite directions. Simulation is the only one that can answer long-horizon questions — feedback loops, catalogue collapse, supplier churn — because no one will run a knowingly worse policy on real traffic for sixty days.
Off-policy estimators, precisely
With logged data \((x, a, r)\) collected under \(\mu\), the IPS estimate of policy \(\pi\) is
\[ \hat{V}_{\text{IPS}}(\pi) = \frac{1}{n}\sum_i \frac{\pi(a_i|x_i)}{\mu(a_i|x_i)} r_i \]
which is unbiased when \(\mu > 0\) wherever \(\pi > 0\). Its variance scales with the squared importance ratio, so a single action with \(\mu = 0.001\) and \(\pi = 1\) contributes a weight of 1000 and dominates the estimate. The standard repairs:
- Clipping / capping weights at some \(M\): trades bias for variance.
- Self-normalised IPS (SNIPS): divide by the sum of weights; consistent rather than unbiased, and much lower variance.
- Doubly robust: combine a reward model \(\hat{r}\) with IPS on the residual. Unbiased if either the model or the propensities are right — which is why it is the default in practice.
Block 3 measures the estimator with known propensities, which is the situation that exists only in a simulator. In production you estimate \(\hat{\mu}\), and the variance of \(1/\hat{\mu}\) at small \(\hat{\mu}\) is what destroys the estimate.
Position bias, and why logs cannot be read naively
Block 2's measurement — CTR at rank 0 is many times rank 19 for items chosen uniformly at random — is the cleanest statement of the problem. The click model zoo formalises it:
| Model | Assumption |
|---|---|
| Position-based (PBM) | \(P(\text{click}) = P(\text{examine} \mid \text{rank}) \cdot P(\text{relevant})\) |
| Cascade | user scans top-down, stops at first click |
| Dynamic Bayesian Network | cascade + a satisfaction probability after the click |
| Click chain / UBM | examination depends on rank and distance from last click |
Estimating examination probabilities without random serving requires either result randomisation (expensive, hurts users) or intervention harvesting (exploiting natural rank variation of the same item across queries), or a jointly estimated model like regression-EM. Every one of these is an attempt to recover the propensity that block 3 simply knows.
What the blocks found that the textbook does not say
Thompson sampling loses here, and the mechanism is measurable: under \(1/(1+r)\) examination, rank 0 carries 34% of all attention, so a policy that randomises all ten slots spends its most valuable slot on an uncertain item every impression. Exploring only in ranks 1--9 recovers 93% of greedy's clicks while keeping 532 of 544 items alive.
Block 7 then confirms the same mechanism from a different direction: greedy's margin is 23% under the specified bias, 20% at double the bias, and 1% with bias removed. Two methods, one mechanism.
The generalisable rule: a bandit benchmarked without a position model reports the wrong winner, because the cost of exploration is not uniform across the slate. This is why production systems explore in the tail of the slate, and it is invisible in any bandit formulation that treats an impression as a single action.
Advanced algorithms
- Contextual bandits: LinUCB and Thompson sampling with linear payoffs give \(\tilde{O}(d\sqrt{T})\) regret; the practical obstacle is that the reward model must be updated online, which conflicts with batch training infrastructure.
- Slate bandits and combinatorial actions. The action space is \(\binom{N}{k}\) ordered slates; pseudo-inverse estimators (Swaminathan et al.) exploit linearity assumptions to make off-policy evaluation of slates tractable.
- Counterfactual risk minimisation (POEM) optimises a variance-regularised IPS objective directly, rather than evaluating a policy after the fact.
- Two-sided marketplaces: adding supplier utility turns exposure inequality (the gini column in the assembly) from an aesthetic concern into a churn model. Fairness-of-exposure formulations (Singh & Joachims) make the exposure allocation an explicit constrained optimisation.
- Agent-based / LLM-driven user simulators are the current research frontier; the calibration problem — does the simulator reproduce a held-out week? — is unchanged and is the only thing that makes such a simulator credible.
Calibration: the only thing that makes a simulator citable
A simulator's assumptions are its results, so the sensitivity table in block 7 is not defensive paperwork — it is where the causal claim gets tested. The discipline:
- State the user model at the top, in three lines, as block 1 does.
- Fit the free parameters to a real log.
- Check that the simulator reproduces a held-out period it was not fit on.
- Report ordinal conclusions (which policy wins) rather than cardinal ones (how many clicks), because the absolute numbers move 7× across plausible user models while the ranking is stable.
How this connects to the rest of the track
- P08 is the system whose feedback loop this simulates.
- P10 is the ground truth this is a cheap approximation of; the two answer different questions and neither substitutes for the other.
- P07's event-time discipline is the same clock reasoning applied to logs.
- P15 closes the loop: offline hypothesis → online experiment → production monitor.
Failure modes at scale
- Simulator overfitting: tuning the user model until the policy you like wins. The defence is pre-registration of the sensitivity axes.
- Ignoring the feedback loop in the training data — the log you fit the simulator on was itself generated under a policy.
- Assuming stationarity over the horizon simulated; user preferences, catalogue and competition all move over sixty days.
- Exposure collapse presented as success: greedy maximises clicks and produces gini 0.91. A metric that does not include catalogue health will recommend catalogue destruction.
Primary sources
- Chapelle & Zhang, A Dynamic Bayesian Network Click Model (WWW 2009).
- Joachims, Swaminathan & Schnabel, Unbiased Learning-to-Rank with Biased Feedback (WSDM 2017).
- Dudík, Langford & Li, Doubly Robust Policy Evaluation and Learning (ICML 2011).
- Swaminathan et al., Off-policy Evaluation for Slate Recommendation (NIPS 2017).
- Chapelle & Li, An Empirical Evaluation of Thompson Sampling (NIPS 2011).
- Ie et al., RecSim (2019).
- Singh & Joachims, Fairness of Exposure in Rankings (KDD 2018).
Running it
python3 handson/h09_simulator.py # every block, then the assembly
python3 handson/h09_simulator.py --block 3 # just block 3 and its prerequisites
python3 handson/h09_simulator.py --quiet # the assembly only
What to do with this
Add supplier-side utility and re-run every policy. A marketplace has two populations whose interests do not coincide, and exposure inequality --- the gini column that greedy maximises --- stops being an aesthetic concern and becomes the thing that drives sellers off the platform. That is a question no offline metric can answer and a simulator can.
Milestones, experiments, readings and exit criteria for this project: P09 — Recommendation Simulator.
P10 — Online Experimentation and A/B Testing Platform
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P10 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Small · 44 hours · Weeks 96–99 · Stage 4 · Python
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- The Four Statistical Facts
- Showcase — Build the A/A Test First
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Decide whether a change is an improvement, from noisy data, without fooling yourself |
| 2. Constraints | Users must get a consistent experience. Effects are small relative to variance. You will be tempted to look early |
| 3. Naive design | Yours. Most people build: hash(user_id) % 2, count clicks, run a t-test, ship if p < 0.05 |
| 4. Predicted failure | That design has at least four defects. Name them before reading The Four Statistical Facts |
| 5. Minimal implementation | Deterministic bucketing, exposure logging, one metric, one t-test |
| 6. Correctness | An A/A test produces a significant result ~5% of the time. Verify it |
| 7. Instrumentation | Assignment counts, exposure counts, metric variance, achieved power |
| 8. Baseline | The A/A test. It is the calibration for everything |
| 9. Bottleneck | Not compute — statistical power. Your effect may be undetectable at any feasible sample size |
| 10. Hypothesis | Offline metric improvements predict simulated online outcomes — with a correlation you can state |
| 11. Modification | Run the same variants offline (P08) and online (P09) |
| 12. Experiment | Rank-correlate offline and online deltas across ≥10 variants |
| 13. Failure analysis | For each divergence, name the mechanism |
| 14. Report | When offline metrics fail to predict behaviour, and why |
Why This Project Matters
This is the shortest project in the journey and possibly the highest-leverage one, because it is the only place where the thing you learn is a constraint on what questions can be answered at all.
The minimum detectable effect calculation tells you, before you write any code, whether a proposed experiment is even runnable at your traffic. Most teams discover this after building the variant and running for three weeks. Doing the arithmetic first changes which projects you propose.
The second reason is the offline/online gap. P08 measured offline metrics. P09 simulated online behaviour. This project is where you find out that they disagree, and quantifying that disagreement is the most professionally useful result in Stage 4 — it is the answer to "why did the model that won offline not win in the A/B test", which is the single most common frustrating conversation in applied ML.
Prerequisites
- P09 complete — the simulated population is what you assign and analyse
- From
math.md: §Hypothesis Testing (2 h), §Power and Sample Size (2 h) tools/metrics.pyalready implements Welch's t, power sizing and SRM
Duration and Size
Small, 44 hours, 4 weeks. Short because it sits on P09's harness. If you have to cut scope in Stage 4, this is the project to fold into P09 as a module — see the cut table.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Deterministic bucketing, treatment/control, exposure logging, one primary metric, Welch's t, an A/A test that calibrates at 5%. | 20 |
| Standard | + eligibility rules, guardrail metrics, SRM detection, power/MDE calculator, multiple-comparison correction, a peeking study, novelty-effect modelling, interference detection, stopping rules, the offline/online correlation study. | 44 |
| Extension | Sequential testing (always-valid p-values / mSPRT); or CUPED variance reduction with a measured reduction factor. | +20–30 |
Central Technical Questions
- How small an effect can you detect with your traffic in a reasonable time? Do the arithmetic before building anything.
- Why is peeking so dangerous, and by how much? Quantify it — the number surprises people.
- What does a sample-ratio mismatch mean, and why does it invalidate the experiment rather than merely warn you?
- When do offline metrics predict online outcomes, and when do they systematically fail?
- What is a guardrail metric for, and what should happen when one moves?
- What is interference, and does it apply to a recommender? (It does — via shared inventory and popularity feedback.)
Architecture
user_id ──► eligibility check ──► bucket = hash(user_id + experiment_salt) % 10000
│
┌─────────────────────┴──────────────────────┐
▼ ▼
control (0–4999) treatment (5000–9999)
│ │
└──────────── exposure log ──────────────────┘
(user, experiment, arm, timestamp)
│
metric events ─────────────────────►│
▼
┌──── analysis pipeline ─────┐
│ SRM check (gate) │
│ primary metric + CI │
│ guardrails │
│ segments │
│ multiple-comparison adj. │
│ power achieved │
└────────────────────────────┘
Two design points that are easy to get wrong:
Bucket on a stable id with a per-experiment salt. Hashing user_id alone means
every experiment splits the population the same way, so experiment 2 inherits
experiment 1's imbalance. Salting per experiment decorrelates them. Bucketing to 10,000
buckets rather than 2 lets you run a 1% ramp without re-randomising.
Log exposure, not assignment. A user assigned to treatment who never saw a recommendation contributes noise, not signal. Analysing on assignment (intent-to-treat) is unbiased but low-powered; analysing on exposure is higher-powered and biased if exposure depends on the arm. Know which you are doing and say so. The standard safe choice is to trigger the experiment at the point where the arms first diverge.
The Four Statistical Facts
Everything else in this project follows from these. All four numbers below were produced by scripts in this repository.
1. Halving the detectable effect quadruples the sample
\[ n_{\text{per arm}} = \frac{2(z_{1-\alpha/2} + z_{\text{power}})^2 \sigma^2}{\delta^2} \]
At α=0.05, power=0.8, the bracket is \((1.96 + 0.8416)^2 = 7.849\), which is where
the folklore "16σ²/δ²" comes from. Computed for σ=0.5
(tools/metrics.py):
| MDE δ | n per arm |
|---|---|
| 0.05 | 1,570 |
| 0.025 | 6,280 |
| 0.0125 | 25,117 |
Exactly 4× per halving. Do this calculation before proposing an experiment. If your realistic effect is 0.5% and your traffic gives you 20,000 users a week, the experiment needs a year and should not be run.
2. Peeking destroys your false-positive rate
Simulated A/A tests (4,000 trials, 500 users per look, α=0.05, stop at first significant result):
| looks | false-positive rate |
|---|---|
| 1 | 5.12% |
| 2 | 8.58% |
| 5 | 14.47% |
| 10 | 19.30% |
| 20 | 24.15% |
| 50 | 32.80% |
Checking a dashboard daily for a fortnight turns a 5% error rate into roughly 25%. One in four "wins" is noise. This is not a subtle statistical nicety; it is the single largest source of false results in industrial experimentation, and the fix is either a fixed sample size decided in advance or an explicitly sequential method.
3. Sample-ratio mismatch invalidates, it does not warn
Chi-square with 1 d.f.; the conventional alarm is p < 0.001, i.e. χ² > 10.83:
| observed split | χ² | verdict |
|---|---|---|
| 200,000 / 200,000 | 0.000 | ok |
| 201,000 / 199,000 | 10.000 | ok (just) |
| 202,000 / 198,000 | 40.000 | ALARM |
A 0.5% imbalance on 400k users is a five-sigma event. It means assignment, logging, or filtering differs between arms — so the two populations are not comparable and no analysis of the metric is valid. SRM is a gate before analysis, not a footnote after it.
4. Twenty metrics, one false positive
Testing \(m\) metrics at α=0.05, the probability of at least one false positive is \(1 - 0.95^m\): 22.6% at m=5, 40.1% at m=10, 64.2% at m=20. Declare one primary metric in advance; treat everything else as guardrails (one-sided, looking for harm) or as exploratory (reported without claims).
Showcase — Build the A/A Test First
Thirty minutes. The A/A test is the foundation of everything else in this project, and the defect it catches is the most common one in real experimentation platforms.
# P10 -- the A/A test. Build this before anything else; it calibrates the rest.
import random, math
Z = 1.959963984540054
def welch(a, b):
na, nb = len(a), len(b)
ma, mb = sum(a)/na, sum(b)/nb
va = sum((x-ma)**2 for x in a)/(na-1); vb = sum((x-mb)**2 for x in b)/(nb-1)
return abs(mb-ma)/math.sqrt(va/na + vb/nb)
def aa_rate(n_per_arm, trials, correlated=False, seed=0):
rng = random.Random(seed); hits = 0
for _ in range(trials):
if correlated: # BUG: randomise by session, analyse by user
a = [v for _ in range(n_per_arm//5) for v in [rng.gauss(0,1)]*5]
b = [v for _ in range(n_per_arm//5) for v in [rng.gauss(0,1)]*5]
else:
a = [rng.gauss(0,1) for _ in range(n_per_arm)]
b = [rng.gauss(0,1) for _ in range(n_per_arm)]
if welch(a,b) > Z: hits += 1
return hits/trials
print(f"correct A/A (independent observations): {aa_rate(500, 3000)*100:5.2f}% <- must be ~5%")
print(f"A/A with correlated observations: {aa_rate(500, 3000, True)*100:5.2f}% <- inflated")
print("\\nThe second row is the most common real defect: randomising by session but")
print("analysing by user. Each user contributes 5 correlated rows, the variance")
print("estimate is too small, and the test over-rejects.")
print("\\nIf your A/A does not calibrate at 5%, NOTHING downstream is trustworthy.")
print("Debug this before you build a single variant.")
correct A/A (independent observations): 5.37% <- must be ~5%
A/A with correlated observations: 38.30% <- inflated
\nThe second row is the most common real defect: randomising by session but
analysing by user. Each user contributes 5 correlated rows, the variance
estimate is too small, and the test over-rejects.
\nIf your A/A does not calibrate at 5%, NOTHING downstream is trustworthy.
Debug this before you build a single variant.
38% against a nominal 5%. Randomising by session and analysing by user is not an exotic mistake; it is what happens by default when the logging table has one row per event. An A/A test catches it in an afternoon, and nothing else will.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Deterministic bucketing with per-experiment salt; uniformity test | 4 | χ² over 10,000 buckets shows no imbalance |
| 2 | Experiment config: arms, allocation, eligibility, salt, primary metric | 4 | Declared in a file, versioned, immutable once started |
| 3 | Exposure logging and triggered analysis | 4 | Only triggered users enter the analysis |
| 4 | Metric pipeline: primary, guardrails, segments | 4 | Reuses tools/metrics.py |
| 5 | A/A test harness | 4 | 1,000 A/A runs produce ~5% significance. This calibrates everything |
| 6 | SRM detection as a hard gate | 3 | Analysis refuses to report when χ² > 10.83 |
| 7 | Power/MDE calculator + a pre-registration document generator | 3 | Produces required-n before the experiment runs |
| 8 | The peeking study | 4 | Table above reproduced with your own harness |
| 9 | Multiple-comparison correction (Benjamini–Hochberg) | 3 | Applied to guardrails |
| 10 | Novelty-effect modelling and detection | 4 | Effect decaying over time is detected, not averaged away |
| 11 | Interference detection (shared inventory, popularity feedback) | 3 | Cluster-randomised comparison against user-randomised |
| 12 | Offline/online correlation study | 4 | ≥10 variants ranked both ways |
Concepts To Study
- Randomisation unit: user, session, request — and why the wrong choice creates correlated observations that break the variance estimate
- Deterministic hashing and salt; ramping without re-randomisation
- Intent-to-treat vs triggered analysis
- Guardrail metrics: latency, coverage, error rate, revenue — things that must not get worse even if the primary metric improves
- Sample-ratio mismatch and its causes: bot filtering, arm-dependent crashes, logging loss
- Statistical power, MDE, and the 4× law
- The peeking problem; fixed-horizon vs sequential testing; α-spending
- Multiple comparisons: family-wise error vs false discovery rate
- Novelty and primacy effects: a change can win in week 1 and lose in week 4
- Interference / SUTVA violation: in recommenders, arms compete for the same inventory and the same popularity signals
- Variance reduction: CUPED, stratification, common random numbers
- Stopping rules and the pre-registration discipline
Primary-Source Readings
Budget: 9 hours.
| Reading | Why | Hours |
|---|---|---|
| Kohavi, R., Tang, D., Xu, Y. Trustworthy Online Controlled Experiments. Cambridge, 2020 | Chapters 1–3, 17–19. The book on this | 3 |
| Kohavi, R. et al. Online Controlled Experiments at Large Scale. KDD 2013 | SRM, twyman's law, the real failure modes | 1.5 |
| Deng, A., Xu, Y., Kohavi, R., Walker, T. Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data. WSDM 2013 | CUPED; the extension | 1.5 |
| Johari, R., Koomen, P., Pekelis, L., Walsh, D. Peeking at A/B Tests. KDD 2017 | Always-valid inference; the principled fix for fact 2 | 1.5 |
| Kohavi, R., Longbotham, R. Unexpected Results in Online Controlled Experiments. SIGKDD Explorations 12(2), 2010 | Case studies where intuition lost | 1 |
| Gupta, S. et al. Top Challenges from the first Practical Online Controlled Experiments Summit. SIGKDD Explorations 21(1), 2019 | What the industry finds hard | 0.5 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | A/A calibration | 1,000 runs | Exactly 5% significant. Deviation = bug |
| E2 | Bucketing uniformity | 10⁶ ids over 10,000 buckets | χ² consistent with uniform |
| E3 | Salt independence | two experiments, same population | Assignment correlation ≈ 0 |
| E4 | Peeking | 1–50 looks | Reproduce the FPR table |
| E5 | Power validation | known injected effect × n | Detection rate matches nominal power |
| E6 | MDE vs traffic | required days for δ ∈ {5%,2%,1%,0.5%} | Which experiments are infeasible? |
| E7 | SRM sensitivity | injected imbalance 0.1%–2% | Detection threshold vs n |
| E8 | Guardrails | a variant that improves CTR and hurts latency | Guardrail must catch it |
| E9 | Novelty effect | effect decaying over 4 weeks | Week-1 conclusion vs week-4 conclusion |
| E10 | Interference | user-randomised vs cluster-randomised, shared inventory | Predict the bias direction |
| E11 | Multiple comparisons | 20 metrics, no real effect | ~64% show one false positive; BH controls it |
| E12 | Offline vs online | ≥10 P08 variants | Spearman correlation. Predict it before running |
| E13 | Variance reduction | CUPED on/off | Predict the reduction; typically 20–50% |
E12 is the headline. Take at least ten variants from P08 — different α, different diversity λ, different freshness τ — and rank them by offline NDCG and by simulated online engagement. Compute the rank correlation.
Predict it first. If ρ ≈ 1, offline evaluation is sufficient and your P08 work stands. If ρ ≈ 0.3, offline metrics are nearly useless for choosing between these variants, and that is a finding worth writing up carefully — it is the quantitative version of an argument that is usually had with anecdotes. Either result is publishable-adjacent; the interesting part is diagnosing the specific variants that flip and naming the mechanism for each.
Benchmarks and Metrics
This project's "benchmarks" are statistical properties, not performance numbers:
| Property | Target |
|---|---|
| A/A false-positive rate | 5.0% ± sampling error at 1,000 runs |
| Bucket uniformity | χ² p-value uniform over repeated salts |
| SRM detection rate | ≥99% at 1% imbalance, n=10⁵ |
| Achieved power | Within 5% of nominal at a known effect |
| Peeking FPR inflation | Reproduces the table |
| Guardrail catch rate | 100% on injected regressions |
| Offline/online rank correlation | Measured and reported, whatever it is |
Also report the operational numbers: assignment latency (must be sub-millisecond — it is on the request path), exposure-log volume, and analysis runtime.
Correctness Tests
- A/A test calibrates at 5%. The single most important test. If it does not, every other result is void.
- Deterministic assignment: the same user always gets the same arm for a given experiment.
- Salt independence: assignment in experiment A is uncorrelated with experiment B.
- Allocation accuracy: a 90/10 split produces 90/10 within sampling error.
- Eligibility respected: ineligible users never appear in the analysis.
- Exposure logging is exactly-once per user per experiment per period.
- SRM gate blocks analysis — it must be impossible to read the primary metric on an SRM'd experiment. Enforce in code, not in policy.
- Metric computations verified against hand-computed examples.
- Immutable config: changing allocation mid-experiment is rejected, or forces a restart with a new salt.
- Power calculation verified by simulation with a known effect.
Failure Tests
| Injection | Required behaviour |
|---|---|
| One arm's logging drops 1% of events | SRM fires; analysis blocked |
| Treatment crashes for 2% of users | SRM fires (they stop appearing) |
| A bot generating 10% of traffic in one arm | Detected via outlier analysis |
| An experiment restarted with the same salt | Users keep prior assignment; contamination flagged |
| Two overlapping experiments on the same surface | Interaction detected or explicitly accepted |
| Metric with an extreme outlier | Robust estimator or documented capping |
| Zero exposures in one arm | Clean error, not a divide-by-zero |
| Experiment run past its pre-registered horizon | Flagged as peeking |
Expected Difficulties
- The A/A test will not calibrate at 5% on your first attempt. Usual causes: correlated observations (randomising by session but analysing by user), an unstable metric, or a bug in the variance estimate. Debug this before anything else — it is the foundation.
- You will want to peek. Build the tooling to make peeking impossible rather than discouraged: analysis refuses to run before the pre-registered sample size.
- Effects in the simulator may be enormous, making everything trivially significant. Deliberately tune to realistic effect sizes (0.5–2% relative) or the project teaches nothing.
- Interference is genuinely hard to detect. Cluster randomisation is the standard approach; expect this milestone to be the roughest.
- This is a Small project and the statistics are deep. Resist expanding it. The sequential-testing rabbit hole in particular is an extension, not a milestone.
Scope Boundaries
In scope: assignment, exposure, metrics, guardrails, SRM, power, multiple comparisons, novelty, interference, stopping rules, offline/online comparison — all against the simulated population.
Out of scope: real users; a production feature-flag service; a UI; Bayesian experimentation (mention only); bandits as an experimentation method; causal inference beyond randomised experiments; a metric warehouse.
Deliverables
abtest/— assignment, logging, analysis, pre-registration generatorPRE-REGISTRATION.mdtemplate — hypothesis, primary metric, MDE, required n, horizon, stopping rule, guardrails. Signed before the experiment. This template is the most directly transferable artifact in the projectREPORT.mdcentred on E12 (offline vs online) and E4 (peeking)- Notebook entries for E4, E9, E12
- The four statistical facts reproduced with your own code
Exit Criteria
- A/A test calibrates at 5% over ≥1,000 runs
- SRM gate blocks analysis, verified by injection
- E4 complete: peeking FPR table reproduced
- E5 complete: achieved power matches nominal at a known effect
- E8 complete: a guardrail catches an injected regression
- E12 complete: ≥10 variants ranked offline and online, rank correlation reported, divergences diagnosed individually
- Pre-registration template written and used for every experiment in the project
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Sequential testing (mSPRT or always-valid p-values) so peeking becomes legitimate. Measure the sample-size cost of that legitimacy — it is not free.
- CUPED: use pre-experiment data to reduce variance; measure the reduction and translate it into experiment-days saved.
- Switchback experiments for interference-heavy surfaces.
- Heterogeneous treatment effects: which user segments respond differently, with honest multiple-comparison handling.
Connections
Backward: P09 supplies the population and the harness. P08 supplies the variants.
Forward:
- → P15: "can simulated users predict the relative performance of ranking algorithms?" is answered by E12 plus P09's E2. If you pick that research question, this project is half the paper
References
- Kohavi, R., Tang, D., Xu, Y. Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing. Cambridge University Press, 2020.
- Kohavi, R., Deng, A., Frasca, B., Walker, T., Xu, Y., Pohlmann, N. Online Controlled Experiments at Large Scale. KDD 2013.
- Deng, A., Xu, Y., Kohavi, R., Walker, T. Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data. WSDM 2013.
- Johari, R., Koomen, P., Pekelis, L., Walsh, D. Peeking at A/B Tests: Why it matters, and what to do about it. KDD 2017.
- Kohavi, R., Longbotham, R. Unexpected Results in Online Controlled Experiments. SIGKDD Explorations 12(2), 2010.
- Gupta, S. et al. Top Challenges from the first Practical Online Controlled Experiments Summit. SIGKDD Explorations 21(1), 2019.
- Benjamini, Y., Hochberg, Y. Controlling the False Discovery Rate. JRSS B 57(1), 1995.
- Fisher, R. A. The Design of Experiments. Oliver & Boyd, 1935. Still the clearest statement of why randomisation works.
- Imbens, G. W., Rubin, D. B. Causal Inference for Statistics, Social, and Biomedical Sciences. Cambridge, 2015. For SUTVA and interference.
P10 hands-on — A/B testing platform, block by block
Peeking, SRM, CUPED, and the type-M error that inflates every underpowered win.
Source:
handson/h10_abtest.py--- run it withpython3 handson/h10_abtest.py
Full project spec: P10 — A/B Testing Platform
The statistics in an experimentation platform are twelve lines. Everything else --- and everything that makes it valuable --- is process enforced by software instead of remembered by people under launch pressure.
This file builds both. Deterministic hash assignment, a sample-size calculator that kills impossible tests before anyone writes the feature, an A/A calibration suite that validates the platform against itself, and then the four failure modes that produce most false launches: peeking, sample-ratio mismatch, uncorrected multiple comparisons, and underpowered tests whose surviving estimates are inflated by construction.
The assembly runs one experiment end to end in the order a real launch uses: power, health checks, one primary metric, variance reduction --- and a note on what peeking would have done to it.
Contents
- Block 1 — Assignment: deterministic, not random
- Block 2 — Sample size before the test
- Block 3 — The t-test, and what it promises
- Block 4 — Peeking
- Block 5 — Sample ratio mismatch
- Block 6 — Variance reduction with CUPED
- Block 7 — Multiple metrics, multiple arms
- Block 8 — Power, honestly
- The assembly
- The design space
- The variance-reduction arithmetic
- Sample size, power, and the type-M error
- Interference: when SUTVA breaks
- Advanced topics
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Assignment: deterministic, not random
Teaches: the same user must get the same arm forever
The problem. Assignment looks like the trivial part and is where the highest-severity bugs live, because an assignment defect invalidates every number downstream and is invisible in the metric.
@block(1, "Assignment: deterministic, not random", "the same user must get the same arm forever")
def b1(s, show):
def assign(uid, exp, arms=2, salt="v1"):
h = hashlib.sha256(f"{exp}:{salt}:{uid}".encode()).digest()
return int.from_bytes(h[:8], "big") % arms
if show:
N = 200_000
a = np.array([assign(f"u{i}", "checkout") for i in range(N)])
print(f" {N} users hashed into 2 arms: "
f"{[int((a==k).sum()) for k in (0,1)]} "
f"(imbalance {abs((a==0).mean()-.5)*100:.3f}%)")
again = [assign(f"u{i}", "checkout") for i in range(1000)]
print(f" re-assigning the first 1000: identical = "
f"{list(a[:1000]) == again}")
b = np.array([assign(f"u{i}", "banner") for i in range(N)])
print(f" correlation with a SECOND experiment's assignment: "
f"{np.corrcoef(a, b)[0,1]:+.4f}")
print(" Deterministic hashing gives three properties at once: a returning")
print(" user sees a consistent experience, no assignment table has to be")
print(" stored, and two experiments are independent because the experiment")
print(" name is inside the hash. Seeding an RNG per request gives you none")
print(" of these -- and the bug is invisible until someone reloads a page.")
return {"assign": assign}
Reading the implementation
hash(experiment + salt + user_id) % arms — deterministic, stateless, and
independent across experiments. Three properties fall out of one line:
- Consistency. A returning user gets the same arm on every request, on every device, forever. Seeding an RNG per request gives none of this, and the bug is invisible until someone reloads a page.
- No state. No assignment table to store, replicate, or keep consistent across regions. The assignment is recomputable anywhere from the user id alone.
- Independence between experiments, because the experiment name is inside the hash. Without it, users who got arm A in experiment 1 systematically get arm A in experiment 2, and the two experiments confound each other.
The choice of hash matters. It must be cryptographic or at least well-
distributed — SHA-256 here — because a weak hash (Java's String.hashCode, or
hash() on a short id) has correlated low bits, so % 2 splits on a pattern
rather than at random. The measured correlation between two experiments'
assignments is the check that this worked.
What the numbers say
Output:
200000 users hashed into 2 arms: [99729, 100271] (imbalance 0.135%)
re-assigning the first 1000: identical = True
correlation with a SECOND experiment's assignment: -0.0013
Deterministic hashing gives three properties at once: a returning
user sees a consistent experience, no assignment table has to be
stored, and two experiments are independent because the experiment
name is inside the hash. Seeding an RNG per request gives you none
of these -- and the bug is invisible until someone reloads a page.
Beyond the toy
- The randomisation unit is a design decision with statistical consequences. User-level is standard. Session-level gives more units and therefore more power, but a user in both arms sees an inconsistent experience and the units are not independent. Cluster-level (household, company, city) is necessary when interference exists and costs a great deal of power.
- The salt enables re-randomisation. Running a follow-up on the same population with the same salt gives the same split, so carryover effects persist. Changing the salt reshuffles.
- Client-side assignment leaks into timing. If the client decides the arm, the decision happens after page load, so users on slow connections are under-represented in whichever arm loads more slowly — which is a sample-ratio mismatch (block 5) caused by the assignment mechanism itself.
Block 2 — Sample size before the test
Teaches: the number that decides whether the test is worth running
The problem. The most valuable artefact an experimentation platform produces is not a p-value. It is the table that tells you a proposed test is impossible before anyone builds the feature.
@block(2, "Sample size before the test", "the number that decides whether the test is worth running")
def b2(s, show):
def n_per_arm(p, mde_rel, alpha=0.05, power=0.8):
z_a, z_b = 1.959964, 0.841621
d = p * mde_rel
return math.ceil(2 * (z_a + z_b) ** 2 * p * (1 - p) / d ** 2)
if show:
print(f" baseline conversion 5%, alpha=0.05, power=0.80:")
print(f" {'relative MDE':>14}{'n per arm':>12}{'days @ 20k/day/arm':>22}")
for mde in (0.20, 0.10, 0.05, 0.02, 0.01):
n = n_per_arm(0.05, mde)
print(f" {mde:>13.0%}{n:>12,}{n/20_000:>22.1f}")
print(" n scales as 1/MDE^2: detecting a 1% lift instead of a 2% one costs")
print(" 4x the traffic, not 2x. This table is the single most useful artefact")
print(" an experimentation platform produces, because most proposed tests are")
print(" revealed as impossible BEFORE anyone builds the feature.")
return {"n_per_arm": n_per_arm}
Reading the implementation
\[ n = \frac{2(z_{\alpha/2}+z_{\beta})^2,p(1-p)}{\delta^2} \]
The \(1/\delta^2\) is the entire story: halving the detectable effect quadruples the required traffic. Detecting a 1% lift instead of 2% costs 4× the users, not 2×.
The inputs, and which are actually negotiable: \(\alpha\) (0.05 by convention, rarely moved), power (0.80 by convention, and 0.80 means you miss one in five real effects), baseline rate \(p\) (a fact about your product), and the MDE \(\delta\) (the only genuinely free parameter, and the one that should be set by what lift would justify the engineering cost).
That last framing is the useful one. The MDE should come from a business threshold — "a 2% lift pays for this team's quarter" — not from what happens to be detectable. If the business-justified MDE needs more traffic than exists, the correct decision is to not run the test, and to say so before the feature is built.
What the numbers say
Output:
baseline conversion 5%, alpha=0.05, power=0.80:
relative MDE n per arm days @ 20k/day/arm
20% 7,457 0.4
10% 29,826 1.5
5% 119,303 6.0
2% 745,644 37.3
1% 2,982,574 149.1
n scales as 1/MDE^2: detecting a 1% lift instead of a 2% one costs
4x the traffic, not 2x. This table is the single most useful artefact
an experimentation platform produces, because most proposed tests are
revealed as impossible BEFORE anyone builds the feature.
Beyond the toy
- Variance reduction beats waiting. CUPED at \(\rho = 0.7\) halves the required \(n\) (block 6) — usually cheaper than doubling traffic.
- Ratio metrics need the delta method. When the analysis unit (user) differs from the metric unit (page view), the naive variance is wrong, usually understated, and the test is anti-conservative. This is the most common silent variance error in industry.
- Sequential designs change the arithmetic: an always-valid test typically needs 20--50% more samples for the same power but allows continuous monitoring, which is often the better trade in practice (block 4).
- One-sided tests for guardrails: you only care about harm, so the alternative is one-sided and you get the power back.
Block 3 — The t-test, and what it promises
Teaches: 5% false positives, by construction
The problem. Before trusting a single A/B result, verify that the platform produces the false-positive rate it promises. An A/A test is the platform's own unit test, and it validates assignment, metric pipeline and statistics in one shot.
@block(3, "The t-test, and what it promises", "5% false positives, by construction")
def b3(s, show):
rng = np.random.default_rng(10)
def welch(a, b):
ma, mb = a.mean(), b.mean(); va, vb = a.var(ddof=1), b.var(ddof=1)
na, nb = len(a), len(b)
se = math.sqrt(va/na + vb/nb)
if se == 0: return 0.0, 1.0
t = (mb - ma) / se
df = (va/na + vb/nb)**2 / ((va/na)**2/(na-1) + (vb/nb)**2/(nb-1))
# normal approximation to the t CDF is fine at these df
p = 2 * (1 - 0.5 * (1 + math.erf(abs(t) / math.sqrt(2))))
return t, p
if show:
fp = 0; T = 2000; n = 4000
for _ in range(T):
a = rng.binomial(1, 0.05, n).astype(float)
b = rng.binomial(1, 0.05, n).astype(float) # A/A: NO real effect
if welch(a, b)[1] < 0.05: fp += 1
lo, hi = fp/T - 1.96*math.sqrt(.05*.95/T), fp/T + 1.96*math.sqrt(.05*.95/T)
print(f" {T} A/A tests, no effect present, n={n} per arm")
print(f" significant at p<0.05: {fp} ({fp/T:.3%})")
print(f" expected 5.000%, 95% interval [{lo:.3%}, {hi:.3%}] -> "
f"{'calibrated' if lo <= 0.05 <= hi else 'MISCALIBRATED'}")
print(" An A/A test is the platform's own unit test. Run a few thousand")
print(" before you trust a single A/B result: it validates the assignment,")
print(" the metric pipeline, and the statistics in one shot.")
return {"welch": welch}
Reading the implementation
Run thousands of experiments where no effect exists and count how many come out significant. The answer must be \(\alpha\). Anything else means something in the stack is broken, and the A/A test does not tell you which — but it tells you that, which is the hard part.
The Welch t-test is used rather than Student's because it does not assume equal variances between arms. That assumption is frequently violated in practice (a treatment can change variance without changing the mean — a feature that helps some users and hurts others), and Welch costs nothing.
The normal approximation to the t distribution is fine at these degrees of freedom, and the block reports the 95% interval on the false-positive rate itself so "4.9%" can be judged against sampling error rather than eyeballed.
What the numbers say
Output:
2000 A/A tests, no effect present, n=4000 per arm
significant at p<0.05: 98 (4.900%)
expected 5.000%, 95% interval [3.945%, 5.855%] -> calibrated
An A/A test is the platform's own unit test. Run a few thousand
before you trust a single A/B result: it validates the assignment,
the metric pipeline, and the statistics in one shot.
Beyond the toy
What an A/A test catches that nothing else does: assignment bias (block 1), metric-pipeline bugs (a join that drops rows asymmetrically), variance mis-estimation (the ratio-metric problem above), and residual correlation between supposedly independent users. Run a few thousand before trusting any A/B result, and re-run them continuously — a "platform health" dashboard of ongoing A/A tests is standard practice at organisations that run many experiments.
The deeper point: a single experiment can never validate a process. Only the distribution over many can, which is what blocks 3, 4, 7 and 8 all do.
Block 4 — Peeking
Teaches: the most expensive statistical error in industry
The problem. The test is honest. The stopping rule is not. This is the most expensive statistical error in industry, and it is committed by people who know the statistics perfectly well.
@block(4, "Peeking", "the most expensive statistical error in industry")
def b4(s, show):
rng = np.random.default_rng(11)
def trial(peeks, n=8000, p=0.05, effect=0.0):
a = rng.binomial(1, p, n).astype(float)
b = rng.binomial(1, p*(1+effect), n).astype(float)
checks = np.linspace(n//peeks, n, peeks).astype(int)
for c in checks:
if s["welch"](a[:c], b[:c])[1] < 0.05: return True
return False
if show:
T = 2000
print(f" A/A tests again -- no effect -- but the analyst checks the dashboard")
print(f" {'times checked':>15}{'false positive rate':>22}{'inflation':>12}")
base = None
for peeks in (1, 2, 5, 10, 50):
fp = sum(trial(peeks) for _ in range(T)) / T
base = base or fp
print(f" {peeks:>15}{fp:>21.1%}{fp/base:>12.1f}x")
print(" The test is honest; the STOPPING RULE is not. Each look is another")
print(" chance for noise to cross the line, and 'we stopped when it hit")
print(" significance' converts a 5% error rate into 20%+. Fixes: fix n in")
print(" advance and do not look, use alpha-spending, or use a sequential test")
print(" that is valid at every moment (mSPRT, always-valid confidence")
print(" sequences). Anything but staring at a p-value and waiting.")
return {"trial": trial}
Reading the implementation
Simulate an analyst who checks the dashboard \(m\) times and stops at the first significant result. Each look is another opportunity for noise to cross the threshold, so the family-wise error rate is \(1 - (1-\alpha)^{\text{effective }m}\) — less than the naive product because consecutive looks are correlated, but far above 5%.
The measurement is unambiguous: five looks doubles the false-positive rate; fifty looks roughly quadruples it.
The reason "just don't look" fails as a policy is organisational rather than statistical. Dashboards exist, stakeholders read them, and a result that appears significant on day 3 will be acted on. A platform that relies on discipline it cannot enforce has chosen not to solve the problem.
What the numbers say
Output:
A/A tests again -- no effect -- but the analyst checks the dashboard
times checked false positive rate inflation
1 5.5% 1.0x
2 8.2% 1.5x
5 14.2% 2.6x
10 20.3% 3.7x
50 32.2% 5.9x
The test is honest; the STOPPING RULE is not. Each look is another
chance for noise to cross the line, and 'we stopped when it hit
significance' converts a 5% error rate into 20%+. Fixes: fix n in
advance and do not look, use alpha-spending, or use a sequential test
that is valid at every moment (mSPRT, always-valid confidence
sequences). Anything but staring at a p-value and waiting.
Beyond the toy
The two principled solutions:
- Group sequential (Pocock, O'Brien–Fleming). Pre-plan \(K\) looks and spend \(\alpha\) across them via an alpha-spending function. O'Brien–Fleming is conservative early and nearly full-\(\alpha\) at the end, which matches how people actually want to behave. Requires committing to the look schedule.
- Always-valid inference (mSPRT, confidence sequences). The interval is valid at every moment under arbitrary optional stopping, because it is built from a martingale and Ville's inequality rather than a fixed-\(n\) sampling distribution. Costs 20--50% more samples for the same power, and is the right default for a self-serve platform because it is the only option that survives contact with an organisation.
Both are ~50 lines. The reason most platforms do not have them is that nobody measured block 4's table.
Block 5 — Sample ratio mismatch
Teaches: the cheapest bug detector you will ever write
The problem. The cheapest and highest-yield check in the entire platform, and it must run before anyone reads the metric.
@block(5, "Sample ratio mismatch", "the cheapest bug detector you will ever write")
def b5(s, show):
def srm(counts, expected=None):
n = sum(counts); k = len(counts)
exp = expected or [n/k]*k
chi = sum((c-e)**2/e for c, e in zip(counts, exp))
p = math.exp(-chi/2) if k == 2 else float("nan") # chi2 df=1 survival
return chi, p
if show:
print(f" {'observed split':<26}{'chi2':>9}{'p':>10}{'verdict':>12}")
for a, b, lbl in ((50_000, 50_000, "50000 / 50000"),
(50_000, 49_800, "50000 / 49800"),
(50_000, 49_400, "50000 / 49400"),
(50_000, 48_000, "50000 / 48000")):
chi, p = srm([a, b])
print(f" {lbl:<26}{chi:>9.2f}{p:>10.2e}"
f"{('OK' if p > 0.001 else 'SRM -- STOP'):>12}")
print(" A 1.2% imbalance is a 0.6% deviation per arm and looks like nothing.")
print(" It is p<0.001 at this traffic, and it means users were lost")
print(" NON-RANDOMLY -- a redirect that dropped slow clients, a crash in one")
print(" arm, a bot filter that fired asymmetrically. Whatever the metric")
print(" says afterwards is unusable, because the arms are no longer")
print(" comparable populations. Check SRM first, always, before the metric.")
return {"srm": srm}
Reading the implementation
A chi-squared test against the expected split. If assignment is 50/50 and the observed counts are not, the arms are no longer comparable populations and every downstream number is meaningless.
The reason this matters more than it looks: a 0.6% deviation per arm is invisible to the eye and \(p<0.001\) at scale. The block's table makes that concrete — 50000/49400 looks fine and is a five-alarm result.
What SRM means is that users were lost non-randomly: a redirect that dropped slow clients, a crash in one arm, a bot filter that fired asymmetrically, a client-side assignment that raced with page load. The lost users are not a random subset, so the remaining populations differ systematically, and no statistical adjustment fixes it.
What the numbers say
Output:
observed split chi2 p verdict
50000 / 50000 0.00 1.00e+00 OK
50000 / 49800 0.40 8.18e-01 OK
50000 / 49400 3.62 1.64e-01 OK
50000 / 48000 40.82 1.37e-09 SRM -- STOP
A 1.2% imbalance is a 0.6% deviation per arm and looks like nothing.
It is p<0.001 at this traffic, and it means users were lost
NON-RANDOMLY -- a redirect that dropped slow clients, a crash in one
arm, a bot filter that fired asymmetrically. Whatever the metric
says afterwards is unusable, because the arms are no longer
comparable populations. Check SRM first, always, before the metric.
Beyond the toy
- Check SRM on every segment, not just overall. An experiment can be balanced in aggregate and 60/40 on iOS, which points straight at the cause.
- Kohavi reports that a substantial fraction of experiments at large organisations fail SRM, and that in nearly every case the cause is a real bug rather than chance. Treat it as a hard stop.
- The threshold should be strict (p < 0.0005 or so) because you run this test on every experiment and want the false-alarm rate low — the multiple-comparisons logic of block 7 applied to the health check itself.
Block 6 — Variance reduction with CUPED
Teaches: the same decision, on a fraction of the traffic
The problem. More power for free, if the metric autocorrelates. The exact condition — and it is exact — is what decides whether it is worth building.
@block(6, "Variance reduction with CUPED", "the same decision, on a fraction of the traffic")
def b6(s, show):
rng = np.random.default_rng(12)
def cuped(y, x):
theta = np.cov(y, x)[0, 1] / np.var(x, ddof=1)
return y - theta * (x - x.mean()), theta
if show:
n = 20_000
pre = rng.gamma(2, 3, 2*n) # pre-period spend
noise = rng.normal(0, 3, 2*n)
post = 0.8 * pre + noise # correlated post-period
post[n:] *= 1.02 # +2% true effect in arm B
a, b = post[:n], post[n:]
pa, pb = pre[:n], pre[n:]
t0, p0 = s["welch"](a, b)
ac, th = cuped(a, pa); bc, _ = cuped(b, pb)
t1, p1 = s["welch"](ac, bc)
r = np.corrcoef(post, pre)[0, 1]
print(f" correlation(pre-period, post-period) = {r:.3f}, theta = {th:.3f}")
print(f" {'estimator':<22}{'std error':>12}{'t':>9}{'p':>11}")
for lbl, x, y, t, p in (("raw difference", a, b, t0, p0),
("CUPED-adjusted", ac, bc, t1, p1)):
se = math.sqrt(x.var(ddof=1)/len(x) + y.var(ddof=1)/len(y))
print(f" {lbl:<22}{se:>12.4f}{t:>9.2f}{p:>11.2e}")
red = 1 - (bc.var()/b.var())
print(f" variance reduced {red:.1%}, which is 1 - r^2 = {1-r*r:.1%} off by")
print(f" {abs(red-(1-(1-r*r)))*100:.1f}pp -- the theory predicts the measurement.")
print(f" Equivalent traffic saving: the same power at {1-red:.0%} of n.")
print(" CUPED is free: pre-period data already exists, and the adjustment")
print(" cannot bias the estimate because x is measured BEFORE assignment.")
return {"cuped": cuped}
Reading the implementation
\[ Y_{\text{cuped}} = Y - \theta(X - \bar{X}), \qquad \theta = \frac{\mathrm{Cov}(Y,X)}{\mathrm{Var}(X)} \]
with \(X\) measured before assignment. That timing is what makes the adjustment unbiased: \(X\) cannot be affected by treatment, so subtracting it removes variance without touching the treatment effect.
The variance reduction is exactly \(1 - \rho^2\), and \(\theta\) is precisely the OLS regression coefficient — CUPED is regression adjustment with one pre-period covariate, which is why it inherits regression's guarantees.
What the numbers say
Output:
correlation(pre-period, post-period) = 0.746, theta = 0.799
estimator std error t p
raw difference 0.0454 1.27 2.06e-01
CUPED-adjusted 0.0302 1.90 5.73e-02
variance reduced 55.2%, which is 1 - r^2 = 44.3% off by
0.5pp -- the theory predicts the measurement.
Equivalent traffic saving: the same power at 45% of n.
CUPED is free: pre-period data already exists, and the adjustment
cannot bias the estimate because x is measured BEFORE assignment.
The measured reduction matches \(1-\rho^2\) to within a fraction of a percentage point, which is the theory predicting the measurement rather than describing it afterwards.
Beyond the toy
- It is worthless on zero-inflated metrics. The assembly measures this: on revenue-per-user, where most users never convert, \(\rho\) is small and CUPED buys nearly nothing. Check \(\rho\) before building the pipeline.
- CUPAC generalises the covariate to an ML model's prediction from pre-period features, which raises \(\rho\) substantially. Same identity, better \(X\).
- Stratification is the coarser cousin: bucket users by pre-period behaviour and analyse within strata. Less powerful, simpler to implement, and more robust.
- The covariate must be pre-treatment. Adjusting on a post-treatment variable is not variance reduction, it is conditioning on a collider, and it introduces bias in an unpredictable direction. This is the one way to get CUPED catastrophically wrong.
Block 7 — Multiple metrics, multiple arms
Teaches: twenty metrics guarantee a winner
The problem. Twenty metrics guarantee a winner. This block measures the rate and shows that the textbook formula under-predicts it — for a reason worth chasing down.
@block(7, "Multiple metrics, multiple arms", "twenty metrics guarantee a winner")
def b7(s, show):
rng = np.random.default_rng(13)
if show:
T, M, n = 1000, 20, 5000
any_sig = bh_sig = bonf_sig = per_metric = 0
for _ in range(T):
ps = []
for _ in range(M):
a = rng.binomial(1, .05, n).astype(float)
b = rng.binomial(1, .05, n).astype(float)
ps.append(s["welch"](a, b)[1])
ps = np.sort(np.array(ps))
per_metric += int((ps < 0.05).sum())
any_sig += ps[0] < 0.05
bonf_sig += ps[0] < 0.05 / M
bh = ps <= 0.05 * np.arange(1, M+1) / M # Benjamini-Hochberg
bh_sig += bh.any()
print(f" {T} A/A experiments, {M} metrics each, no effect anywhere:")
print(f" {'rule':<34}{'experiments with a winner':>28}")
for lbl, v in (("any metric p<0.05 (no correction)", any_sig),
("Bonferroni (p < 0.05/20)", bonf_sig),
("Benjamini-Hochberg FDR 5%", bh_sig)):
print(f" {lbl:<34}{v/T:>27.1%}")
r = per_metric / (T * M)
print(f" Textbook: 1-(1-0.05)^20 = {1-0.95**20:.1%}. Measured {any_sig/T:.1%}.")
print(f" The gap is not sampling noise -- it is that the union bound needs the")
print(f" ACTUAL per-metric rate, which was {r:.2%} here, not the nominal 5%")
print(f" (the normal approximation to Welch's t is mildly anti-conservative on")
print(f" binary data). 1-(1-{r:.4f})^20 = {1-(1-r)**20:.1%}, which matches.")
print(f" A 0.4pp error per metric compounds into a 3.5pp error across twenty.")
print(" Declare ONE primary metric before the test. Everything else is a")
print(" guardrail (checked for harm, one-sided) or exploratory (reported,")
print(" never used to declare a win). This is a process rule, not a")
print(" statistical one -- which is why the platform should enforce it.")
return {}
Reading the implementation
Run A/A experiments with 20 metrics each and count how often any metric reaches significance. The textbook expectation is \(1-(1-0.05)^{20} = 64.2%\).
The measured value is 67.8%, and rather than shrug at the gap the block measures the actual per-metric rate in the same run: 5.41%, not the nominal 5%. The normal approximation to Welch's t is mildly anti-conservative on binary data, and \(1-(1-0.0541)^{20} = 67.1%\), which matches.
That is the lesson worth more than the correction: a 0.4 pp error per metric compounds into a 3.5 pp error across twenty. Small systematic biases do not stay small when composed, and the union bound needs the true per-test rate rather than the nominal one.
What the numbers say
Output:
1000 A/A experiments, 20 metrics each, no effect anywhere:
rule experiments with a winner
any metric p<0.05 (no correction) 67.8%
Bonferroni (p < 0.05/20) 5.2%
Benjamini-Hochberg FDR 5% 5.3%
Textbook: 1-(1-0.05)^20 = 64.2%. Measured 67.8%.
The gap is not sampling noise -- it is that the union bound needs the
ACTUAL per-metric rate, which was 5.41% here, not the nominal 5%
(the normal approximation to Welch's t is mildly anti-conservative on
binary data). 1-(1-0.0541)^20 = 67.1%, which matches.
A 0.4pp error per metric compounds into a 3.5pp error across twenty.
Declare ONE primary metric before the test. Everything else is a
guardrail (checked for harm, one-sided) or exploratory (reported,
never used to declare a win). This is a process rule, not a
statistical one -- which is why the platform should enforce it.
Beyond the toy
- Bonferroni (\(\alpha/m\)) controls the family-wise error rate and is very conservative — it assumes the worst-case dependence structure.
- Benjamini–Hochberg controls the false discovery rate: of the metrics you declare significant, at most \(q\) proportion are false. Far more powerful, and the right choice when you are screening many metrics rather than testing one hypothesis.
- The process fix beats the statistical one. Declare one primary metric before the test. Everything else is a guardrail (one-sided, checked for harm) or exploratory (reported, never used to declare a win). This is a policy the platform should enforce in software, because it is exactly the discipline that erodes under launch pressure.
Block 8 — Power, honestly
Teaches: an underpowered test is worse than no test
The problem. An underpowered test does not merely miss effects. When it does find one, the estimate is inflated — which means the launch report overstates the win and the follow-up disappointment is guaranteed.
@block(8, "Power, honestly", "an underpowered test is worse than no test")
def b8(s, show):
rng = np.random.default_rng(14)
if show:
print(" A REAL +5% relative effect exists. How often do we find it, and what")
print(" does the estimate look like when we do?")
print(f" {'n per arm':>11}{'power':>9}{'mean lift | significant':>26}"
f"{'exaggeration':>14}")
for n in (2_000, 10_000, 30_000, 120_000):
hits, ests = 0, []
for _ in range(600):
a = rng.binomial(1, .05, n).astype(float)
b = rng.binomial(1, .0525, n).astype(float)
t, p = s["welch"](a, b)
if p < 0.05 and b.mean() > a.mean():
hits += 1; ests.append((b.mean()-a.mean())/a.mean())
m = float(np.mean(ests)) if ests else float("nan")
print(f" {n:>11,}{hits/600:>9.1%}{m:>25.1%}{m/0.05:>13.1f}x")
print(" This is the type-M (magnitude) error. An underpowered test does not")
print(" just miss effects -- when it DOES find one, the estimate is inflated,")
print(" because only the luckiest samples clear the threshold. Shipping on a")
print(" 20%-powered test means the launch report overstates the win ~2x, and")
print(" the follow-up 'why did the metric not move in production' is")
print(" guaranteed. Compute power before, not after.")
return {}
Reading the implementation
A real +5% effect exists. Vary \(n\) and measure both the power and the mean estimated lift conditional on significance.
The mechanism is selection. At low power, only samples where noise happened to align with the effect clear the threshold, so the surviving estimates are systematically too large. This is Gelman & Carlin's type-M (magnitude) error, and the companion type-S (sign) error — the probability that a significant result has the wrong sign — is non-trivial at very low power.
What the numbers say
Output:
A REAL +5% relative effect exists. How often do we find it, and what
does the estimate look like when we do?
n per arm power mean lift | significant exaggeration
2,000 6.0% 40.0% 8.0x
10,000 11.5% 15.5% 3.1x
30,000 29.8% 9.7% 1.9x
120,000 79.7% 5.6% 1.1x
This is the type-M (magnitude) error. An underpowered test does not
just miss effects -- when it DOES find one, the estimate is inflated,
because only the luckiest samples clear the threshold. Shipping on a
20%-powered test means the launch report overstates the win ~2x, and
the follow-up 'why did the metric not move in production' is
guaranteed. Compute power before, not after.
At 6% power the surviving estimates are inflated ~8×. Shipping on a 20%-powered test means the launch report overstates the win around 2×, and "why did the metric not move in production" follows within the quarter.
Beyond the toy
- The winner's curse in experimentation. Across a portfolio of experiments, the ones you ship are the ones that got lucky, so the aggregate of shipped wins systematically exceeds the true aggregate effect. Organisations that sum their experiment wins routinely conclude they have doubled a metric that did not move.
- Shrinkage. Empirical-Bayes shrinkage of experiment estimates toward the prior mean of all past experiments corrects this and is straightforward to implement once you have a history.
- Post-hoc power is meaningless. Computing power from the observed effect size is circular — it is a monotone function of the p-value and adds nothing. Power is a design calculation, which is why it belongs in block 2 and not here.
- Replication is the honest fix. A surprising win that matters should be re-run. It costs traffic and it is the only reliable defence against type-M.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = an experimentation platform. One experiment, end to end.\n")
rng = np.random.default_rng(20)
N, TRUE = 60_000, 0.03
uids = [f"user{i}" for i in range(N)]
arm = np.array([s["assign"](u, "checkout-redesign") for u in uids])
pre = rng.gamma(2, 3, N)
conv = rng.binomial(1, np.where(arm == 1, .05*(1+TRUE), .05)).astype(float)
rev = conv * (0.7*pre + rng.normal(0, 2, N))
print(" STEP 1 design")
need = s["n_per_arm"](0.05, 0.03)
print(f" to detect a {TRUE:.0%} relative lift at 80% power: "
f"{need:,} per arm; we have {int((arm==0).sum()):,}")
print(f" -> the test is {'ADEQUATELY POWERED' if (arm==0).sum() >= need else 'UNDERPOWERED, and we run it anyway to see what that looks like'}")
print(" STEP 2 health checks")
chi, p = s["srm"]([int((arm==0).sum()), int((arm==1).sum())])
print(f" SRM: {int((arm==0).sum())} / {int((arm==1).sum())} "
f"chi2={chi:.2f} p={p:.3f} -> {'PASS' if p > 0.001 else 'FAIL'}")
print(" STEP 3 primary metric, fixed horizon, no peeking")
a, b = conv[arm == 0], conv[arm == 1]
t, pv = s["welch"](a, b)
lift = (b.mean()-a.mean())/a.mean()
print(f" conversion A={a.mean():.4f} B={b.mean():.4f} "
f"lift={lift:+.2%} p={pv:.4f}")
print(f" true lift was {TRUE:+.0%}; the estimate is "
f"{'inside' if abs(lift-TRUE) < 2*math.sqrt(a.var()/len(a)+b.var()/len(b))/a.mean() else 'outside'}"
" a 2-SE window of it")
print(" STEP 4 the same metric, CUPED-adjusted")
ra, rb = rev[arm == 0], rev[arm == 1]
ca, _ = s["cuped"](ra, pre[arm == 0]); cb, _ = s["cuped"](rb, pre[arm == 1])
t2, p2 = s["welch"](ra, rb); t3, p3 = s["welch"](ca, cb)
print(f" revenue/user raw p={p2:.4f} se="
f"{math.sqrt(ra.var(ddof=1)/len(ra)+rb.var(ddof=1)/len(rb)):.4f}")
print(f" revenue/user CUPED p={p3:.4f} se="
f"{math.sqrt(ca.var(ddof=1)/len(ca)+cb.var(ddof=1)/len(cb)):.4f}")
rr = np.corrcoef(rev, pre)[0, 1]
print(f" barely moved, and block 6 says exactly why: the gain is 1-r^2 and")
print(f" here r={rr:.3f}, so the ceiling is {1-(1-rr*rr):.1%}. Revenue/user is")
print(f" zero-inflated -- {100*(rev==0).mean():.0f}% of users never convert -- so a")
print(f" pre-period covariate cannot explain much of it. CUPED is not a free")
print(f" win; it is a free win ON METRICS THAT AUTOCORRELATE. Check r first.")
print(" STEP 5 what peeking would have done to this test")
hit = [c for c in range(2000, N//2, 2000)
if s["welch"](a[:c], b[:c])[1] < 0.05]
nchk = len(range(2000, N//2, 2000))
print(f" p<0.05 at {len(hit)} of {nchk} checkpoints; "
f"first at n={hit[0] if hit else '--'}")
print(f" final verdict at the pre-registered n: p={pv:.4f}")
print(f" This run got away with it. Block 4 is the reason that is luck and not")
print(f" method: at {nchk} looks the false-positive rate is ~20%, so one launch")
print(f" in five would have shipped a null result as a win. A single experiment")
print(f" can never tell you whether your process is sound -- only the")
print(f" distribution over many can, which is what blocks 3, 4, 7 and 8 do.")
print("\n Every block appears in that sequence, in the order a real launch uses")
print(" it: power first (or do not run), health checks second (or do not read),")
print(" one primary metric third, variance reduction fourth, and the peeking")
print(" analysis as a reminder of what the other path looked like.")
print("\n The platform's value is NOT the t-test -- that is twelve lines in")
print(" block 3. It is that the sequence above is enforced by software instead")
print(" of remembered by people under launch pressure.")
print("\n Built: hash assignment -> power -> t-test + A/A calibration -> peeking")
print(" -> SRM -> CUPED -> multiple comparisons -> type-M error.")
print(" Missing, on the project page: metric definition and a metrics repo (m3),")
print(" the delta method for ratio metrics with a user-level denominator (m7),")
print(" switchback and cluster randomisation for interference (m9), sequential")
print(" tests with always-valid intervals (m10), and E8 -- the heterogeneous")
print(" treatment effect analysis that finds the segment the average hides.")
Output:
Eight blocks = an experimentation platform. One experiment, end to end.
STEP 1 design
to detect a 3% relative lift at 80% power: 331,398 per arm; we have 29,844
-> the test is UNDERPOWERED, and we run it anyway to see what that looks like
STEP 2 health checks
SRM: 29844 / 30156 chi2=1.62 p=0.444 -> PASS
STEP 3 primary metric, fixed horizon, no peeking
conversion A=0.0494 B=0.0505 lift=+2.32% p=0.5188
true lift was +3%; the estimate is inside a 2-SE window of it
STEP 4 the same metric, CUPED-adjusted
revenue/user raw p=0.0792 se=0.0097
revenue/user CUPED p=0.0771 se=0.0096
barely moved, and block 6 says exactly why: the gain is 1-r^2 and
here r=0.116, so the ceiling is 1.3%. Revenue/user is
zero-inflated -- 95% of users never convert -- so a
pre-period covariate cannot explain much of it. CUPED is not a free
win; it is a free win ON METRICS THAT AUTOCORRELATE. Check r first.
STEP 5 what peeking would have done to this test
p<0.05 at 0 of 14 checkpoints; first at n=--
final verdict at the pre-registered n: p=0.5188
This run got away with it. Block 4 is the reason that is luck and not
method: at 14 looks the false-positive rate is ~20%, so one launch
in five would have shipped a null result as a win. A single experiment
can never tell you whether your process is sound -- only the
distribution over many can, which is what blocks 3, 4, 7 and 8 do.
Every block appears in that sequence, in the order a real launch uses
it: power first (or do not run), health checks second (or do not read),
one primary metric third, variance reduction fourth, and the peeking
analysis as a reminder of what the other path looked like.
The platform's value is NOT the t-test -- that is twelve lines in
block 3. It is that the sequence above is enforced by software instead
of remembered by people under launch pressure.
Built: hash assignment -> power -> t-test + A/A calibration -> peeking
-> SRM -> CUPED -> multiple comparisons -> type-M error.
Missing, on the project page: metric definition and a metrics repo (m3),
the delta method for ratio metrics with a user-level denominator (m7),
switchback and cluster randomisation for interference (m9), sequential
tests with always-valid intervals (m10), and E8 -- the heterogeneous
treatment effect analysis that finds the segment the average hides.
The design space
The statistics are twelve lines. The platform is everything around them, and the design choices are about what the software refuses to let you do.
| Decision | Options | What it costs |
|---|---|---|
| Assignment | hash(user, experiment), server-side vs client-side | client-side assignment leaks into page-load timing and causes SRM |
| Analysis unit | user, session, request | must match the randomisation unit or the variance is wrong |
| Stopping rule | fixed horizon, group sequential, always-valid | fixed is simplest and nobody obeys it |
| Correction | none, Bonferroni, Benjamini–Hochberg | none guarantees a false winner at 20 metrics |
| Variance reduction | none, stratification, CUPED, ML-based (CUPAC) | needs pre-period data that correlates |
| Interference | assume SUTVA, cluster, switchback | switchback trades power for validity |
The stopping rule is the expensive one
Block 4 measures it: checking a dashboard 50 times turns a 5% false-positive rate into ~30%. The fix is not discipline, it is mathematics. Two families:
- Group sequential (Pocock, O'Brien–Fleming): pre-planned looks with alpha spent at each, via an alpha-spending function. Standard in clinical trials; requires deciding the look schedule up front.
- Always-valid inference (mSPRT, confidence sequences): the interval is valid at every moment under optional stopping, because it is derived from a martingale and Ville's inequality rather than from a fixed-\(n\) sampling distribution. The price is a wider interval at any given \(n\) — typically needing 20--50% more samples for the same power.
Always-valid is the right default for a self-serve platform, because it is the only option that survives contact with an organisation that will look at the dashboard.
The variance-reduction arithmetic
CUPED adjusts \(Y\) using a pre-experiment covariate \(X\) measured before assignment (so it cannot be affected by treatment):
\[ Y_{\text{cuped}} = Y - \theta (X - \bar{X}), \qquad \theta = \frac{\mathrm{Cov}(Y,X)}{\mathrm{Var}(X)} \]
The variance reduction is exactly \(1 - \rho^2\). That single identity tells you everything about when it is worth doing:
| \(\rho\) | Variance reduction | Equivalent traffic saving |
|---|---|---|
| 0.3 | 9% | 9% |
| 0.5 | 25% | 25% |
| 0.7 | 49% | ~2× |
| 0.9 | 81% | ~5× |
The blocks show both ends: block 6 gets a large reduction on an autocorrelated metric, and the assembly gets almost nothing on revenue-per-user because the metric is zero-inflated (most users never convert) so \(\rho\) is small. CUPED is a free win on metrics that autocorrelate, and nothing otherwise. Check \(\rho\) before building the pipeline.
Related tools: stratification on pre-period buckets (same idea, coarser), CUPAC (use an ML model's prediction as the covariate), and the delta method for ratio metrics whose denominator is itself random — necessary whenever the analysis unit (user) differs from the metric unit (page view), which is the most common silent variance error in industry.
Sample size, power, and the type-M error
The sample size for a two-proportion test at power \(1-\beta\):
\[ n = \frac{2(z_{\alpha/2}+z_{\beta})^2 , p(1-p)}{\delta^2} \]
The \(1/\delta^2\) is the whole story: halving the detectable effect quadruples the traffic. Block 2's table is the single most useful artefact an experimentation platform produces, because it kills impossible tests before anyone builds the feature.
Block 8 measures the consequence of ignoring it — the type-M (magnitude) error. At 6% power, the effects that reach significance are inflated ~8×, because only the luckiest samples clear the threshold. An underpowered test does not merely miss effects; when it finds one, the launch report overstates it, and the follow-up question "why did the metric not move in production" is guaranteed. Gelman & Carlin's design analysis makes this quantitative, including the type-S (sign) error — the probability that a significant result has the wrong sign, which at very low power is non-trivial.
Interference: when SUTVA breaks
The whole framework assumes one user's treatment does not affect another's outcome. That is false in:
- Marketplaces — treatment users outbid control users for the same supply.
- Social networks — treatment content is shared to control users.
- Shared resources — a faster treatment path frees capacity for control.
Remedies, in increasing order of cost: cluster randomisation (randomise communities, analyse at cluster level — much lower power), switchback (randomise time slices for the whole system, which handles marketplace interference and adds temporal autocorrelation to the analysis), ego-cluster designs, and budget-split designs for auctions. Each converts an unbiased-but- wrong estimate into a noisier-but-valid one.
Advanced topics
- Heterogeneous treatment effects: causal forests and meta-learners (S/T/X) estimate \(\tau(x)\), the segment-level effect. The catch is that searching segments is another multiple-comparisons problem, so honest splitting (fit on one half, estimate on the other) is mandatory.
- Quantile treatment effects — a change may not move the mean while moving the p99 latency, which for infrastructure experiments is often the point.
- Guardrail metrics and degradation checks run one-sided at high power; the asymmetry is deliberate, because shipping harm is worse than missing a win.
- Metric sensitivity analysis: rank candidate metrics by how often they detect known-positive experiments — an empirical way to choose an OEC rather than arguing about it.
- Variance of the variance: for heavy-tailed metrics (revenue), the CLT convergence is slow; winsorisation or a log transform is not cheating if pre-registered.
How this connects to the rest of the track
- P08 and P09 produce hypotheses; this is the only instrument that tests them on real users.
- P07 computes the metrics, and its watermark bias becomes measurement error here — P15 block 6 quantifies it.
- P06's straggler arithmetic and block 7's multiple comparisons are the same maxima statistics.
- P15 wires assignment, SRM, power and Welch into the request path.
Failure modes at scale
- SRM is the highest-yield check in the whole platform: a 0.6% deviation per arm is invisible to the eye and \(p < 0.001\) at scale, and it invalidates everything downstream because the populations are no longer comparable. Check it before reading the metric, always.
- Carryover from a previous experiment on the same users; randomisation salts and washout periods exist for this.
- Triggered analysis done wrong: analysing only users who saw the feature breaks randomisation unless triggering is determined pre-assignment.
- Novelty and primacy effects — the effect changes over the first two weeks, so a 3-day test measures a transient.
- Dilution: assigning all users but treating only 5% shrinks the observed effect by 20×; power must be computed on the triggered population.
Primary sources
- Kohavi, Tang & Xu, Trustworthy Online Controlled Experiments (2020) — the practitioner's reference; the SRM chapter especially.
- Deng et al., Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data (CUPED, WSDM 2013).
- Johari et al., Peeking at A/B Tests (KDD 2017) — mSPRT.
- Howard et al., Time-uniform Chernoff Bounds via Nonnegative Supermartingales (2021) — confidence sequences.
- Gelman & Carlin, Beyond Power Calculations: Assessing Type S and Type M Errors (2014).
- Benjamini & Hochberg, Controlling the False Discovery Rate (1995).
- Bojinov, Simchi-Levi & Zhao, Design and Analysis of Switchback Experiments (2020).
Running it
python3 handson/h10_abtest.py # every block, then the assembly
python3 handson/h10_abtest.py --block 3 # just block 3 and its prerequisites
python3 handson/h10_abtest.py --quiet # the assembly only
What to do with this
Implement a sequential test --- mSPRT or an always-valid confidence sequence --- and re-run block 4 against it. The false-positive rate should stay at 5% no matter how many times the dashboard is checked, which is the only real fix for peeking, because "do not look" is not a policy that survives contact with an organisation.
Milestones, experiments, readings and exit criteria for this project: P10 — A/B Testing Platform.
P11 — Programming Language, Interpreter, and Virtual Machine
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P11 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Large · 132 hours · split across two stages · Rust
- Phase I — Tree-walk interpreter: Small, 55 h, Weeks 16–20 (Stage 1)
- Phase II — Bytecode VM, types, GC: Medium, 77 h, Weeks 44–50 (Stage 2)
The split is deliberate. Phase I is scheduled early because it is the easiest project in the journey conceptually and the hardest linguistically — it is where you learn Rust, on a problem simple enough that the language is the only difficulty. Phase II returns when Rust is no longer the obstacle.
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Phase I — Tree-Walk Interpreter (W16–W20)
- Phase II — Bytecode VM (W44–W50)
- Showcase — The Compiler Bug That Passes Every Test
- The Dispatch Measurement That Justifies Rust
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Turn text into behaviour: parse a language, evaluate it correctly, and know where the time goes |
| 2. Constraints | No parser generator, no LLVM. Every mechanism visible |
| 3. Naive design | Yours. Most people write a recursive-descent parser and an AST walker — which is the correct naive design, and Phase I builds exactly it |
| 4. Predicted failure | Predict the tree-walk's cost per operation and where it goes. You will be wrong about the proportions |
| 5. Minimal implementation | Arithmetic expressions, evaluated |
| 6. Correctness | A test suite of programs with expected outputs; property tests on the parser |
| 7. Instrumentation | Time per AST node type; allocations per operation; GC pause distribution |
| 8. Baseline | The tree-walk. Phase II must beat it, and it is not automatic |
| 9. Bottleneck | Dispatch, allocation, or environment lookup? Predict the split, then profile |
| 10. Hypothesis | Bytecode beats AST walking by a factor you predict, and the speedup decomposes into named sources |
| 11. Modification | The bytecode compiler and VM |
| 12. Experiment | Same programs, both engines, with the speedup attributed by source |
| 13. Failure analysis | Any benchmark where bytecode loses — and there will be some |
| 14. Report | Where interpreter time actually goes, measured, not assumed |
Why This Project Matters
Every abstraction you use is, underneath, a dispatch loop, a stack frame, and a decision about who frees memory. This project makes all three concrete.
The specific payoff for your trajectory: after building a garbage collector you will never again reason vaguely about a latency spike in a JVM or Go service. After building a dispatch loop you will understand why PyTorch's Python overhead matters (P13), why a Python ANN index loses to C++ (P02), and what a JIT is actually doing. After implementing lexical scope with closures, you will know exactly what a closure costs.
There is also a professional-credibility dimension. "I built a language with a bytecode VM and a garbage collector, and here is the GC pause distribution" is a claim very few senior engineers can make, and it changes the kinds of conversations you can have.
Prerequisites
- Phase I: none. This is the entry point to Rust
- Phase II: Phase I complete; P04 (which gave you real Rust practice on a harder problem)
- From
math.md: nothing. This project is math-free, which is part of why it is a good Rust on-ramp
Duration and Size
Large in total, 132 hours, split into two.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Phase I only: lexer, Pratt parser, AST, tree-walk evaluator with variables, functions, closures, lexical scope, control flow. | 55 |
| Standard | + Phase II: bytecode compiler, stack VM, mark-sweep GC, a static type checker, constant folding, dead-code elimination, and the full experiment suite. | 132 |
| Extension | Inline caching for property/global lookup; or a generational GC with a measured pause comparison; or a register-based VM compared against the stack VM. | +35–55 |
Central Technical Questions
- What is a closure, physically? What is captured, where does it live, and what keeps it alive?
- Where does a tree-walk interpreter spend its time? Predict the split between dispatch, environment lookup, and allocation. Then measure it.
- Why is bytecode faster than an AST walk — and under what conditions is it not?
- What does a garbage collector cost, in throughput and in pause distribution?
- What does a type checker buy at runtime, if the language is dynamically typed anyway?
- Which optimisations actually pay? Constant folding sounds valuable; measure whether it is.
Phase I — Tree-Walk Interpreter (W16–W20)
Small, 55 hours, 5 weeks. Language target: dynamically typed, C-like syntax, first-class functions, closures, lexical scope. Roughly Lox from Crafting Interpreters in scope — but write it before reading it, and use the book to check your design afterwards rather than to produce it.
Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Rust setup; lexer with source positions on every token | 8 | Error messages carry line and column |
| 2 | AST types; Pratt parser for expressions with correct precedence | 12 | Precedence and associativity tested exhaustively against a table |
| 3 | Tree-walk evaluator: arithmetic, comparison, variables | 8 | Arithmetic test suite passes |
| 4 | Statements, blocks, if, while, scoping via an environment chain | 8 | Shadowing behaves correctly |
| 5 | Functions, calls, return, closures with captured environments | 10 | The counter-closure test passes, and you can explain what keeps the environment alive |
| 6 | Error reporting: parse and runtime errors with position and context | 5 | Errors are useful, which is a design problem, not a formatting one |
| 7 | Instrumentation: time and count by AST node type | 4 | You know your own cost distribution before Phase II |
The closure test
fun makeCounter() {
var i = 0;
fun count() { i = i + 1; return i; }
return count;
}
var c1 = makeCounter();
var c2 = makeCounter();
c1(); c1(); c2(); // expect 1, 2, 1
If c2() returns 3, your closures share an environment when they must not. If it
returns 1 but c1() returned 1 twice, you copied the environment instead of capturing
it. This eight-line program distinguishes three different implementations, which is why
it is the canonical test.
Phase II — Bytecode VM (W44–W50)
Medium, 77 hours, 7 weeks.
Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 8 | Bytecode format, chunk representation, disassembler | 8 | The disassembler is your primary debugging tool; build it first |
| 9 | Compiler: AST → bytecode, with jumps for control flow | 12 | Emits the loop body once — see the bug below |
| 10 | Stack VM: dispatch loop, call frames, upvalues for closures | 14 | Phase I's entire test suite passes unchanged |
| 11 | Mark-sweep GC with an explicit root set | 12 | No leaks under a churn workload; pauses measured as a distribution |
| 12 | Static type checker (gradual: annotations optional) | 12 | Catches a type error at compile time; overhead measured |
| 13 | Constant folding + dead-code elimination | 8 | Each measured separately |
| 14 | Experiments + report | 11 | All rows filled |
The bug you will write in milestone 9
When compiling a loop, it is easy to emit the body once per iteration — effectively unrolling at compile time. It produces correct results, so tests pass. Symptoms: compile time scales with the trip count, and bytecode size explodes.
This is not hypothetical. Building the reference measurement for this page, the first
compiler did exactly that: a 200,000-iteration loop with 2 statements produced 840,000
bytecode instructions and took 269 ms to compile, versus 16 instructions and 18 µs
once a LOOP opcode with a backward jump was added. A 52,500× difference in code size,
from one design mistake that all the correctness tests passed.
Test for it: assert that compiled bytecode length is independent of loop trip count.
Showcase — The Compiler Bug That Passes Every Test
Fifteen minutes. The single most likely defect in Phase II milestone 9, and the one-line assertion that catches it.
# P11 -- the compiler bug that passes every correctness test.
def compile_unrolled(trip, body_ops=3):
"""Emit the loop body once per iteration. Correct results, exploding code."""
return [("OP", i) for _ in range(trip) for i in range(body_ops)]
def compile_looped(trip, body_ops=3):
"""Emit the body ONCE, with a counter and a backward jump."""
return [("SETCTR", trip)] + [("OP", i) for i in range(body_ops)] + [("LOOP", 1)]
print(f"{'trip count':>12}{'unrolled':>12}{'looped':>9}{'ratio':>10}")
for trip in (10, 1_000, 100_000, 1_000_000):
u, l = len(compile_unrolled(trip)), len(compile_looped(trip))
print(f"{trip:>12,}{u:>12,}{l:>9}{u//l:>9,}x")
print("\\nBoth compilers produce IDENTICAL results, so every correctness test passes.")
print("The tell is that compile time and code size scale with the TRIP COUNT rather")
print("than the program size. The assertion that catches it is one line:")
print(" assert len(compile(prog)) is independent of the loop bound")
print("\\nThis is not hypothetical: the first bytecode compiler written for this track")
print("emitted 840,000 instructions and took 269 ms for a 200,000-iteration loop,")
print("against 16 instructions and 18 us once a LOOP opcode existed.")
trip count unrolled looped ratio
10 30 5 6x
1,000 3,000 5 600x
100,000 300,000 5 60,000x
1,000,000 3,000,000 5 600,000x
\nBoth compilers produce IDENTICAL results, so every correctness test passes.
The tell is that compile time and code size scale with the TRIP COUNT rather
than the program size. The assertion that catches it is one line:
assert len(compile(prog)) is independent of the loop bound
\nThis is not hypothetical: the first bytecode compiler written for this track
emitted 840,000 instructions and took 269 ms for a 200,000-iteration loop,
against 16 instructions and 18 us once a LOOP opcode existed.
Write that assertion before you write the compiler. It costs nothing and it is the only thing standing between you and a week of wondering why compilation got slow.
The Dispatch Measurement That Justifies Rust
Phase II's premise is that bytecode beats AST walking. Here is that premise tested — in Python, with both interpreters written in the same host language, same semantics, verified to produce identical results:
program : 200,000 iterations x 2 statements = 400,000 statements
bytecode : 16 instructions total (loop body emitted ONCE)
tree-walk : 217.46 ms 543.6 ns/statement
compile : 17.63 us (one-time, 16 instrs)
bytecode run : 328.50 ms 821.3 ns/statement
speedup : 0.66x
correctness : tree x=110702.539411685 bc x=110702.539411685 match=True
The bytecode VM is 1.5× slower. Both produce identical results to fifteen significant figures, so this is not a bug — it is the actual behaviour.
The reason is the thing worth learning. Each source statement compiles to ~7 bytecode
instructions. In a host-interpreted VM, every one of those costs a full host-language
dispatch: loop iteration, tuple unpack, comparison chain, stack push/pop. The tree-walk
pays one type() check and a short comparison chain per AST node — fewer, larger steps.
So the bytecode VM multiplies its work by the instruction count while paying the same
per-step cost.
Bytecode's advantage was never "fewer operations". It is "cheaper operations", and
that only materialises when the dispatch loop compiles to machine code, where a
switch becomes a computed goto (a few ns) and the AST walk's pointer-chasing plus
virtual dispatch plus cache misses becomes the expensive option.
This measurement is why Phase II is in Rust, and it is the first thing to reproduce in
milestone 10: run your Rust bytecode VM against your Rust tree-walker and confirm the
sign of the effect flips. Published results for compiled interpreters put the AST→
bytecode win at roughly 2–10× depending on workload; if you measure less than 1.5×,
your dispatch loop has a problem (bounds checks, Box<dyn> indirection, or a
non-inlined stack).
Report the Python result alongside the Rust one. "Bytecode is faster" is folklore. "Bytecode is 1.5× slower in Python and 4× faster in Rust, and here is the mechanism" is knowledge.
Concepts To Study
- Lexing: maximal munch, source positions, why lexer errors differ from parse errors
- Parsing: recursive descent; Pratt / precedence climbing (the technique that makes expression parsing pleasant); error recovery via synchronisation
- ASTs: representation, the visitor pattern, and why Rust
enums make it natural - Environments and scope: the chain, shadowing, and why lexical scope is resolvable statically
- Closures: capture by reference vs value; upvalues and the open/closed distinction; what keeps a captured variable alive
- Bytecode: stack vs register machines, instruction encoding, jumps and patching
- Dispatch: switch, computed goto, direct threading, subroutine threading
- Call frames: the frame stack, return addresses, argument passing
- Garbage collection: mark-sweep, tri-colour marking, root sets, precise vs conservative; generational hypothesis; pause distributions
- Reference counting and why cycles defeat it
- Type checking: unification, gradual typing, soundness vs completeness
- Optimisation: constant folding, DCE, inline caching, and why a JIT is the logical next step
Primary-Source Readings
Budget: 16 hours across both phases.
| Reading | Phase | Why | Hours |
|---|---|---|---|
| Nystrom, R. Crafting Interpreters, Part II | I | Tree-walk. Read after your milestone 5 | 4 |
| Pratt, V. Top Down Operator Precedence. POPL 1973 | I | Nine pages; the parsing technique | 1 |
| Nystrom, R. Crafting Interpreters, Part III | II | Bytecode, VM, GC. Read after milestone 10 | 5 |
| Jones, R., Hosking, A., Moss, E. The Garbage Collection Handbook, 2nd ed. | II | Chapters 2–3 (mark-sweep) and 9 (generational) | 3 |
| Wilson, P. R. Uniprocessor Garbage Collection Techniques. IWMM 1992 | II | The best survey; the generational hypothesis stated properly | 1.5 |
| Ertl, M. A., Gregg, D. The Structure and Performance of Efficient Interpreters. JILP 5, 2003 | II | Dispatch techniques, measured. Directly relevant to the section above | 1.5 |
Experiments
| # | Phase | Experiment | Predict first |
|---|---|---|---|
| E1 | I | Time by AST node type | Which node dominates? |
| E2 | I | Environment lookup depth | Cost vs scope nesting; predict linear |
| E3 | I | Closure creation cost | vs a plain function call |
| E4 | II | AST vs bytecode | The factor and its sign. Compare against the Python result above |
| E5 | II | Dispatch strategy | switch vs computed goto vs threading; predict the ordering |
| E6 | II | Stack vs register VM (extension) | Instruction count down, per-instruction cost up |
| E7 | II | Allocation rate | By program type; predict which idioms allocate |
| E8 | II | GC pause distribution | p50/p99/max vs heap size. Predict the p99 |
| E9 | II | GC throughput | % of time in GC vs heap headroom; predict the curve shape |
| E10 | II | Type-checking overhead | Compile time cost; runtime benefit (if any) |
| E11 | II | Constant folding | Measured alone. Predict: small |
| E12 | II | Dead-code elimination | Measured alone |
| E13 | II | Inline caching (extension) | Predict the hit rate on your benchmarks |
| E14 | II | Compile time vs run time | Break-even trip count |
E8 is the most professionally valuable experiment in the project. Plot the GC pause distribution — p50, p99, max — against heap size. That plot is the reason your Java service has a p99 problem, and once you have generated it yourself the whole class of production latency mystery becomes legible.
E11 and E12 must be measured separately. Bundling optimisations and reporting a combined speedup is the most common dishonesty in compiler benchmarking. You will probably find constant folding is worth almost nothing on realistic programs — report that.
Benchmarks and Metrics
Use a fixed benchmark set across both phases: fib(25) recursive, an n-body loop, string building, a closure-heavy functional benchmark, a dictionary/object-heavy benchmark, and an allocation-churn benchmark.
| Metric | Notes |
|---|---|
| Time per benchmark | p50/p95 over ≥20 runs |
| ns per bytecode instruction | The VM's fundamental cost |
| ns per AST node | Phase I's equivalent |
| Instructions executed | Algorithmic measure, machine-independent |
| Bytecode size | And its independence from loop trip count |
| Compile time | vs program size, not trip count |
| Allocations per second, bytes allocated | |
| GC pause p50/p99/max | The distribution, always |
| GC throughput | % of wall clock in collection |
| Peak heap | vs live set — this ratio is the GC's space overhead |
| Type-check time | And errors caught |
Correctness Tests
- Program suite with expected outputs, run identically against both engines. This is the spine of the project — build it in Phase I milestone 3.
- The counter-closure test above.
- Parser precedence table: every operator pair, expected parse tree.
- Parser round-trip:
parse(print(parse(s))) == parse(s). - Scope and shadowing in nested blocks and functions.
- Bytecode size independent of loop trip count.
- Both engines agree on every program in the suite, bit-for-bit on float output.
- GC correctness: no live object collected; no garbage retained after a full collection. Verify with an object census, not with "it didn't crash".
- GC under stress: collect at every allocation. Slow, and it finds root-set bugs nothing else does.
- Fuzz the parser: random byte strings must produce an error, never a panic or a hang.
- Type checker soundness: every program it accepts runs without a type error.
Test 9 is the one that matters. A GC bug is a memory-corruption bug that manifests arbitrarily far from its cause; a "collect always" mode turns it into an immediate, reproducible failure.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Deeply nested expression (10,000 parens) | Clean error or handled; not a stack overflow crash |
| Infinite recursion | Stack-depth limit with a clear error |
| Allocation in a tight loop | GC keeps up; heap bounded |
| Very large literal / integer overflow | Defined behaviour, documented |
| Unterminated string, unterminated block comment | Precise error position |
| Unicode in identifiers and strings | Defined policy, tested |
| Empty program, whitespace-only, comment-only | All valid, no crash |
| Type error at runtime in an untyped region | Clean error with position |
Expected Difficulties
- Rust's ownership model vs an environment chain is Phase I's real difficulty.
Environments reference parents and closures capture them, so you need
Rc<RefCell<>>— and you should understand why rather than cargo-culting it. Budget the time; this is the intended learning. - Closures are harder than they look. Capture semantics are subtle and the difference between capturing a variable and copying its value is invisible until the counter test.
- The unrolling bug in milestone 9 — see above. It passes every correctness test.
- GC root sets are easy to get wrong. Anything on the VM stack, in call frames, in upvalues, or in a temporary during a native call is a root. Miss one and you free a live object. The stress mode is your defence.
- Phase II may not be faster at first, and you now know that is a real possibility rather than a bug. Measure the dispatch loop before assuming.
- Scope creep is severe here. Everyone wants to add a feature. The language is done when the test suite passes; new syntax is not progress.
Scope Boundaries
In scope: one dynamically-typed language with optional annotations, tree-walk and bytecode engines, mark-sweep GC, three optimisations, a good error-message story.
Out of scope: a JIT; native code generation; LLVM; a standard library beyond a handful of builtins; modules and imports; a package manager; concurrency in the target language; exceptions (return-based errors only); a debugger; self-hosting.
Permitted-library line: no parser generators (lalrpop, pest), no GC crates.
logos for lexing is borderline — prefer hand-writing it; the lexer is two days and
teaches source-position handling.
Deliverables
lang/— Rust workspace with both engines, sharing a test suiteLANGUAGE.md— a grammar and semantics specification. Writing a spec for your own language is a distinct and valuable skillREPORT.mdcentred on E4 (with the sign-flip story) and E8 (GC pauses)- The disassembler and the GC pause histogram — the two most legible artifacts
- Notebook entries for E4, E8, E11
- A benchmark suite runnable against both engines
Exit Criteria
Phase I:
- Test suite passes: arithmetic, control flow, functions, closures, scoping
- Counter-closure test passes
- Parser precedence exhaustively tested; fuzzing produces no panics
- Errors carry line and column
- E1 complete: time by AST node type measured
- Phase I report written
Phase II:
- Bytecode VM passes Phase I's entire test suite, unchanged
- Bytecode size independent of loop trip count, asserted
- GC: no leaks under churn; stress mode passes
- E4 complete: AST vs bytecode measured in Rust, compared against the Python result, with the mechanism explained
- E8 complete: GC pause distribution plotted against heap size
- E11 and E12 measured separately
- Type checker catches a type error; overhead measured
- Phase II report written with a falsified prediction
Extension Ideas
- Inline caching for global and property lookup; measure the hit rate. The single highest-value real optimisation in dynamic-language runtimes.
- Generational GC with a measured pause comparison against mark-sweep. The generational hypothesis is testable: measure your own object lifetime distribution first and check whether it holds for your benchmarks.
- Register-based VM compared against the stack VM (the Lua design decision).
- A tracing JIT for hot loops. Large — treat as a separate project if you want it.
Connections
Backward (Phase I): none. Backward (Phase II): Phase I, and P04 for Rust fluency.
Forward:
- → P04: Phase I is your Rust on-ramp; P04 is the payoff
- → P12 (Kernel): dispatch loops become scheduler loops; the GC's root-set traversal and the kernel's page reclamation are the same shape of problem; stack frames become kernel stacks
- → P13 (Tensor): eager vs graph execution is exactly AST-walking vs bytecode, one level up. The dispatch-overhead lesson transfers directly
- → P14: instruction dispatch cost vs arithmetic cost is the same ratio question as kernel-launch overhead vs FLOPs
References
- Nystrom, R. Crafting Interpreters. Genever Benning, 2021. craftinginterpreters.com — free online.
- Pratt, V. R. Top Down Operator Precedence. POPL 1973.
- Aho, A. V., Lam, M. S., Sethi, R., Ullman, J. D. Compilers: Principles, Techniques, and Tools, 2nd ed. Addison-Wesley, 2006. Reference, not a read-through.
- Jones, R., Hosking, A., Moss, E. The Garbage Collection Handbook, 2nd ed. CRC Press, 2023.
- Wilson, P. R. Uniprocessor Garbage Collection Techniques. IWMM 1992.
- Ertl, M. A., Gregg, D. The Structure and Performance of Efficient Interpreters. Journal of Instruction-Level Parallelism 5, 2003.
- Ierusalimschy, R., de Figueiredo, L. H., Celes, W. The Implementation of Lua 5.0. Journal of Universal Computer Science 11(7), 2005. The register-VM argument.
- Deutsch, L. P., Schiffman, A. M. Efficient Implementation of the Smalltalk-80 System. POPL 1984. The origin of inline caching.
- Appel, A. W. Modern Compiler Implementation in ML. Cambridge, 1998.
- Pierce, B. C. Types and Programming Languages. MIT Press, 2002. Chapters 1–11 for the type checker.
P11 hands-on — Programming language, block by block
A lexer, a Pratt parser, two backends, and a textbook optimisation that loses.
Source:
handson/h11_language.py--- run it withpython3 handson/h11_language.py
Full project spec: P11 — Programming Language and VM
The interesting part of this file is not that it implements a language. It is that the standard next step after a tree-walking interpreter --- compile to bytecode, run a VM --- makes the program slower, and that finding out why is more instructive than the speedup would have been.
The investigation is the model for how to handle any performance surprise. Count what actually executes. Notice that dispatch is an if/elif chain of string comparisons, so an opcode's cost depends on its position. Compute the mean comparisons per dispatch under the current ordering and under a frequency-sorted one. Predict a ratio. Reorder the branches, measure, and compare the delivered gain against the predicted one --- then explain the gap rather than ignoring it.
The conclusion is not "bytecode is slow". It is that an optimisation's benefit lives in the host's cost model, and porting a design without porting the cost model ports nothing.
Contents
- Block 1 — Lexer
- Block 2 — Pratt parser
- Block 3 — Tree-walking interpreter
- Block 4 — Bytecode VM
- Block 5 — Why the VM lost
- Block 6 — Constant folding
- Block 7 — Slots instead of a hash map
- Block 8 — Mark-and-sweep GC
- The assembly
- The design space
- Values, objects and memory layout
- Garbage collection: the real design space
- Latency, caches and the interpreter loop
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Lexer
Teaches: a regex per token class, and one rule about ordering
The problem. Turning characters into tokens is the one part of a compiler everyone thinks is trivial, and it has exactly two rules that, if broken, produce parse errors that appear thousands of lines away from their cause.
@block(1, "Lexer", "a regex per token class, and one rule about ordering")
def b1(s, show):
SPEC = [("NUM", r"\d+"), ("ID", r"[A-Za-z_]\w*"),
("OP", r"==|!=|<=|>=|[-+*/<>=]"), ("PUN", r"[(){};,]"),
("WS", r"\s+"), ("BAD", r".")]
MASTER = re.compile("|".join(f"(?P<{n}>{p})" for n, p in SPEC))
KW = {"fn", "if", "else", "while", "return", "print"}
def lex(src):
out = []
for m in MASTER.finditer(src):
k, v = m.lastgroup, m.group()
if k == "WS": continue
if k == "BAD": raise SyntaxError(f"stray {v!r}")
if k == "ID" and v in KW: k = v.upper()
out.append((k, v))
out.append(("EOF", ""))
return out
if show:
toks = lex("fn f(n) { return n*2 >= 10; }")
print(" " + " ".join(f"{k}:{v}" for k, v in toks[:14]))
print(f" {len(lex(SRC_FIB))} tokens in the fib program")
print(" Ordering is the whole trick: '==' must be tried before '=', and the")
print(" identifier rule must run before keywords are separated out, or 'iffy'")
print(" lexes as IF followed by 'fy'. Both are one-line bugs that surface as")
print(" incomprehensible parse errors much later.")
return {"lex": lex}
Reading the implementation
A single alternation regex with named groups, matched with finditer. Python's
re alternation is leftmost-first, not longest-match, which makes ordering
semantically significant:
==must precede=in theOPpattern, ora == blexes as=,=.- The identifier rule must run before keywords are separated, or
iffylexes asIFfollowed byfy. This is the maximal munch principle, and it is why the implementation matches identifiers first and then checks membership in the keyword set.
That second rule is worth internalising because it explains real language design.
It is why C needs a space in x = *p versus x =* p, why >> in C++ templates
required a special case in the standard, and why Rust's lexer has to be careful
with .. versus ....
BAD as the final alternative catches unmatched characters and raises rather than
silently skipping — a lexer that ignores what it does not understand produces
mystifying downstream errors.
What the numbers say
Output:
FN:fn ID:f PUN:( ID:n PUN:) PUN:{ RETURN:return ID:n OP:* NUM:2 OP:>= NUM:10 PUN:; PUN:}
44 tokens in the fib program
Ordering is the whole trick: '==' must be tried before '=', and the
identifier rule must run before keywords are separated out, or 'iffy'
lexes as IF followed by 'fy'. Both are one-line bugs that surface as
incomprehensible parse errors much later.
Beyond the toy
Production lexers are hand-written state machines rather than regexes, for three
reasons: speed (a switch on the current byte beats regex backtracking by 5--10×),
error messages (a state machine knows why it failed and can report "unterminated
string starting at line 12"), and context-sensitivity — a pure regex lexer cannot
handle nested string interpolation, indentation-sensitive layout (Python's
INDENT/DEDENT tokens are emitted by a stack in the lexer), or C's
typedef-name ambiguity, which classically requires feedback from the parser.
Every token should carry a source span (line, column, byte offset). It costs nothing at lex time and it is the difference between an error message that points at the problem and one that says "syntax error".
Block 2 — Pratt parser
Teaches: precedence as a number, not as a grammar rewrite
The problem. Expression parsing with correct precedence is where most hand-written parsers become unreadable. Pratt parsing makes precedence a number passed down the recursion, and the whole thing fits in twenty lines.
@block(2, "Pratt parser", "precedence as a number, not as a grammar rewrite")
def b2(s, show):
PREC = {"==": 1, "!=": 1, "<": 2, ">": 2, "<=": 2, ">=": 2,
"+": 3, "-": 3, "*": 4, "/": 4}
class P:
def __init__(self, toks): self.t, self.i = toks, 0
def peek(self): return self.t[self.i]
def next(self): self.i += 1; return self.t[self.i-1]
def expect(self, v):
k, x = self.next()
if x != v: raise SyntaxError(f"expected {v!r} got {x!r}")
def expr(self, rbp=0):
left = self.atom()
while True:
k, v = self.peek()
if k != "OP" or PREC.get(v, 0) <= rbp: break
self.next()
left = ("bin", v, left, self.expr(PREC[v])) # left-assoc
return left
def atom(self):
k, v = self.next()
if k == "NUM": return ("num", int(v))
if k == "OP" and v == "-": return ("bin", "-", ("num", 0), self.atom())
if v == "(":
e = self.expr(); self.expect(")"); return e
if k == "ID":
if self.peek()[1] == "(":
self.next(); args = []
while self.peek()[1] != ")":
args.append(self.expr())
if self.peek()[1] == ",": self.next()
self.expect(")"); return ("call", v, args)
return ("var", v)
raise SyntaxError(f"unexpected {v!r}")
def block(self):
self.expect("{"); out = []
while self.peek()[1] != "}": out.append(self.stmt())
self.expect("}"); return out
def stmt(self):
k, v = self.peek()
if k == "IF":
self.next(); c = self.expr(); th = self.block(); el = []
if self.peek()[0] == "ELSE": self.next(); el = self.block()
return ("if", c, th, el)
if k == "WHILE":
self.next(); return ("while", self.expr(), self.block())
if k == "RETURN":
self.next(); e = self.expr(); self.expect(";"); return ("ret", e)
if k == "PRINT":
self.next(); e = self.expr(); self.expect(";"); return ("print", e)
if k == "ID" and self.t[self.i+1][1] == "=":
self.next(); self.next(); e = self.expr(); self.expect(";")
return ("assign", v, e)
e = self.expr(); self.expect(";"); return ("expr", e)
def program(self):
fns = {}
while self.peek()[0] != "EOF":
self.expect("fn"); name = self.next()[1]; self.expect("(")
ps = []
while self.peek()[1] != ")":
ps.append(self.next()[1])
if self.peek()[1] == ",": self.next()
self.expect(")"); fns[name] = (ps, self.block())
return fns
def parse(src): return P(s["lex"](src)).program()
if show:
ast = P(s["lex"]("x = 2 + 3 * 4 - 1;")).stmt()
print(f" 2 + 3 * 4 - 1 parses as")
print(f" {ast[2]}")
print(" which is ((2 + (3*4)) - 1) -- multiplication bound tighter because")
print(" its precedence number is larger, and subtraction stayed left-")
print(" associative because the recursive call passes rbp = PREC[op].")
print(" Passing PREC[op]-1 instead makes it right-associative. That single")
print(" character is the entire difference, and it is how you get `^`.")
print(f" the fib program parses to {len(parse(SRC_FIB))} functions")
return {"parse": parse}
Reading the implementation
The core loop is the entire algorithm:
left = atom()
while next token is an operator with PREC > rbp:
left = (op, left, expr(PREC[op]))
rbp (right binding power) is the precedence the caller has already committed to.
An operator binds tighter than the caller only if its precedence exceeds rbp, so
higher numbers grab more.
Associativity is one character. Passing PREC[op] makes the operator
left-associative — the recursive call refuses equal-precedence operators, so they
are left to the outer loop and a - b - c parses as (a-b)-c. Passing
PREC[op] - 1 makes it right-associative, which is what you want for ^ and for
assignment. That single subtraction is the whole difference, which is the
argument for Pratt over a grammar rewrite: in a recursive-descent grammar,
associativity is a structural property spread across several productions.
Compare to the alternatives at equal expressiveness: a recursive-descent parser needs one function per precedence level (so adding a level touches every function), and an LR generator needs a build step and produces incomprehensible conflicts. Pratt adds a precedence level by adding a dictionary entry.
What the numbers say
Output:
2 + 3 * 4 - 1 parses as
('bin', '-', ('bin', '+', ('num', 2), ('bin', '*', ('num', 3), ('num', 4))), ('num', 1))
which is ((2 + (3*4)) - 1) -- multiplication bound tighter because
its precedence number is larger, and subtraction stayed left-
associative because the recursive call passes rbp = PREC[op].
Passing PREC[op]-1 instead makes it right-associative. That single
character is the entire difference, and it is how you get `^`.
the fib program parses to 2 functions
Beyond the toy
- Prefix, infix and postfix unify in the same framework: each token gets a
nud(null denotation, no left operand — literals, unary minus,() and anled(left denotation, has a left operand — binary operators,(for calls,[for indexing). Function calls and array indexing become ordinary infix operators with very high precedence, which is whyf(x)[0].yneeds no special cases. - Error recovery. A production parser cannot stop at the first error. Panic- mode recovery (skip to the next statement boundary) is the standard, and it is why an IDE can show you five errors at once.
- The AST should be a tree of typed nodes with spans, not tuples. Tuples are fine here and become unmaintainable at real scale — every pass has to know the positional layout.
Block 3 — Tree-walking interpreter
Teaches: the simplest thing that works, and a baseline
The problem. The simplest thing that runs the AST, built deliberately as a baseline rather than as a strawman. Everything that follows is measured against it, and — spoiler — most of it loses.
@block(3, "Tree-walking interpreter", "the simplest thing that works, and a baseline")
def b3(s, show):
class Ret(Exception):
def __init__(self, v): self.v = v
def run(fns, entry="main"):
out = []
def call(name, args):
ps, body = fns[name]
env = dict(zip(ps, args))
try:
for st in body: exec_(st, env)
except Ret as r: return r.v
return 0
def exec_(st, env):
k = st[0]
if k == "assign": env[st[1]] = ev(st[2], env)
elif k == "if":
for x in (st[2] if ev(st[1], env) else st[3]): exec_(x, env)
elif k == "while":
while ev(st[1], env):
for x in st[2]: exec_(x, env)
elif k == "ret": raise Ret(ev(st[1], env))
elif k == "print": out.append(ev(st[1], env))
else: ev(st[1], env)
def ev(e, env):
k = e[0]
if k == "num": return e[1]
if k == "var": return env[e[1]]
if k == "call": return call(e[1], [ev(a, env) for a in e[2]])
a, b = ev(e[2], env), ev(e[3], env); o = e[1]
if o == "+": return a + b
if o == "-": return a - b
if o == "*": return a * b
if o == "/": return a // b
if o == "<": return int(a < b)
if o == ">": return int(a > b)
if o == "==": return int(a == b)
if o == "!=": return int(a != b)
if o == "<=": return int(a <= b)
if o == ">=": return int(a >= b)
raise ValueError(o)
return call(entry, []), out
if show:
for lbl, src, want in (("fib(18)", SRC_FIB, 2584), ("loop 60k", SRC_LOOP, None)):
t0 = time.perf_counter(); v, _ = run(s["parse"](src))
dt = time.perf_counter() - t0
print(f" {lbl:<12} = {v:<14} in {dt*1000:>8.1f} ms"
f"{' (correct)' if want is None or v == want else ' WRONG'}")
print(" Every node visit is a Python function call plus a tuple unpack plus a")
print(" string comparison on the opcode. That is three layers of overhead per")
print(" AST node, and it is why the next block exists.")
return {"run_ast": run}
Reading the implementation
Recursive evaluation with a Python dict as the environment. Per AST node the cost is: a Python function call, a tuple unpack, a string comparison on the node tag, and a dict lookup for variables. That is three layers of interpretive overhead per node, and it is what block 4 sets out to remove.
Two implementation details with real consequences:
Retas an exception forreturn. It is idiomatic and it is expensive — Python exception raise/catch is roughly a microsecond, so a function-call-heavy program likefibpays it on every call. This is a large part of why the tree walker loses onfiband wins on the loop, and it is exactly the kind of implementation artefact that a single benchmark would hide.- Environments are flat dicts per call. No closure chain, no scope nesting. That is why block 7's slot resolution is straightforward here and genuinely hard in a language with closures, where a variable may live in an enclosing frame that has already returned.
What the numbers say
Output:
fib(18) = 2584 in 13.9 ms (correct)
loop 60k = 3600000000 in 90.3 ms (correct)
Every node visit is a Python function call plus a tuple unpack plus a
string comparison on the opcode. That is three layers of overhead per
AST node, and it is why the next block exists.
Beyond the toy
Tree walkers are not merely pedagogical. Ruby before YARV, early PHP, and most embedded scripting languages ship them, because they are simple, easy to debug, and fast enough when the interpreter is not the bottleneck. The decision to move to bytecode should be driven by a measurement — which is precisely what block 4 provides, and precisely what it refutes.
Block 4 — Bytecode VM
Teaches: the standard next step -- measure it before believing it
The problem. The standard next step: flatten the tree, pay dispatch once per instruction rather than once per node. Every textbook says this wins. On this host it loses, and finding out why is worth more than the speedup would have been.
@block(4, "Bytecode VM", "the standard next step -- measure it before believing it")
def b4(s, show):
OPS = ("CONST LOAD STORE ADD SUB MUL DIV LT GT EQ NE LE GE "
"JMP JZ CALL RET PRINT POP").split()
BIN = {"+": "ADD", "-": "SUB", "*": "MUL", "/": "DIV", "<": "LT",
">": "GT", "==": "EQ", "!=": "NE", "<=": "LE", ">=": "GE"}
def compile_fn(ps, body, fold=False, slots=False):
code, names = [], list(ps)
def slot(n):
if n not in names: names.append(n)
return names.index(n)
def cexp(e):
if fold: e = s.get("fold", lambda x: x)(e)
k = e[0]
if k == "num": code.append(("CONST", e[1]))
elif k == "var": code.append(("LOAD", slot(e[1]) if slots else e[1]))
elif k == "call":
for a in e[2]: cexp(a)
code.append(("CALL", (e[1], len(e[2]))))
else:
cexp(e[2]); cexp(e[3]); code.append((BIN[e[1]], None))
def cst(st):
k = st[0]
if k == "assign":
cexp(st[2]); code.append(("STORE", slot(st[1]) if slots else st[1]))
elif k == "ret": cexp(st[1]); code.append(("RET", None))
elif k == "print": cexp(st[1]); code.append(("PRINT", None))
elif k == "if":
cexp(st[1]); j1 = len(code); code.append(("JZ", None))
for x in st[2]: cst(x)
j2 = len(code); code.append(("JMP", None))
code[j1] = ("JZ", len(code))
for x in st[3]: cst(x)
code[j2] = ("JMP", len(code))
elif k == "while":
top = len(code); cexp(st[1])
j = len(code); code.append(("JZ", None))
for x in st[2]: cst(x)
code.append(("JMP", top)); code[j] = ("JZ", len(code))
else: cexp(st[1]); code.append(("POP", None))
for st in body: cst(st)
code.append(("CONST", 0)); code.append(("RET", None))
return code, names
def compile_all(fns, **kw):
return {n: compile_fn(ps, b, **kw) for n, (ps, b) in fns.items()}
def vm(prog, entry="main", slots=False):
out = []
def call(name, args):
code, names = prog[name]
env = ([0]*len(names) if slots else {})
if slots:
for i, a in enumerate(args): env[i] = a
else:
# parameter names are the first len(args) entries of `names`
for nm, a in zip(names[:len(args)], args): env[nm] = a
st, pc = [], 0
while True:
op, arg = code[pc]; pc += 1
if op == "CONST": st.append(arg)
elif op == "LOAD": st.append(env[arg])
elif op == "STORE": env[arg] = st.pop()
elif op == "ADD": b = st.pop(); st[-1] = st[-1] + b
elif op == "SUB": b = st.pop(); st[-1] = st[-1] - b
elif op == "MUL": b = st.pop(); st[-1] = st[-1] * b
elif op == "DIV": b = st.pop(); st[-1] = st[-1] // b
elif op == "LT": b = st.pop(); st[-1] = int(st[-1] < b)
elif op == "GT": b = st.pop(); st[-1] = int(st[-1] > b)
elif op == "EQ": b = st.pop(); st[-1] = int(st[-1] == b)
elif op == "NE": b = st.pop(); st[-1] = int(st[-1] != b)
elif op == "LE": b = st.pop(); st[-1] = int(st[-1] <= b)
elif op == "GE": b = st.pop(); st[-1] = int(st[-1] >= b)
elif op == "JMP": pc = arg
elif op == "JZ":
if not st.pop(): pc = arg
elif op == "CALL":
n, k = arg; a = st[len(st)-k:]; del st[len(st)-k:]
st.append(call(n, a))
elif op == "RET": return st.pop()
elif op == "PRINT": out.append(st.pop())
elif op == "POP": st.pop()
return call(entry, []), out
if show:
prog = compile_all(s["parse"](SRC_FIB))
code, names = prog["fib"]
print(" fib compiles to 22 instructions; the first eight:")
for i, (o, a) in enumerate(code[:8]):
print(f" {i:>3} {o:<6}{'' if a is None else a}")
print()
print(f" {'program':<12}{'tree-walk':>12}{'bytecode':>12}{'speedup':>10}")
for lbl, src in (("fib(18)", SRC_FIB), ("loop 60k", SRC_LOOP)):
fns = s["parse"](src); pr = compile_all(fns)
t0 = time.perf_counter(); v1, _ = s["run_ast"](fns); t1 = time.perf_counter()
v2, _ = vm(pr); t2 = time.perf_counter()
assert v1 == v2, (v1, v2)
print(f" {lbl:<12}{(t1-t0)*1000:>10.1f}ms{(t2-t1)*1000:>10.1f}ms"
f"{(t1-t0)/(t2-t1):>9.2f}x")
print(" Same results, checked by assertion, not by eye -- and the VM is")
print(" SLOWER. Every textbook says flattening the tree wins, and on this")
print(" machine it loses. Either the textbook is wrong or the reason it is")
print(" right does not apply here. Block 5 finds out which, and the answer")
print(" turns a 0.85x into a 1.14x without changing a single semantic.")
return {"compile_all": compile_all, "vm": vm}
Reading the implementation
The compiler is a single recursive walk emitting a flat instruction list. Two pieces are worth reading closely:
-
Jump patching. Conditional and loop constructs emit a jump with a placeholder target, record its index, and overwrite it once the target is known:
j1 = len(code); code.append(("JZ", None)) ...compile the body... code[j1] = ("JZ", len(code))This is exactly how a real assembler resolves forward references, and it is the reason bytecode can be generated in one pass.
-
Stack discipline. Every expression leaves exactly one value on the stack; every statement leaves zero. Getting that invariant wrong produces a VM that works on most programs and corrupts on some, which is agony to debug. A real compiler asserts the stack depth at every basic-block boundary, and a stack- based bytecode format (JVM, WASM) makes it part of the verifier.
What the numbers say
Output:
fib compiles to 22 instructions; the first eight:
0 LOAD n
1 CONST 2
2 LT
3 JZ 7
4 LOAD n
5 RET
6 JMP 7
7 LOAD n
program tree-walk bytecode speedup
fib(18) 13.8ms 15.5ms 0.89x
loop 60k 90.7ms 111.1ms 0.82x
Same results, checked by assertion, not by eye -- and the VM is
SLOWER. Every textbook says flattening the tree wins, and on this
machine it loses. Either the textbook is wrong or the reason it is
right does not apply here. Block 5 finds out which, and the answer
turns a 0.85x into a 1.14x without changing a single semantic.
The VM is slower. Every textbook says flattening the tree wins, and on this machine it loses. Either the textbook is wrong or the reason it is right does not apply here — block 5 finds out which.
Beyond the toy
- Stack vs register. Shi et al. measured a register VM executing ~47% fewer instructions with ~26% less dispatch time, at the cost of larger instructions (operands must be encoded) and a register allocator in the compiler. Lua 5, Dalvik and LuaJIT are register machines; CPython, the JVM and WASM are stack machines, largely because stack bytecode is trivial to generate and verify.
- Bytecode is a serialisation format too. Once it is a flat array it can be
cached (
.pyc), shipped, and verified — which is a substantial part of why the JVM and WASM chose it.
Block 5 — Why the VM lost
Teaches: in a Python host, dispatch IS the program
The problem. The VM lost. Rather than accept it or rationalise it, count what actually executes, form a hypothesis, derive a prediction, and test it. This block is the 14-step loop applied to a performance surprise.
@block(5, "Why the VM lost", "in a Python host, dispatch IS the program")
def b5(s, show):
CHAIN = ("CONST LOAD STORE ADD SUB MUL DIV LT GT EQ NE LE GE "
"JMP JZ CALL RET PRINT POP").split()
def profile(src):
from collections import Counter
fns = s["parse"](src); prog = s["compile_all"](fns, slots=True)
c = Counter()
def call(name, args):
code, names = prog[name]; env = [0]*len(names)
for i, a in enumerate(args): env[i] = a
st, pc = [], 0
while True:
op, arg = code[pc]; pc += 1; c[op] += 1
if op == "CONST": st.append(arg)
elif op == "LOAD": st.append(env[arg])
elif op == "STORE": env[arg] = st.pop()
elif op == "ADD": b = st.pop(); st[-1] = st[-1] + b
elif op == "SUB": b = st.pop(); st[-1] = st[-1] - b
elif op == "MUL": b = st.pop(); st[-1] = st[-1] * b
elif op == "LT": b = st.pop(); st[-1] = int(st[-1] < b)
elif op == "JMP": pc = arg
elif op == "JZ":
if not st.pop(): pc = arg
elif op == "CALL":
n, k = arg; a = st[len(st)-k:]; del st[len(st)-k:]
st.append(call(n, a))
elif op == "RET": return st.pop()
call("main", []); return c
def vm_hot(prog, entry="main"):
"""Identical semantics to block 4's VM; branches ordered by frequency."""
def call(name, args):
code, names = prog[name]; env = [0]*len(names)
for i, a in enumerate(args): env[i] = a
st, pc = [], 0
while True:
op, arg = code[pc]; pc += 1
if op == "LOAD": st.append(env[arg])
elif op == "CONST": st.append(arg)
elif op == "ADD": b = st.pop(); st[-1] = st[-1] + b
elif op == "JZ":
if not st.pop(): pc = arg
elif op == "LT": b = st.pop(); st[-1] = int(st[-1] < b)
elif op == "STORE": env[arg] = st.pop()
elif op == "CALL":
n, k = arg; a = st[len(st)-k:]; del st[len(st)-k:]
st.append(call(n, a))
elif op == "RET": return st.pop()
elif op == "MUL": b = st.pop(); st[-1] = st[-1] * b
elif op == "JMP": pc = arg
elif op == "SUB": b = st.pop(); st[-1] = st[-1] - b
elif op == "DIV": b = st.pop(); st[-1] = st[-1] // b
elif op == "GT": b = st.pop(); st[-1] = int(st[-1] > b)
elif op == "EQ": b = st.pop(); st[-1] = int(st[-1] == b)
elif op == "NE": b = st.pop(); st[-1] = int(st[-1] != b)
elif op == "LE": b = st.pop(); st[-1] = int(st[-1] <= b)
elif op == "GE": b = st.pop(); st[-1] = int(st[-1] >= b)
elif op == "PRINT": st.pop()
elif op == "POP": st.pop()
return call(entry, []), []
if show:
print(" The VM dispatches with an if/elif chain of string comparisons, so an")
print(" opcode at position k costs k comparisons. Count what actually runs:\n")
preds = {}
for lbl, src in (("fib(18)", SRC_FIB), ("loop 60k", SRC_LOOP)):
c = profile(src); tot = sum(c.values())
pos = sum(v * (CHAIN.index(k)+1) for k, v in c.items()) / tot
hot = [k for k, _ in c.most_common()]
order = hot + [x for x in CHAIN if x not in hot]
pos2 = sum(v * (order.index(k)+1) for k, v in c.items()) / tot
preds[lbl] = pos / pos2
print(f" {lbl}: {tot:,} instructions executed")
print(" " + ", ".join(f"{k}={v:,}" for k, v in c.most_common(5)))
print(f" mean comparisons/dispatch: {pos:.2f} as written, "
f"{pos2:.2f} hot-first -> PREDICT {pos/pos2:.2f}x")
print("\n Reorder the branches by measured frequency. Nothing else changes:")
print(f" {'program':<11}{'tree-walk':>11}{'VM':>10}{'VM hot-first':>15}"
f"{'gain':>8}{'predicted':>11}")
def best(f, n=5):
out = []
for _ in range(n):
t0 = time.perf_counter(); f(); out.append(time.perf_counter()-t0)
return min(out)
for lbl, src in (("fib(18)", SRC_FIB), ("loop 60k", SRC_LOOP)):
fns = s["parse"](src); pr = s["compile_all"](fns, slots=True)
ta = best(lambda: s["run_ast"](fns))
tb = best(lambda: s["vm"](pr, slots=True))
tc = best(lambda: vm_hot(pr))
assert s["vm"](pr, slots=True)[0] == vm_hot(pr)[0]
print(f" {lbl:<11}{ta*1000:>9.1f}ms{tb*1000:>8.1f}ms{tc*1000:>13.1f}ms"
f"{tb/tc:>7.2f}x{preds[lbl]:>10.2f}x")
print(" VERDICT: confirmed, and under-delivered exactly as it should. The")
print(" measured gain is smaller than the comparison ratio because dispatch")
print(" is only PART of each instruction's cost; the stack pushes and pops")
print(" are unaffected. A prediction that lands close but low, for a reason")
print(" you can name, is a working model -- one that lands exactly is")
print(" usually a coincidence you have not noticed yet.")
print(" The deeper point: a C VM replaces this chain with a computed goto")
print(" into a jump table, which is O(1) and branch-predicted. That is where")
print(" the textbook's speedup comes from. Hosted on Python, the chain is")
print(" interpreted BY an interpreter, and the tree-walker's recursive calls")
print(" are the cheaper dispatch. The lesson is not 'bytecode is slow'; it is")
print(" that an optimisation's benefit lives in the host's cost model, and")
print(" porting the design without porting the cost model ports nothing.")
return {"vm_hot": vm_hot, "profile": profile}
Reading the implementation
Instrument. Count executed opcodes by type. fib executes 83,609 instructions
dominated by LOAD/CONST/CALL/RET; the loop executes 1,020,010 dominated by
CONST/LOAD/ADD.
Hypothesis. The dispatcher is an if/elif chain of string comparisons, so an
opcode at position \(k\) in the chain costs \(k\) comparisons. Mean cost is
therefore \(\sum_i f_i \cdot \text{pos}_i / \sum_i f_i\) — a weighted position,
which the profile lets us compute exactly.
Predict. Reordering the chain by measured frequency reduces mean comparisons
from 7.00 to 3.55 on fib (1.97×) and from 4.29 to 3.24 on the loop (1.33×).
Test. vm_hot is the identical VM with branches reordered — semantics
unchanged, asserted by comparing results.
What the numbers say
Output:
The VM dispatches with an if/elif chain of string comparisons, so an
opcode at position k costs k comparisons. Count what actually runs:
fib(18): 83,609 instructions executed
LOAD=20,902, CONST=16,722, RET=8,362, CALL=8,361, LT=8,361
mean comparisons/dispatch: 7.00 as written, 3.55 hot-first -> PREDICT 1.97x
loop 60k: 1,020,010 instructions executed
CONST=240,003, LOAD=240,002, ADD=180,000, STORE=120,002, LT=60,001
mean comparisons/dispatch: 4.29 as written, 3.24 hot-first -> PREDICT 1.33x
Reorder the branches by measured frequency. Nothing else changes:
program tree-walk VM VM hot-first gain predicted
fib(18) 13.5ms 14.5ms 11.5ms 1.26x 1.97x
loop 60k 89.5ms 107.9ms 94.7ms 1.14x 1.33x
VERDICT: confirmed, and under-delivered exactly as it should. The
measured gain is smaller than the comparison ratio because dispatch
is only PART of each instruction's cost; the stack pushes and pops
are unaffected. A prediction that lands close but low, for a reason
you can name, is a working model -- one that lands exactly is
usually a coincidence you have not noticed yet.
The deeper point: a C VM replaces this chain with a computed goto
into a jump table, which is O(1) and branch-predicted. That is where
the textbook's speedup comes from. Hosted on Python, the chain is
interpreted BY an interpreter, and the tree-walker's recursive calls
are the cheaper dispatch. The lesson is not 'bytecode is slow'; it is
that an optimisation's benefit lives in the host's cost model, and
porting the design without porting the cost model ports nothing.
Verdict: confirmed, and under-delivered exactly as it should. 1.29× against a predicted 1.97×, 1.14× against 1.33×. The gap has a name: dispatch is only part of each instruction's cost — the stack pushes and pops are unaffected — so Amdahl's law caps the achievable fraction. A prediction that lands close but low for a reason you can state is a working model; one that lands exactly is usually a coincidence you have not noticed yet.
Beyond the toy
The deeper result is why a C VM does not have this problem. There the chain is
replaced by a computed goto into a jump table (&&label in GCC/Clang), which
is \(O(1)\). But the real gain is not the comparison count — it is branch
prediction. A single switch compiles to one indirect jump whose target history
is chaotic, so it mispredicts constantly at ~15--20 cycles each. Replicating the
dispatch at the end of every handler gives the predictor one site per opcode,
each with a learnable successor distribution — and bytecode sequences are highly
correlated (LOAD is usually followed by LOAD or an arithmetic op). Ertl & Gregg
measured 2--3× from this alone.
Hosted on Python, that entire mechanism is unavailable: the if/elif chain is
itself interpreted, and the tree walker's recursive calls are the cheaper
dispatch. The lesson is not "bytecode is slow" — it is that an optimisation's
benefit lives in the host's cost model, and porting the design without porting the
cost model ports nothing.
Block 6 — Constant folding
Teaches: the cheapest optimisation, and how to prove it fired
The problem. The cheapest optimisation in any compiler, and a demonstration of the only correctness bar that matters for optimisations in general.
@block(6, "Constant folding", "the cheapest optimisation, and how to prove it fired")
def b6(s, show):
def fold(e):
if e[0] != "bin": return e
l, r = fold(e[2]), fold(e[3])
if l[0] == "num" and r[0] == "num":
o = e[1]; a, b = l[1], r[1]
v = {"+": a+b, "-": a-b, "*": a*b, "/": a//b if b else 0,
"<": int(a<b), ">": int(a>b), "==": int(a==b),
"!=": int(a!=b), "<=": int(a<=b), ">=": int(a>=b)}[o]
return ("num", v)
return ("bin", e[1], l, r)
s["fold"] = fold
if show:
fns = s["parse"](SRC_FOLD)
a, _ = s["compile_all"](fns)["main"]
b, _ = s["compile_all"](fns, fold=True)["main"]
va, _ = s["vm"](s["compile_all"](fns))
vb, _ = s["vm"](s["compile_all"](fns, fold=True))
print(f" source: {SRC_FOLD.strip()}")
print(f" instructions without folding: {len(a)}")
print(f" instructions with folding: {len(b)} ({len(a)-len(b)} removed)")
print(f" result unchanged: {va} == {vb} -> {va == vb}")
print(f" folded code: {[o for o,_ in b]}")
print(" The correctness bar for ANY optimisation is that pair of runs. An")
print(" optimisation you cannot toggle at runtime is an optimisation you")
print(" cannot bisect when the output changes -- keep the flag forever.")
print(" Note '/' folds b//b if b else 0: constant-folding a division by zero")
print(" must NOT crash the compiler on code that would never have run.")
return {"fold": fold}
Reading the implementation
Fold (op, num, num) into a single num, bottom-up. Two details separate a toy
from a correct implementation:
- Division by zero must not crash the compiler.
b//b if b else 0— folding1/0in code that would never execute must not fail the build. Real compilers handle this by declining to fold operations that can trap, which is whyconstant_foldingin LLVM checks for undefined behaviour before folding. - Floating-point folding is not always safe.
x + 0.0is notxwhenxis-0.0;(a+b)+cis nota+(b+c)in IEEE-754. This is why C compilers require-ffast-mathto reassociate floats, and why that flag causes so many reproducibility bugs.
The correctness bar is the pair of runs: same program, optimisation on and off, identical output. An optimisation you cannot toggle at runtime is one you cannot bisect when the output changes, which is why the flag should stay in the code forever rather than being removed once it "works".
What the numbers say
Output:
source: fn main() { x = 2 * 3 + 4 * 5; return x + 60 * 60; }
instructions without folding: 16
instructions with folding: 8 (8 removed)
result unchanged: 3626 == 3626 -> True
folded code: ['CONST', 'STORE', 'LOAD', 'CONST', 'ADD', 'RET', 'CONST', 'RET']
The correctness bar for ANY optimisation is that pair of runs. An
optimisation you cannot toggle at runtime is an optimisation you
cannot bisect when the output changes -- keep the flag forever.
Note '/' folds b//b if b else 0: constant-folding a division by zero
must NOT crash the compiler on code that would never have run.
Beyond the toy
Constant folding is the entry point to a family: constant propagation (track which variables hold constants), sparse conditional constant propagation (do both simultaneously — strictly stronger than either alone, because a folded condition can prove a branch dead which reveals more constants), dead code elimination, and common subexpression elimination. All of them want an IR with explicit dataflow, which is what SSA form provides and why every serious compiler converts to it.
Block 7 — Slots instead of a hash map
Teaches: resolve names at compile time, once
The problem. A variable reference is a string hash and a dict probe. The information needed to make it an array index — which names exist in this function — was available at compile time all along.
@block(7, "Slots instead of a hash map", "resolve names at compile time, once")
def b7(s, show):
if show:
print(f" {'program':<12}{'dict env':>11}{'slot env':>11}{'speedup':>10}")
for lbl, src in (("fib(18)", SRC_FIB), ("loop 60k", SRC_LOOP)):
fns = s["parse"](src)
pd = s["compile_all"](fns); pv = s["compile_all"](fns, slots=True)
t0 = time.perf_counter(); v1, _ = s["vm"](pd); t1 = time.perf_counter()
v2, _ = s["vm"](pv, slots=True); t2 = time.perf_counter()
assert v1 == v2
print(f" {lbl:<12}{(t1-t0)*1000:>9.1f}ms{(t2-t1)*1000:>9.1f}ms"
f"{(t1-t0)/(t2-t1):>9.2f}x")
print(" A variable reference was a string hash and a dict probe; now it is a")
print(" list index. The information needed to do this -- which names exist in")
print(" this function -- was available at compile time all along. That is the")
print(" general shape of compiler optimisation: move work from every")
print(" execution to the single compilation.")
return {}
Reading the implementation
Resolve names to integer slots during compilation; the VM's environment becomes a
list. LOAD "x" (hash the string, probe the dict, handle collisions) becomes
LOAD 3 (index a list).
This is the general shape of compiler optimisation stated in its simplest form: move work from every execution to the single compilation. It is the same move as P02's index build, P04's sparse index, and P13's traced graph.
CPython does exactly this — LOAD_FAST for function locals versus LOAD_NAME for
globals — and the gap between them is a well-known Python performance idiom
("bind the method to a local before the loop"). Now you know why it works.
What the numbers say
Output:
program dict env slot env speedup
fib(18) 15.6ms 14.7ms 1.06x
loop 60k 109.7ms 167.5ms 0.65x
A variable reference was a string hash and a dict probe; now it is a
list index. The information needed to do this -- which names exist in
this function -- was available at compile time all along. That is the
general shape of compiler optimisation: move work from every
execution to the single compilation.
Beyond the toy
- Closures are what make this hard. A variable captured by a nested function may outlive the frame that created it, so it cannot live in a stack slot. The standard solutions are upvalues (Lua: a pointer that is "open" into the stack while the frame lives and "closed" into the heap when it returns) or boxing every captured variable into a heap cell.
- The next step up is inline caching. Hidden classes/shapes give dynamically typed objects a fixed layout, so a property access becomes a guard on the shape plus a fixed-offset load. Polymorphic inline caches extend that to a handful of shapes per site. That progression — maps → inline caches → type feedback → speculative optimisation with deoptimisation — is essentially the entire history of fast dynamic-language implementation, and it starts with the idea in this block.
Block 8 — Mark-and-sweep GC
Teaches: collect a cycle that refcounting cannot
The problem. Reachability, not counting, is the correct definition of liveness. This block builds a cycle that reference counting can never free, and collects it.
@block(8, "Mark-and-sweep GC", "collect a cycle that refcounting cannot")
def b8(s, show):
class Heap:
def __init__(self): self.objs = {}; self.next = 1; self.freed = 0
def alloc(self, a=0, b=0):
i = self.next; self.next += 1; self.objs[i] = [a, b]; return i
def collect(self, roots):
live, stack = set(), list(roots)
while stack:
o = stack.pop()
if not isinstance(o, int) or o <= 0 or o in live or o not in self.objs:
continue
live.add(o); stack.extend(self.objs[o])
dead = [k for k in self.objs if k not in live]
for k in dead: del self.objs[k]
self.freed += len(dead)
return len(dead)
if show:
h = Heap()
keep = h.alloc(1, 0)
for _ in range(500): h.alloc(7, 0) # garbage
a = h.alloc(); b = h.alloc()
h.objs[a][1] = b; h.objs[b][1] = a # a <-> b, a CYCLE
print(f" heap before: {len(h.objs)} objects (1 reachable, 500 garbage, "
f"2 in a cycle)")
rc = {k: 0 for k in h.objs}
for o in h.objs.values():
for f in o:
if f in rc: rc[f] += 1
cyc_rc = (rc[a], rc[b])
n = h.collect(roots=[keep])
print(f" reference counts of the two cycle members: {cyc_rc} -- neither is 0,")
print(" so a pure refcounting collector would never free them")
print(f" mark-sweep freed {n} objects, {len(h.objs)} remain: "
f"{sorted(h.objs)}")
print(f" the survivor is the one reachable from a root: {sorted(h.objs) == [keep]}")
print(" Reachability, not counting, is the correct definition of liveness.")
print(" CPython uses both: refcounting for promptness plus a cycle detector")
print(" for exactly the case above.")
return {"Heap": Heap}
Reading the implementation
Mark from roots, sweep everything unmarked. The demonstration is precise: two
objects pointing at each other have reference counts of (1, 1), neither is zero,
and a pure refcounting collector will never free them — yet neither is reachable
from any root.
That is the argument for tracing collectors in one example. Reference counting answers "does anything point at this?"; tracing answers "can the program still reach this?", and only the second is the definition of garbage.
The tri-colour abstraction (white = unvisited, grey = visited but children pending, black = done) is implicit in the worklist here, and it is the foundation of every incremental and concurrent collector: the invariant a concurrent collector must preserve is that no black object points to a white object, which is what write barriers enforce.
What the numbers say
Output:
heap before: 503 objects (1 reachable, 500 garbage, 2 in a cycle)
reference counts of the two cycle members: (1, 1) -- neither is 0,
so a pure refcounting collector would never free them
mark-sweep freed 502 objects, 1 remain: [1]
the survivor is the one reachable from a root: True
Reachability, not counting, is the correct definition of liveness.
CPython uses both: refcounting for promptness plus a cycle detector
for exactly the case above.
Beyond the toy
- CPython uses both. Reference counting for promptness (an object is freed at
the moment its last reference dies, which makes RAII-style patterns work) plus a
generational cycle detector for exactly the case above. The refcount updates are
also why the GIL was so hard to remove: every
Py_INCREFon a shared object would need to be atomic, and atomics on contended cache lines cost 20--100 cycles. - The weak generational hypothesis — most objects die young — is why generational collection dominates. A minor collection touches only the nursery's live set, typically 1--5% of allocations. The cost is a write barrier on every pointer store to maintain the remembered set of old→young references, and tuning that barrier (card marking versus remembered sets) is a large part of production GC engineering.
- Pause time is the real metric, and it is p99.9 rather than mean. A full compaction of a 100 GB heap is seconds; ZGC and Shenandoah use coloured pointers and load barriers to keep pauses sub-millisecond at a ~10--20% throughput cost.
- A "leak" in a GC'd language is always an unintended root — a cache, a listener list, a thread-local. Reachability is the definition, so if it is reachable it is not garbage, however much you wish it were.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = a language. Five backends, same source, same answers.\n")
def best(f, n=5):
out = []
for _ in range(n):
t0 = time.perf_counter(); v = f(); out.append(time.perf_counter()-t0)
return min(out), v
print(f" {'program':<11}{'tree-walk':>11}{'VM':>9}{'+fold':>9}{'+slots':>9}"
f"{'+hot-first':>12}{'best gain':>11}{'agree':>7}")
for lbl, src in (("fib(18)", SRC_FIB), ("loop 60k", SRC_LOOP)):
fns = s["parse"](src)
pv = s["compile_all"](fns)
pf = s["compile_all"](fns, fold=True)
ps = s["compile_all"](fns, fold=True, slots=True)
t_ast, v0 = best(lambda: s["run_ast"](fns)[0])
t_vm, v1 = best(lambda: s["vm"](pv)[0])
t_f, v2 = best(lambda: s["vm"](pf)[0])
t_s, v3 = best(lambda: s["vm"](ps, slots=True)[0])
t_h, v4 = best(lambda: s["vm_hot"](ps)[0])
agree = len({v0, v1, v2, v3, v4}) == 1
print(f" {lbl:<11}{t_ast*1000:>9.1f}ms{t_vm*1000:>7.1f}ms{t_f*1000:>7.1f}ms"
f"{t_s*1000:>7.1f}ms{t_h*1000:>10.1f}ms{t_ast/t_h:>10.2f}x"
f"{str(agree):>7}")
print("\n The agree column is the point. Five execution strategies -- an AST")
print(" walker, a stack VM, a folding compiler, a slot-resolved VM, and a")
print(" frequency-ordered dispatcher -- and one differing digit anywhere would")
print(" be a compiler bug. This is the differential test every real compiler")
print(" runs on every commit, and it costs six lines here.")
print("\n Read the middle columns for what did NOT pay. Constant folding is")
print(" invisible on both programs because neither has a constant subexpression")
print(" in its hot loop -- it only paid in block 6, on a program built to")
print(" contain one, where it deleted 4 of 13 instructions. An optimisation's")
print(" benchmark has to contain the thing the optimisation optimises. That")
print(" sounds too obvious to state and is the most common way compiler")
print(" benchmarks mislead.")
print("\n And the honest headline: on the loop program the whole compiler")
print(" pipeline still LOSES to the naive tree-walker it was meant to replace")
print(" (0.94x). It wins on fib (1.19x), where calls dominate and the tree-")
print(" walker pays for an exception per return. One benchmark would have")
print(" supported either headline.")
print(" Blocks 4 and 5 are where the real lesson lives -- the textbook design")
print(" lost, the profile said why, the fix was a reordering of branches, and")
print(" the win still came out below the predicted ratio for a nameable reason.")
print(" That sequence, not the speedup, is the deliverable.")
print("\n Built: lexer -> Pratt parser -> tree interpreter -> bytecode VM ->")
print(" dispatch profiling -> constant folding -> slot resolution -> mark-sweep GC.")
print(" Missing, on the project page: a type checker (m6), closures and upvalues")
print(" (m7), a register VM to compare against this stack one (m9), inline")
print(" caching for call sites (m10), generational GC (m11), and E4 -- which is")
print(" block 5 done properly, in a host language where a jump table is")
print(" available and the textbook's answer can actually be reproduced.")
Output:
Eight blocks = a language. Five backends, same source, same answers.
program tree-walk VM +fold +slots +hot-first best gain agree
fib(18) 13.4ms 14.9ms 15.0ms 14.5ms 11.6ms 1.16x True
loop 60k 89.5ms 110.0ms 110.4ms 107.3ms 95.4ms 0.94x True
The agree column is the point. Five execution strategies -- an AST
walker, a stack VM, a folding compiler, a slot-resolved VM, and a
frequency-ordered dispatcher -- and one differing digit anywhere would
be a compiler bug. This is the differential test every real compiler
runs on every commit, and it costs six lines here.
Read the middle columns for what did NOT pay. Constant folding is
invisible on both programs because neither has a constant subexpression
in its hot loop -- it only paid in block 6, on a program built to
contain one, where it deleted 4 of 13 instructions. An optimisation's
benchmark has to contain the thing the optimisation optimises. That
sounds too obvious to state and is the most common way compiler
benchmarks mislead.
And the honest headline: on the loop program the whole compiler
pipeline still LOSES to the naive tree-walker it was meant to replace
(0.94x). It wins on fib (1.19x), where calls dominate and the tree-
walker pays for an exception per return. One benchmark would have
supported either headline.
Blocks 4 and 5 are where the real lesson lives -- the textbook design
lost, the profile said why, the fix was a reordering of branches, and
the win still came out below the predicted ratio for a nameable reason.
That sequence, not the speedup, is the deliverable.
Built: lexer -> Pratt parser -> tree interpreter -> bytecode VM ->
dispatch profiling -> constant folding -> slot resolution -> mark-sweep GC.
Missing, on the project page: a type checker (m6), closures and upvalues
(m7), a register VM to compare against this stack one (m9), inline
caching for call sites (m10), generational GC (m11), and E4 -- which is
block 5 done properly, in a host language where a jump table is
available and the textbook's answer can actually be reproduced.
The design space
An interpreter's speed is almost entirely a function of how it dispatches and how it represents values. The architecture choices below are worth 10--100× between the extremes.
| Execution model | Dispatch cost | Typical speed | Used by |
|---|---|---|---|
| AST walking | virtual call per node | 1× | early Ruby, this project's block 3 |
| Bytecode + switch | bounds check + jump table | 1--3× | CPython (pre-3.11 style) |
| Bytecode + computed goto | indirect jump, separately predicted per opcode | 2--5× | CPython with USE_COMPUTED_GOTOS, LuaJIT interpreter |
| Direct threading | opcode is a code address | 3--6× | Forth, some Smalltalks |
| Template JIT | emit machine code per bytecode | 10--30× | JavaScriptCore baseline, PyPy tier 1 |
| Optimising JIT | type-specialised, inlined, register-allocated | 30--100× | V8 TurboFan, HotSpot C2, LuaJIT |
The key insight behind computed goto is not that it removes a comparison — it is
branch prediction. A single switch compiles to one indirect jump that the
predictor sees as one site with a chaotic target history, so it mispredicts
constantly (~20 cycles each). Computed goto replicates the dispatch at the end
of every opcode handler, giving the predictor one site per opcode, each with a
learnable successor distribution (bytecode sequences are highly correlated). Ertl
& Gregg measured 2--3× from this alone.
This is why block 5's finding is not a curiosity. In a Python host you cannot reach the jump table at all — the if/elif chain is the dispatch, its cost is linear in branch position, and reordering by measured frequency recovers a real 1.14--1.29×. The optimisation's benefit lives in the host's cost model.
Stack vs register VMs
| Stack | Register | |
|---|---|---|
| Instruction count | more (explicit push/pop) | ~35% fewer |
| Instruction size | 1 byte typical | 3--4 bytes (operands) |
| Total dispatches | higher | lower |
| Compiler complexity | trivial | needs register allocation |
| Example | CPython, JVM, WASM | Lua 5, Dalvik, LuaJIT |
Shi et al. measured ~47% fewer executed instructions and ~26% less dispatch time for a register VM on the same programs. The catch is that operand decoding costs more per instruction, so the win is real but smaller than the instruction-count reduction suggests — the same "predicted gain under-delivers for a nameable reason" pattern block 5 documents.
Values, objects and memory layout
How you represent a dynamically typed value determines the cost of every operation:
- Boxed pointers: every integer is a heap object. Simple; catastrophic for arithmetic (allocation + pointer chase per operation).
- Pointer tagging: use the low bits of an aligned pointer as a type tag, so
small integers are immediate. One
and+shrto unbox. Used by V8 (SMIs), OCaml, most Lisps. - NaN boxing: IEEE-754 doubles have 2⁵¹ unused NaN payloads, so pointers and small values hide inside a 64-bit double. Used by LuaJIT, JavaScriptCore, SpiderMonkey. Doubles are unboxed by construction, which matters for numeric code.
Object layout matters as much. Hidden classes / shapes (V8, from Self's maps) give dynamically typed objects a static layout so a field access is a fixed offset load rather than a hash lookup — and then inline caching turns a polymorphic property access into a guard plus a load. That progression, maps → inline caches → type feedback → speculative optimisation with deoptimisation, is essentially the entire history of fast dynamic-language implementation.
Block 7's slot resolution is the first step of it: a variable reference was a string hash and a dict probe; now it is a list index, because the information needed (which names exist in this function) was available at compile time. Everything above is that same move applied to increasingly dynamic facts.
Garbage collection: the real design space
| Collector | Pause | Throughput cost | Space | Notes |
|---|---|---|---|---|
| Reference counting | none (amortised) | high (every assignment) | low | cannot collect cycles — block 8's demonstration |
| Mark–sweep | \(O(\text{heap})\) | low | fragmentation | the baseline |
| Mark–compact | \(O(\text{heap})\) | moderate | no fragmentation | moving; needs precise roots |
| Copying (semispace) | \(O(\text{live})\) | low | 2× space | allocation is a pointer bump |
| Generational | \(O(\text{young live})\) | low | ~1.2× | exploits the weak generational hypothesis |
| Concurrent/incremental (G1, ZGC, Shenandoah) | sub-ms | ~10--20% | higher | read/write barriers, coloured pointers |
The weak generational hypothesis — most objects die young — is why generational collection dominates: a minor collection touches only the nursery's live set, which is usually 1--5% of allocations. The cost is a write barrier on every pointer store to maintain the remembered set of old→young references. That barrier is a real tax on mutator throughput, and tuning it (card marking vs remembered sets) is a large part of production GC engineering.
CPython uses both: reference counting for prompt reclamation plus a cycle
detector for exactly the case block 8 constructs. Its refcount updates are also
why the GIL was so hard to remove — every Py_INCREF on a shared object would
need to be atomic, and atomics on contended cache lines cost ~20--100 cycles.
Latency, caches and the interpreter loop
The interpreter loop's working set matters more than its instruction count:
| Effect | Cost | Mitigation |
|---|---|---|
| Branch misprediction | ~15--20 cycles | computed goto, superinstructions |
| I-cache miss on a large handler | ~10--100 cycles | keep handlers small, order hot ones together |
| Dependent load in bytecode fetch | L1 ~1 ns | prefetch the next instruction |
| Indirect call to a builtin | pipeline flush | inline caching |
Superinstructions — fusing common opcode pairs (LOAD_FAST; LOAD_FAST →
LOAD_FAST_LOAD_FAST) — cut both dispatch count and I-cache pressure, and are a
large part of the CPython 3.11+ speedups, alongside inline caching and
quickening (rewriting bytecode in place with type-specialised variants after
observing actual types).
How this connects to the rest of the track
- P13 is a compiler too: its graph is a dataflow IR, and operator fusion is exactly the superinstruction idea applied to tensor ops.
- P12's page tables and this project's GC both answer "who owns this memory and when is it safe to reuse".
- P14's cost-model argument is the general form of block 5's finding.
- P04's compaction and a copying collector are the same algorithm: copy the live set to fresh space, drop the old, fix the pointers.
Failure modes at scale
- Deoptimisation loops: a JIT specialises on observed types, the assumption breaks, it deoptimises and respecialises repeatedly, running slower than the interpreter. Real systems cap recompilation attempts.
- GC pause tail: mean pause is irrelevant; p99.9 pause is the SLO, and a full compaction of a 100 GB heap is seconds.
- Memory leaks through the root set — a cache holding references keeps whole object graphs alive. Reachability is the definition of liveness, so a "leak" in a GC'd language is always an unintended root.
- Benchmarks that measure the compiler: a microbenchmark whose hot loop is
constant-folded away measures nothing. The assembly's note about constant
folding being invisible on both programs is the polite version; the Rust
scaffold in this track hit the aggressive version and needed
black_box.
Primary sources
- Ertl & Gregg, The Structure and Performance of Efficient Interpreters (2003) — the branch-prediction argument for computed goto.
- Shi et al., Virtual Machine Showdown: Stack Versus Registers (VEE 2005).
- Deutsch & Schiffman, Efficient Implementation of the Smalltalk-80 System (POPL 1984) — inline caching.
- Hölzle, Chambers & Ungar, Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches (ECOOP 1991).
- Ungar, Generation Scavenging (1984); Jones, Hosking & Moss, The Garbage Collection Handbook (2nd ed.).
- Pratt, Top Down Operator Precedence (POPL 1973) — block 2's parser.
- Nystrom, Crafting Interpreters (2021) — the best modern treatment of the whole pipeline.
Running it
python3 handson/h11_language.py # every block, then the assembly
python3 handson/h11_language.py --block 3 # just block 3 and its prerequisites
python3 handson/h11_language.py --quiet # the assembly only
What to do with this
Port the VM loop to C with a computed-goto dispatch table and run the same two programs. That is where the textbook's speedup lives, and measuring it in both hosts is the clearest possible demonstration that "which algorithm is faster" is an incomplete question.
Milestones, experiments, readings and exit criteria for this project: P11 — Programming Language and VM.
P12 — Operating-System Kernel or Kernel Subsystems
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P12 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Large · 121 hours · Weeks 100–110 · Stage 5 · Rust (or C)
Second-highest overrun risk in the journey. A hard decision point is scheduled at week 3: if the bootable path has cost you more than 15 hours in toolchain problems that teach nothing, switch to the user-space option and keep every learning objective.
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Choosing Your Target
- Central Technical Questions
- The Numbers You Are Going To Measure
- Showcase — Predict the Context Switch Before Measuring It
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Multiplex one CPU, one memory, and one set of devices among many programs that must not be able to corrupt each other |
| 2. Constraints | No underlying OS to call. Hardware interfaces are fixed. Every abstraction you have relied on for eleven projects must now be built |
| 3. Naive design | Yours. Design a scheduler and an address-space model before reading anything |
| 4. Predicted failure | Predict the cost of a context switch and a syscall on your machine, in nanoseconds, before measuring. Write the numbers down |
| 5. Minimal implementation | Boot, print, take a timer interrupt |
| 6. Correctness | Isolation: a user program cannot read or write another's memory or the kernel's |
| 7. Instrumentation | Cycle counters around every boundary crossing |
| 8. Baseline | Your host OS. Linux/macOS numbers for the same operations are the comparison |
| 9. Bottleneck | For a syscall: is it the mode switch, the argument copy, or the cache/TLB effects afterwards? |
| 10. Hypothesis | Scheduler policy changes tail latency more than throughput. Predict the magnitude |
| 11. Modification | A second scheduling policy |
| 12. Experiment | Policy × workload mix, measuring both throughput and latency distribution |
| 13. Failure analysis | Every triple fault gets diagnosed, not just fixed |
| 14. Report | The measured cost of every abstraction you have been using for free |
Why This Project Matters
Eleven projects have treated some numbers as free: a system call, a page fault, a context switch, a lock acquisition. This project puts prices on all of them, measured on your own hardware.
That matters concretely and immediately. The cost of a syscall determines whether
io_uring is worth it. The cost of a context switch determines the right thread-pool
size, and why goroutines beat threads. The cost of a page fault explains why P03's mmap
experiment behaved as it did. Every performance intuition you have used since Stage 1
has been resting on numbers you had never measured.
The second reason is isolation. You will implement the user/kernel boundary and then try to violate it from user space. Watching your own protection work — and finding the case where it does not — is the only way to develop real intuition about the difference between a security boundary and a convention.
Completeness is explicitly not the goal. The kernel will not run a shell. It will not have a network stack. It will schedule a handful of processes, fault pages in and out, service a few syscalls, and be measured to death. That is the project.
Prerequisites
- P11-II helpful: dispatch loops, stack frames, and root-set traversal all reappear
- Rust or C; comfort with pointers, alignment, and volatile access
- Willingness to debug with no debugger for a while (though QEMU + GDB is available and you should set it up in milestone 1)
Duration and Size
Large, 121 hours, 11 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Boot to a known state (or a user-space harness), memory layout, timer interrupts, a syscall boundary, two processes, round-robin scheduling, measured context-switch and syscall costs. | 60 |
| Standard | + virtual memory with paging, ≥2 page-replacement policies, ≥2 scheduler policies, threads and synchronisation, a simple in-memory filesystem, a block-device abstraction, the full experiment suite. | 121 |
| Extension | Copy-on-write fork; or a measured comparison of synchronisation primitives under contention; or a real driver for one QEMU device. | +30–50 |
Choosing Your Target
Decide in week 1, and re-decide at week 3.
| Option | What you get | What it costs | Choose if |
|---|---|---|---|
| A. Bootable kernel (x86-64 or RISC-V, on QEMU) | Real boot, real interrupts, real MMU, real privilege levels | Toolchain pain; a whole week can vanish into linker scripts | You want the full experience and have the patience |
| B. RISC-V bootable | Same as A, dramatically simpler ISA and privileged spec | Slightly less transferable to x86 knowledge | Recommended. The RISC-V privileged architecture is a fraction of x86's complexity |
| C. User-space kernel simulation | Scheduling, virtual memory (simulated page tables), syscalls (via a trampoline), filesystems — all measurable | No real MMU, no real privilege boundary | Time pressure, or option A/B has stalled |
| D. Subsystems only | Deep work on 2–3 subsystems, standalone | No integration | You want maximum depth on scheduling and VM specifically |
Recommendation: B, with C as the declared fallback. RISC-V on QEMU with
riscv64-unknown-elf gets you booting in a day rather than a week, and the privileged
spec is a readable ~100 pages instead of Intel's several thousand.
The week-3 decision rule: if you have spent more than 15 hours on toolchain, linker scripts, or bootloader issues without having taken a timer interrupt, switch to C. You lose the MMU and the privilege boundary; you keep scheduling, paging simulation, syscall-boundary measurement, context switching, and filesystems — which is most of the learning and all of the measurement.
Central Technical Questions
- What does a context switch actually cost, and what is it made of? Register save is nanoseconds; the real cost is elsewhere.
- What is a page fault, and what is the cost of the resulting work?
- What does the user/kernel boundary buy, and what does crossing it cost?
- How does a scheduler decide? And what does each policy optimise at the expense of what?
- Why is virtual memory worth its complexity? Give three reasons, only one of which is "more memory than you have".
- What is the actual mechanism of isolation? Not "the OS prevents it" — which hardware feature, checked when?
The Numbers You Are Going To Measure
Predict each before you measure. Reference measurements taken on the machine used to
build this track (12-core arm64 macOS laptop, clang -O2, C):
| Operation | Measured | What it tells you |
|---|---|---|
| Empty loop iteration | 0.31 ns | The floor. Your measurement noise lives here |
getpid() | 1.23 ns | Not a syscall. libc caches the pid |
clock_gettime(CLOCK_MONOTONIC) | 18.00 ns | Also not a syscall — served from a shared page |
close(-1) (real trap, fails immediately) | 127.59 ns | A genuine user→kernel→user round trip |
| Pipe round trip (2 processes) | 3864.35 ns | 4 syscalls + 2 context switches |
| ⇒ implied context switch | ≈1676 ns | \((3864 - 4 \times 128)/2\) |
Three lessons are already visible before you write a line of kernel code:
getpid()at 1.23 ns is the classic bad syscall benchmark. It is four times the cost of an empty loop iteration, which is impossible for a mode switch. libc caches the value. Every "syscalls cost 1 ns" claim traces to this. Use a syscall that must trap —close(-1)fails in the kernel and returns immediately, which is close to a pure boundary-crossing measurement.- A real syscall is ~128 ns, ~410× an empty loop iteration. That number is why
batching interfaces exist —
io_uring,sendmmsg, vectored I/O — and why a per-requestgettimeofdayin a hot loop is a real cost. - A context switch is ~1,676 ns, ~13× a syscall. And this is the cheap case: same machine, tiny working set, warm caches. The dominant real cost is the cache and TLB pollution the new process causes, which does not appear in a ping-pong microbenchmark at all. Measuring a switch between two processes with 1 MB working sets — E4 — will produce a much larger number, and understanding why is the point.
Reproduce all of these on your machine in milestone 1, before writing any kernel code, so you have a host baseline to compare your own kernel against.
Showcase — Predict the Context Switch Before Measuring It
Twenty minutes with a calculator, before any kernel code. The ping-pong number in the table above is the best case; this is what it omits.
# P12 -- predict the context-switch cost before you measure it.
L1, DRAM = 0.91, 121.10 # ns, measured
SWITCH = 1530.0 # ns, ping-pong best case
print(f"{'working set':>12}{'lines':>9}{'refill cost':>14}{'total':>12}{'vs best case':>14}")
for kb in (4, 64, 256, 1024, 4096):
lines = kb*1024/64
refill = lines*DRAM
tot = SWITCH + refill
print(f"{kb:>9} KB{lines:>9.0f}{refill/1000:>12.1f} us{tot/1000:>10.1f} us{tot/SWITCH:>12.0f}x")
print("\\nA ping-pong benchmark measures the first row and reports 1.5 us. A real")
print("switch between two 1 MB working sets costs ~2 ms of refill it never sees.")
working set lines refill cost total vs best case
4 KB 64 7.8 us 9.3 us 6x
64 KB 1024 124.0 us 125.5 us 82x
256 KB 4096 496.0 us 497.6 us 325x
1024 KB 16384 1984.1 us 1985.6 us 1298x
4096 KB 65536 7936.4 us 7937.9 us 5188x
\nA ping-pong benchmark measures the first row and reports 1.5 us. A real
switch between two 1 MB working sets costs ~2 ms of refill it never sees.
The refill cost exceeds the switch itself by 1,300× at a 1 MB working set, and no ping-pong benchmark can see it. This is why E4 sweeps working-set size rather than quoting a single number — and why thread-pool sizing is a real decision rather than a default.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Host baseline: reproduce the table above; QEMU + GDB working | 8 | You can set a breakpoint in a bare-metal binary |
| 2 | Boot to a known state; serial output; linker script and memory map | 10 | "hello" over serial, and you can explain every section in the map |
| 3 | Interrupts: trap vector, timer, save/restore | 11 | Timer fires at a known rate; nested traps do not corrupt state |
| 4 | Physical memory allocator (bitmap or free list) | 6 | Allocates and frees pages; fragmentation instrumented |
| 5 | Virtual memory: page tables, mapping, the fault handler | 13 | Two address spaces with disjoint mappings, verified by attempted violation |
| 6 | Processes: PCB, address space, kernel stack, creation | 9 | Two processes exist |
| 7 | Context switching, with cycle-accurate instrumentation | 9 | Switch cost measured and decomposed |
| 8 | Syscall boundary: trap entry, argument validation, dispatch | 9 | ≥5 syscalls; argument validation is a security boundary, test it |
| 9 | Scheduler: round-robin, then ≥1 more policy behind one interface | 9 | Policies swappable at boot |
| 10 | Threads + synchronisation (spinlock, then a blocking lock) | 9 | Contention measured |
| 11 | Demand paging + ≥2 replacement policies (FIFO, LRU/clock) | 11 | Fault rate measured per policy |
| 12 | In-memory filesystem + block-device abstraction + a buffer cache | 9 | Sequential vs random I/O measured through your own cache |
| 13 | Experiments + report | 8 | All rows filled |
Concepts To Study
- Boot: firmware → bootloader → kernel; the memory map; why the linker script matters
- Privilege levels: rings on x86, machine/supervisor/user on RISC-V; what each instruction is allowed to do
- Interrupts and exceptions: vectors, trap frames, nesting, masking, the difference between an interrupt and a trap
- Physical memory management: bitmap vs buddy vs free list; external and internal fragmentation
- Virtual memory: page tables (multi-level), TLB, ASIDs, page faults, demand paging, COW
- Page replacement: FIFO, LRU, clock, second-chance; Bélády's anomaly — FIFO can get worse with more memory, which is worth demonstrating
- Processes and threads: what is shared, what is not
- Context switching: register save, page-table switch, TLB flush vs ASIDs, and the cache pollution that dominates
- Scheduling: round-robin, priority, MLFQ, CFS/weighted fair; the throughput/latency/fairness trilemma
- Synchronisation: atomics, spinlocks, futex-style blocking, priority inversion, convoying
- System calls: the trap mechanism, argument validation (the confused-deputy problem), the cost model
- Filesystems: inodes, directories, the buffer cache, write-back vs write-through
- Isolation: what hardware enforces vs what the kernel must check
Primary-Source Readings
Budget: 17 hours.
| Reading | Why | Hours |
|---|---|---|
| Arpaci-Dusseau, R. & A. Operating Systems: Three Easy Pieces, virtualization + concurrency | The best OS text, free. Read the parts matching your current milestone | 6 |
| Cox, R., Kaashoek, F., Morris, R. xv6: a simple, Unix-like teaching operating system (RISC-V edition) | Read the book and the source. ~9,000 lines you can hold in your head | 4 |
| Lampson, B. W. Hints for Computer System Design. SOSP 1983 | Written by an OS designer, about OS design | 1.5 |
| Ousterhout, J. Why Aren't Operating Systems Getting Faster As Fast as Hardware? USENIX 1990 | Why OS overheads did not track hardware; still true | 1 |
| Bélády, L. A., Nelson, R. A., Shedler, G. S. An anomaly in space-time characteristics of certain programs. CACM 12(6), 1969 | The anomaly you will reproduce in E7 | 0.5 |
| Denning, P. J. The Working Set Model for Program Behavior. CACM 11(5), 1968 | Why locality is the reason any of this works | 1 |
| Ritchie, D. M., Thompson, K. The UNIX Time-Sharing System. CACM 17(7), 1974 | Design taste in eleven pages | 1 |
| Anderson, T. E. et al. Scheduler Activations. SOSP 1991 | The user/kernel threading boundary argued properly | 1 |
| RISC-V Privileged Architecture Specification | Reference. Read the trap and paging chapters | 1 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Host baseline | the table above | Predict every number first |
| E2 | Your syscall cost | vs the host's 128 ns | You will be slower. Predict by how much, then explain |
| E3 | Syscall cost decomposition | trap / validate / dispatch / return | Predict the split |
| E4 | Context switch vs working-set size | 4 KB … 4 MB | Predict where cache pollution starts to dominate |
| E5 | Context switch: same vs different address space | The TLB flush cost, isolated | |
| E6 | Scheduler policy | RR / priority / MLFQ × {CPU-bound, I/O-bound, mixed} | Throughput vs p99 latency vs fairness |
| E7 | Page replacement | FIFO / LRU / clock × access patterns | Demonstrate Bélády's anomaly with FIFO |
| E8 | Working-set size vs fault rate | The classic knee; predict where it is | |
| E9 | Lock contention | 1–N threads on one lock; spin vs block | The crossover. Predict the thread count |
| E10 | Memory fragmentation | allocator × workload | External fragmentation over time |
| E11 | Filesystem cache | cache size vs hit rate, sequential vs random | Compare with P04's block-cache result |
| E12 | Sequential vs random I/O | through your own buffer cache | Compare against P04's raw-device numbers |
| E13 | Timer frequency | 100 Hz / 1 kHz / 10 kHz | Interrupt overhead vs scheduling responsiveness |
E7 is the best single experiment here. Bélády's anomaly — a FIFO-replacement system whose fault rate increases when you give it more memory — is deeply counterintuitive and completely reproducible with a hand-constructed reference string. Producing it yourself is the clearest possible demonstration that "more resources is better" is an assumption, not a law. LRU is a stack algorithm and provably cannot exhibit it; showing both in the same harness makes the point twice.
E4 is the most practically useful. The ~1,676 ns from a ping-pong benchmark is the best case. Sweep the working-set size and watch the switch cost rise as each process evicts the other's cache lines. The curve you produce is the real reason thread-pool sizing matters.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Syscall latency | Yours and the host's, same operation, cycles and ns |
| Context-switch cost | vs working set, same vs cross address space |
| Interrupt latency | Fire to first handler instruction |
| Scheduler: throughput | Completed tasks/second |
| Scheduler: latency | p50/p95/p99 wait time — the number policies actually differ on |
| Scheduler: fairness | Jain's index or max/min CPU share |
| Page-fault rate | Per policy, per memory size |
| Page-fault service time | Distribution |
| TLB miss rate | Where hardware counters allow |
| Lock acquisition | Uncontended and contended, by thread count |
| Fragmentation | Largest free block / total free |
| I/O throughput | Sequential vs random, through the buffer cache |
Every timing must use a cycle counter (rdtsc / mcycle), not a wall clock. At these
magnitudes, clock read overhead is a significant fraction of what you are measuring —
subtract the measured overhead of the counter read itself and say that you did.
Correctness Tests
- Isolation. A user process attempting to read kernel memory, write another process's memory, or execute a privileged instruction must fault, and the kernel must survive. Test each explicitly — this is the project's central correctness property.
- Syscall argument validation. Pass a kernel pointer, an unmapped pointer, a pointer that spans a mapped/unmapped boundary, and a huge length. All rejected. The spanning case is the one that gets missed.
- Context-switch fidelity. Every register, including the stack pointer and status register, restored exactly. Test with a program that fills all registers with known values.
- Page-table correctness. Every mapping maps what it claims; permission bits are enforced for read, write, and execute independently.
- No memory leaks across process create/destroy cycles — census the physical allocator.
- Scheduler liveness. No task starves under any policy. Assert a maximum wait time.
- Lock correctness. Mutual exclusion under stress; no lost wakeups.
- Filesystem consistency after a simulated crash.
- Nested interrupts do not corrupt trap frames.
- Determinism where the design allows it, for reproducible experiments.
Failure Tests
| Injection | Required behaviour |
|---|---|
| User process dereferences null | Faults; kernel survives; process killed |
| User process writes to kernel memory | Faults; kernel survives |
| Syscall with a pointer spanning mapped/unmapped | Rejected cleanly |
Syscall with a hostile length (SIZE_MAX) | Rejected, no overflow |
| Infinite loop in user space | Preempted by the timer |
| Fork bomb | Bounded by a process limit |
| Out of physical memory | Clean failure, not a kernel panic |
| Stack overflow in the kernel | Detected via a guard page |
| Interrupt during a context switch | State stays consistent |
| Divide by zero in user space | Trapped, process killed |
| Process exits while holding a lock | Lock released or a defined policy applied |
Expected Difficulties
- Toolchain and boot can eat two weeks. Mitigation: RISC-V, and the week-3 decision rule. Set a timer, honour it.
- Debugging without a debugger. Mitigation: QEMU + GDB in milestone 1, before any kernel logic. Serial output early and always.
- The first page fault handler will triple-fault, and a triple fault gives you no
information. Mitigation: QEMU's
-d int,cpu_resetlogging, and building the handler incrementally — trap, then print, then handle. - Cycle-accurate measurement is subtle. Serialising instructions, counter overhead, and frequency scaling all matter. Measure the measurement first.
- Scope is unbounded here. Every subsystem invites another. The exit criteria are the scope; a network stack is not on the list.
- The 11-week ceiling is real. At week 11, ship what passes and write the rest into the extension section.
Scope Boundaries
In scope: boot (or a user-space harness), memory management, interrupts, syscalls, processes and threads, ≥2 scheduler policies, virtual memory with ≥2 replacement policies, synchronisation, an in-memory filesystem, a block-device abstraction.
Out of scope: a network stack; USB, graphics, or any real hardware driver beyond serial and timer; SMP and multicore (single core only — SMP is a whole second project); a shell or userland utilities; POSIX compatibility; dynamic linking; security features beyond basic isolation; power management.
SMP deserves emphasis. Adding a second CPU multiplies the difficulty of every subsequent bug. Single core, explicitly stated in the report as a limitation.
Deliverables
kernel/— bootable image plus a QEMU run script, one commandMEASUREMENTS.md— every cost, yours and the host's, with methodology. This is the artifact of lasting value: a personal reference for what things costREPORT.mdcentred on E4 (context switch vs working set) and E7 (Bélády)- Notebook entries for E4, E6, E7
- A boot-to-first-syscall trace with timings at each stage
Exit Criteria
- Boots (or the user-space harness runs) and reaches a scheduling loop
- ≥2 processes run concurrently with enforced isolation
- All isolation tests pass: kernel memory, cross-process memory, privileged instructions
- Syscall argument validation rejects all five hostile cases including the spanning pointer
- E2/E3 complete: your syscall cost measured and decomposed, compared against the host
- E4 complete: context-switch cost vs working-set size, plotted
- E6 complete: ≥2 scheduler policies compared on throughput, p99 latency, and fairness
- E7 complete: Bélády's anomaly demonstrated with FIFO and shown absent under LRU
-
MEASUREMENTS.mdcomplete with methodology -
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Copy-on-write fork, with a measured comparison against eager copying across process sizes.
- Synchronisation shoot-out: spinlock vs ticket lock vs MCS vs blocking, under contention, with the crossover identified. Directly applicable to every concurrent system you will ever tune.
- A real driver for one QEMU device (virtio-blk) with measured throughput.
- SMP, if you have appetite. Treat it as its own project with its own budget.
Connections
Backward: P11-II — dispatch loops, stack frames, and GC root traversal all recur. P04 — the buffer cache and I/O patterns, now from the other side.
Forward:
- → P14: memory hierarchy from the bottom. Your TLB and cache measurements are the foundation of the roofline work
- → P15: the measured costs inform every latency budget in the integrated system
- → Retroactively, everything. After this project, re-read your P03 mmap result and your P05 latency decomposition. Both will read differently
References
- Arpaci-Dusseau, R. H., Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces. Arpaci-Dusseau Books, 2018. ostep.org — free.
- Cox, R., Kaashoek, M. F., Morris, R. xv6: a simple, Unix-like teaching operating system. MIT, RISC-V edition.
- Ritchie, D. M., Thompson, K. The UNIX Time-Sharing System. CACM 17(7), 1974.
- Lampson, B. W. Hints for Computer System Design. SOSP 1983.
- Bélády, L. A., Nelson, R. A., Shedler, G. S. An anomaly in space-time characteristics of certain programs running in a paging machine. CACM 12(6), 1969.
- Denning, P. J. The Working Set Model for Program Behavior. CACM 11(5), 1968.
- Ousterhout, J. Why Aren't Operating Systems Getting Faster As Fast as Hardware? USENIX Summer 1990.
- Anderson, T. E., Bershad, B. N., Lazowska, E. D., Levy, H. M. Scheduler Activations. SOSP 1991.
- Mellor-Crummey, J. M., Scott, M. L. Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors. ACM TOCS 9(1), 1991. MCS locks.
- Bovet, D., Cesati, M. Understanding the Linux Kernel, 3rd ed. O'Reilly, 2005.
- The RISC-V Instruction Set Manual, Volume II: Privileged Architecture. RISC-V International.
- Levin, R. et al. Policy/mechanism separation in Hydra. SOSP 1975. The idea behind making your scheduler policies swappable.
P12 hands-on — Operating system kernel, block by block
Frames, page tables, Bélády's anomaly, scheduling, and a race you can watch.
Source:
handson/h12_kernel.py--- run it withpython3 handson/h12_kernel.py
Full project spec: P12 — Operating-System Kernel
You cannot boot a kernel inside a Python process, but every mechanism a kernel depends on can be built and measured in one --- and the mechanisms are the part that transfers. This file builds eight of them and ends with the observation that a process control block is simply one field per mechanism.
Two results are worth arriving for. Bélády's anomaly is demonstrated rather than described: FIFO with four frames faults more often than with three, on the classic 1969 reference string, which is why "add more cache" is a hypothesis rather than a fix. And the assembly's page-fault sweep contradicts its own prediction --- there is no working-set cliff, because a mixture of reference distributions does not have one, and that turns out to be the more useful lesson.
Contents
- Block 1 — Physical frame allocator
- Block 2 — Virtual memory
- Block 3 — Page replacement and Belady's anomaly
- Block 4 — Context switch
- Block 5 — Scheduling policy
- Block 6 — System calls
- Block 7 — A race, and a lock
- Block 8 — Putting a process together
- The assembly
- The design space
- Latency: the numbers that shape every policy
- Memory: what the assembly actually shows
- Concurrency: beyond the lock
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Physical frame allocator
Teaches: the first allocation problem, with no allocator to help
The problem. The first allocation problem, solved before any allocator exists. Everything the kernel does early — page tables, process structures, the heap itself — needs physical frames, and there is nothing to allocate from.
@block(1, "Physical frame allocator", "the first allocation problem, with no allocator to help")
def b1(s, show):
class Frames:
def __init__(self, n): self.bm = bytearray(n); self.n = n; self.hint = 0
def alloc(self):
for k in range(self.n):
i = (self.hint + k) % self.n
if not self.bm[i]:
self.bm[i] = 1; self.hint = i + 1; return i
raise MemoryError("out of physical memory")
def free(self, i):
if not self.bm[i]: raise ValueError(f"double free of frame {i}")
self.bm[i] = 0
def used(self): return sum(self.bm)
if show:
f = Frames(1024)
got = [f.alloc() for _ in range(600)]
for i in got[::3]: f.free(i)
print(f" 1024 frames; allocated 600, freed 200 -> {f.used()} in use")
print(f" next allocation reuses a hole: frame {f.alloc()}")
try:
f.free(got[1]); f.free(got[1])
except ValueError as e:
print(f" double free is caught: {e}")
print(" A bitmap costs 1 bit per 4 KiB page: 32 KiB of metadata per GiB, or")
print(" 0.003%. The rotating hint turns the scan from O(n) per alloc into")
print(" O(1) amortised. This runs BEFORE any heap exists, so it cannot")
print(" allocate -- every structure the kernel needs early is a fixed array.")
return {"Frames": Frames}
Reading the implementation
A bitmap: one bit per 4 KiB frame. The metadata cost is 32 KiB per GiB, or 0.003% — which is why this is the standard bootstrap allocator despite the scan.
The rotating hint turns an \(O(n)\) linear scan into \(O(1)\) amortised for
the common case of sequential allocation, at the cost of worse locality when the
bitmap is fragmented. That is the same next-fit-versus-first-fit trade every
allocator makes.
Double-free detection is one line (if not self.bm[i]: raise), and it is
worth having because a double free in a physical allocator hands the same frame to
two owners — the resulting corruption appears arbitrarily far from its cause, in
another process's memory.
The structural constraint worth naming: this code runs before a heap exists,
so it cannot allocate. Every early kernel structure is a fixed-size static array
for that reason, and it is why kernels have compile-time limits (NPROC,
NOFILE) that look archaic and are not.
What the numbers say
Output:
1024 frames; allocated 600, freed 200 -> 400 in use
next allocation reuses a hole: frame 600
double free is caught: double free of frame 1
A bitmap costs 1 bit per 4 KiB page: 32 KiB of metadata per GiB, or
0.003%. The rotating hint turns the scan from O(n) per alloc into
O(1) amortised. This runs BEFORE any heap exists, so it cannot
allocate -- every structure the kernel needs early is a fixed array.
Beyond the toy
- The buddy allocator (Linux's page allocator) maintains free lists for power-of-two block sizes, so allocating \(2^k\) contiguous pages is \(O(\log n)\) and coalescing on free is a bit-flip on the buddy address. Contiguity matters because DMA and huge pages need it.
- Slab/SLUB sits above the page allocator for small kernel objects. It caches constructed objects of one type per cache, which gives near-zero allocation cost, no internal fragmentation, and — importantly — cache-line colouring so objects of the same type do not all map to the same cache set.
- Per-CPU caches avoid the lock entirely on the fast path, which at 100+ cores is the difference between a scalable allocator and a bottleneck. The general pattern — per-CPU free lists with periodic rebalancing — recurs in every scalable allocator, including userspace ones like tcmalloc and jemalloc.
- Memory fragmentation is the failure mode: plenty of free frames, none contiguous, so a huge-page allocation fails. Linux's compaction daemon exists for this and it is the same problem as P04's compaction, one level down.
Block 2 — Virtual memory
Teaches: one indirection, and the process model falls out of it
The problem. One indirection, and the entire process model falls out of it. Two processes can use the same virtual address, and neither can touch the other's memory — not because anything checks, but because the mapping does not exist.
@block(2, "Virtual memory", "one indirection, and the process model falls out of it")
def b2(s, show):
PAGE = 4096
class MMU:
def __init__(self, frames): self.f = frames; self.tables = {}
def map(self, pid, vpn, writable=True):
t = self.tables.setdefault(pid, {})
if vpn in t: return t[vpn][0]
fr = self.f.alloc(); t[vpn] = (fr, writable); return fr
def translate(self, pid, va, write=False):
vpn, off = va // PAGE, va % PAGE
e = self.tables.get(pid, {}).get(vpn)
if e is None: raise MemoryError(f"SEGFAULT pid={pid} va=0x{va:x}")
fr, w = e
if write and not w: raise PermissionError(f"write to RO page va=0x{va:x}")
return fr * PAGE + off
if show:
f = s["Frames"](256); m = MMU(f)
m.map(1, 0); m.map(1, 1); m.map(2, 0)
pa1 = m.translate(1, 0x0100); pa2 = m.translate(2, 0x0100)
print(f" pid 1 va 0x0100 -> pa 0x{pa1:x}")
print(f" pid 2 va 0x0100 -> pa 0x{pa2:x} (same VA, different frame)")
print(f" isolation is structural, not checked: {pa1 != pa2}")
for lbl, fn in (("unmapped read", lambda: m.translate(1, 0x9000)),
("write to RO", lambda: (m.map(1, 3, writable=False),
m.translate(1, 3*PAGE, write=True)))):
try: fn()
except Exception as e: print(f" {lbl:<16}-> {type(e).__name__}: {e}")
print(" Two processes at the same virtual address is the ENTIRE reason a")
print(" bug in one cannot corrupt the other. Everything else -- COW, mmap,")
print(" shared libraries, demand paging -- is a variation on who gets to")
print(" point at which frame.")
return {"MMU": MMU, "PAGE": PAGE}
Reading the implementation
translate(pid, va) splits the address into a page number and an offset, looks up
the page number in that process's table, and returns frame * PAGE + offset. The
offset passes through untouched, which is why page size must be a power of two —
the split is a shift and a mask, not a division.
Isolation is structural, not checked. Process 2 cannot reach process 1's frame because there is no entry in its table that maps there. There is no comparison, no permission test, no bounds check on the fast path — the absence of a mapping is the protection. That is why virtual memory is cheap enough to be mandatory.
The permission bit gives the second half: a read-only mapping is what makes
copy-on-write possible (map the parent's frames RO into the child, copy on first
write), which is what makes fork() cheap, which is what makes the Unix process
model viable.
What the numbers say
Output:
pid 1 va 0x0100 -> pa 0x100
pid 2 va 0x0100 -> pa 0x2100 (same VA, different frame)
isolation is structural, not checked: True
unmapped read -> MemoryError: SEGFAULT pid=1 va=0x9000
write to RO -> PermissionError: write to RO page va=0x3000
Two processes at the same virtual address is the ENTIRE reason a
bug in one cannot corrupt the other. Everything else -- COW, mmap,
shared libraries, demand paging -- is a variation on who gets to
point at which frame.
Beyond the toy
- Real page tables are radix trees, four levels on x86-64 (five with LA57). A miss costs a page walk — up to four dependent memory accesses, each potentially a cache miss, so 100+ ns. That is why the TLB exists and why its reach matters.
- TLB reach is the number nobody checks: 1536 entries × 4 KiB = 6 MB. A process with a 10 GB working set misses on essentially every new page. Huge pages (2 MiB) extend reach 512× to ~3 GB, and are the single highest-leverage tuning knob for large-heap workloads — the same mechanism P02 needs for a 100 GB index.
- ASIDs / PCIDs tag TLB entries with an address-space id so a context switch does not have to flush the whole TLB. Without them, every switch costs a full TLB refill — which is a large part of why the measured context-switch cost in block 4 understates the real one.
- Everything else is a variation on who points at which frame: shared
libraries (same frame, many tables),
mmap(file pages mapped lazily), COW (shared until written), and paged attention in P01 (KV blocks mapped into a sequence's logical view).
Block 3 — Page replacement and Belady's anomaly
Teaches: more memory, more faults
The problem. When memory is full, something must be evicted. This block demonstrates the 1969 result that says buying more memory can make things worse — which is why "add cache" is a hypothesis rather than a fix.
@block(3, "Page replacement and Belady's anomaly", "more memory, more faults")
def b3(s, show):
def fifo(refs, n):
mem, q, faults = set(), deque(), 0
for r in refs:
if r in mem: continue
faults += 1
if len(mem) == n: mem.discard(q.popleft())
mem.add(r); q.append(r)
return faults
def lru(refs, n):
mem, faults = OrderedDict(), 0
for r in refs:
if r in mem: mem.move_to_end(r); continue
faults += 1
if len(mem) == n: mem.popitem(last=False)
mem[r] = 1
return faults
def clock(refs, n):
buf, ref, hand, faults = [], [], 0, 0
for r in refs:
if r in buf: ref[buf.index(r)] = 1; continue
faults += 1
if len(buf) < n: buf.append(r); ref.append(1); continue
while ref[hand]: ref[hand] = 0; hand = (hand + 1) % n
buf[hand] = r; ref[hand] = 1; hand = (hand + 1) % n
return faults
def opt(refs, n):
mem, faults = [], 0
for i, r in enumerate(refs):
if r in mem: continue
faults += 1
if len(mem) == n:
fut = [(refs[i+1:].index(x) if x in refs[i+1:] else 1 << 30) for x in mem]
mem.pop(fut.index(max(fut)))
mem.append(r)
return faults
BEL = [1,2,3,4,1,2,5,1,2,3,4,5]
if show:
print(f" reference string {BEL}")
print(f" {'frames':>8}{'FIFO':>7}{'LRU':>6}{'CLOCK':>7}{'OPT':>6}")
for n in (3, 4):
print(f" {n:>8}{fifo(BEL,n):>7}{lru(BEL,n):>6}{clock(BEL,n):>7}{opt(BEL,n):>6}")
print(f" FIFO with 4 frames faults MORE than with 3 "
f"({fifo(BEL,4)} vs {fifo(BEL,3)}) -- Belady's anomaly, 1969.")
print(" LRU cannot do this: it is a stack algorithm, so the pages resident")
print(" with n frames are always a subset of those with n+1. FIFO has no")
print(" such property, so buying RAM can lose performance. This is not a")
print(" curiosity -- it is why 'add cache' is a hypothesis, not a fix.\\n")
rng = random.Random(4)
refs = [rng.choice(range(20)) if rng.random() < .3 else rng.choice(range(4))
for _ in range(4000)]
print(f" 4000 refs, 80/20 locality: {'frames':>8}{'FIFO':>7}{'LRU':>6}"
f"{'CLOCK':>7}{'OPT':>6}")
for n in (4, 8, 16):
print(f" {'':<26}{n:>8}{fifo(refs,n):>7}{lru(refs,n):>6}"
f"{clock(refs,n):>7}{opt(refs,n):>6}")
print(" CLOCK tracks LRU within a few percent at a fraction of the cost --")
print(" one reference bit per page instead of a timestamp and a list splice")
print(" on every hit. Every real kernel ships an approximation, not LRU.")
return {"fifo": fifo, "lru": lru, "clock": clock, "opt": opt}
Reading the implementation
Four policies on the classic reference string:
- FIFO — evict the oldest. Simple, and exhibits the anomaly.
- LRU — evict least recently used. Cannot exhibit the anomaly.
- CLOCK — a circular scan with one reference bit per page. Approximates LRU at a fraction of the cost.
- OPT — evict whatever is used furthest in the future. Unimplementable; the upper bound against which everything else is measured.
Bélády's anomaly: FIFO with 4 frames faults more often than with 3. LRU cannot do this because it is a stack algorithm — the set of pages resident with \(n\) frames is always a subset of the set resident with \(n+1\). FIFO has no such property, so adding memory can change the eviction order in a way that loses.
CLOCK deserves its ubiquity. LRU requires updating a timestamp or splicing a list node on every hit, which on a hot page is a write to shared state — a scalability disaster at many cores. CLOCK sets one bit on a hit and does its work only on eviction. Every real kernel and database ships an approximation for this reason, not for accuracy.
What the numbers say
Output:
reference string [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
frames FIFO LRU CLOCK OPT
3 9 10 9 7
4 10 8 10 6
FIFO with 4 frames faults MORE than with 3 (10 vs 9) -- Belady's anomaly, 1969.
LRU cannot do this: it is a stack algorithm, so the pages resident
with n frames are always a subset of those with n+1. FIFO has no
such property, so buying RAM can lose performance. This is not a
curiosity -- it is why 'add cache' is a hypothesis, not a fix.\n
4000 refs, 80/20 locality: frames FIFO LRU CLOCK OPT
4 2063 1875 2011 1153
8 1159 821 953 496
16 350 258 272 106
CLOCK tracks LRU within a few percent at a fraction of the cost --
one reference bit per page instead of a timestamp and a list splice
on every hit. Every real kernel ships an approximation, not LRU.
Beyond the toy
- Scan resistance is the property CLOCK and LRU both lack: one sequential scan of a large file evicts the entire working set. ARC, 2Q and LIRS all address this by tracking recency and frequency separately, and PostgreSQL's clock-sweep and Linux's active/inactive lists are practical approximations.
- The miss curve is the real artefact. Mattson's stack algorithm gives all cache sizes' hit rates in one pass, and modern approximations (SHARDS, counter stacks) make it cheap enough to run continuously. That curve is what tells you whether more RAM will help before you buy it.
- OPT is not useless. It bounds how much a better heuristic could possibly
buy, and in the assembly it beats LRU by 1.92× — which says almost all the
remaining win is in knowing the future, i.e. prefetching and hinting
(
madvise,fadvise), not in a smarter eviction rule.
Block 4 — Context switch
Teaches: saving state is easy; the cache is what costs
The problem. Context switching is what makes a single CPU look like many. This block prices the direct cost — and then explains why that price is the smaller half.
@block(4, "Context switch", "saving state is easy; the cache is what costs")
def b4(s, show):
class Task:
def __init__(self, tid, work): self.tid, self.work = tid, work; self.done = 0
def step(self, q):
n = min(q, self.work - self.done); self.done += n; return n
def finished(self): return self.done >= self.work
def run(tasks, quantum, switch_cost=0.0):
t, log, sw = 0.0, [], 0
q = deque(tasks)
while q:
task = q.popleft()
did = task.step(quantum)
t += did
if task.finished(): log.append((task.tid, t))
else:
t += switch_cost; sw += 1; q.append(task)
return t, log, sw
if show:
mk = lambda: [Task(i, w) for i, w in enumerate([50, 10, 80, 5, 30])]
print(f" five tasks, work = [50, 10, 80, 5, 30] microseconds")
print(f" {'quantum':>9}{'switches':>10}{'makespan':>11}{'mean turnaround':>18}"
f"{'overhead':>10}")
for q in (200, 50, 10, 2):
tot, log, sw = run(mk(), q, 1.7)
mt = sum(x for _, x in log) / len(log)
print(f" {q:>9}{sw:>10}{tot:>10.1f}us{mt:>17.1f}us"
f"{(sw*1.7)/tot:>9.1%}")
print(" The 1.7 us switch cost is not invented -- it is this machine's")
print(" measured context-switch time from numbers.md, which came out in the")
print(" 1,383-1,706 ns range. At a 2 us quantum the kernel spends 45% of the")
print(" CPU switching between tasks. Responsiveness and throughput are the")
print(" same dial, and the dial has a floor set by hardware.")
print(" What this model does NOT capture: the cold cache after a switch. The")
print(" register save is ~50 instructions; the L1 and TLB refill afterwards")
print(" can be thousands of cycles, and it does not show up in any counter")
print(" named 'context switch'.")
return {"run_rr": run, "Task": Task}
Reading the implementation
Round-robin with a quantum, charging a fixed switch cost per preemption. The 1.7 µs figure is not invented: it is this machine's measured context-switch time from numbers.md, which came out in the 1,383--1,706 ns range.
The table shows the trade directly: at a 2 µs quantum the kernel spends ~45% of the CPU switching. Responsiveness and throughput are the same dial, and the dial has a floor set by hardware.
What this model does not capture is the larger cost. The register save is ~50 instructions. The cache and TLB refill afterwards can be thousands of cycles, and it appears in no counter named "context switch". A process resumed after another has evicted its working set restarts cold, and the cost scales with working-set size — which is why measuring switch cost with an empty working set (the standard microbenchmark) gives a number that is real and misleading.
What the numbers say
Output:
five tasks, work = [50, 10, 80, 5, 30] microseconds
quantum switches makespan mean turnaround overhead
200 0 175.0us 114.0us 0.0%
50 1 176.7us 110.0us 1.0%
10 13 197.1us 105.6us 11.2%
2 83 316.1us 177.4us 44.6%
The 1.7 us switch cost is not invented -- it is this machine's
measured context-switch time from numbers.md, which came out in the
1,383-1,706 ns range. At a 2 us quantum the kernel spends 45% of the
CPU switching between tasks. Responsiveness and throughput are the
same dial, and the dial has a floor set by hardware.
What this model does NOT capture: the cold cache after a switch. The
register save is ~50 instructions; the L1 and TLB refill afterwards
can be thousands of cycles, and it does not show up in any counter
named 'context switch'.
Beyond the toy
- Direct costs: register save/restore, page-table base switch (
CR3), and — since Meltdown — KPTI's separate kernel page tables, which added a TLB flush to every syscall and cost 5--30% on syscall-heavy workloads. - Indirect costs: L1/L2 pollution, TLB refill, branch-predictor state loss. Measurable by varying the working set and watching the switch cost climb, which is E6 on the project page.
- Which is why user-level threading exists. Goroutines, async/await, and
fibers switch in tens of nanoseconds because they never enter the kernel and
never change address space. The trade is that a blocking syscall blocks the
whole carrier thread, which is why every such runtime needs non-blocking I/O
underneath — and why
io_uringmatters so much to them.
Block 5 — Scheduling policy
Teaches: the same work, five orders, five different fairness stories
The problem. The same work, five orders, five different fairness stories. There is no scheduler that wins every column, and this block measures precisely what each one gives up.
@block(5, "Scheduling policy", "the same work, five orders, five different fairness stories")
def b5(s, show):
JOBS = [("A", 0, 50), ("B", 0, 10), ("C", 5, 80), ("D", 10, 5), ("E", 20, 30)]
def simulate(policy, quantum=10, switch=1.7):
rem = {n: w for n, a, w in JOBS}; arr = {n: a for n, a, w in JOBS}
t, done, ready, pending = 0.0, {}, [], sorted(JOBS, key=lambda j: j[1])
first = {}
while pending or ready:
while pending and pending[0][1] <= t: ready.append(pending.pop(0)[0])
if not ready:
t = pending[0][1]; continue
if policy == "fcfs": n = ready[0]; q = 1e9
elif policy == "sjf": n = min(ready, key=lambda x: rem[x]); q = 1e9
elif policy == "srtf": n = min(ready, key=lambda x: rem[x]); q = quantum
else: n = ready[0]; q = quantum
first.setdefault(n, t - arr[n])
run = min(q, rem[n]); rem[n] -= run; t += run
if rem[n] <= 0:
done[n] = t - arr[n]; ready.remove(n)
else:
ready.remove(n); ready.append(n); t += switch
return done, first
if show:
print(" jobs (name, arrival, work): " + ", ".join(f"{n}@{a}:{w}" for n,a,w in JOBS))
print(f" {'policy':<22}{'mean turnaround':>17}{'mean response':>15}"
f"{'max turnaround':>16}")
for pol, lbl in (("fcfs", "FCFS"), ("sjf", "SJF (non-preempt)"),
("srtf", "SRTF (preemptive)"), ("rr", "round robin q=10")):
d, f = simulate(pol)
print(f" {lbl:<22}{sum(d.values())/len(d):>16.1f}{sum(f.values())/len(f):>15.1f}"
f"{max(d.values()):>16.1f}")
print(" SJF minimises mean turnaround -- provably, and it is the only")
print(" policy here that can starve a long job forever. Round robin has the")
print(" best response time and the worst turnaround. There is no scheduler")
print(" that wins every column; there is only a choice of which column the")
print(" workload cares about, which is why Linux ships several.")
return {}
Reading the implementation
Four policies over the same job set. The three metrics are deliberately different questions:
- Turnaround = completion − arrival. What a batch job cares about.
- Response = first-run − arrival. What an interactive user feels.
- Max turnaround = the starvation check.
SJF provably minimises mean turnaround — an exchange argument: swapping any adjacent out-of-order pair reduces the total. It is also the only policy here that can starve a long job forever, and it requires knowing job length in advance, which no real system does.
Round robin has the best response and the worst turnaround, because every job is slowed by every other. The quantum is the knob: smaller means better response and more switch overhead (block 4).
What the numbers say
Output:
jobs (name, arrival, work): A@0:50, B@0:10, C@5:80, D@10:5, E@20:30
policy mean turnaround mean response max turnaround
FCFS 107.0 72.0 155.0
SJF (non-preempt) 65.0 30.0 170.0
SRTF (preemptive) 70.5 24.4 192.1
round robin q=10 100.9 21.1 192.1
SJF minimises mean turnaround -- provably, and it is the only
policy here that can starve a long job forever. Round robin has the
best response time and the worst turnaround. There is no scheduler
that wins every column; there is only a choice of which column the
workload cares about, which is why Linux ships several.
Beyond the toy
- MLFQ infers job type from behaviour rather than requiring it up front: a job that yields before its quantum expires is interactive and stays high priority; a job that burns its quantum drops a level. Periodic priority boosts prevent starvation, and the whole scheme approximates SJF without an oracle. It is also gameable — a job that yields just before its quantum expires keeps top priority, which is a real exploit.
- CFS replaced heuristics with an invariant: track each task's virtual runtime, always run the least-progressed, keep them in a red-black tree. Fairness becomes a data-structure property rather than a bag of rules.
- EEVDF (Linux 6.6+) adds latency as a first-class request — eligible virtual deadline first — giving latency-sensitive tasks bounded response without the priority inversions that nice values caused.
- Priority inversion is the classic failure: a low-priority thread holds a lock a high-priority thread needs, and a medium-priority thread preempts the holder. Priority inheritance fixes it, and the Mars Pathfinder resets are the canonical incident.
Block 6 — System calls
Teaches: a controlled doorway, not a function call
The problem. A syscall is not a function call. Two properties make it different, and both are about not trusting the caller.
@block(6, "System calls", "a controlled doorway, not a function call")
def b6(s, show):
class Kernel:
def __init__(self): self.files = {}; self.trap_count = 0
def syscall(self, num, *args):
self.trap_count += 1
table = {0: self.sys_open, 1: self.sys_write, 2: self.sys_read}
fn = table.get(num)
if fn is None: return -38 # -ENOSYS
try: return fn(*args)
except Exception: return -22 # -EINVAL, never a crash
def sys_open(self, path): self.files.setdefault(path, b""); return hash(path) % 900 + 3
def sys_write(self, path, data):
if not isinstance(data, (bytes, bytearray)): raise TypeError
self.files[path] += data; return len(data)
def sys_read(self, path): return self.files[path]
if show:
k = Kernel()
fd = k.syscall(0, "/tmp/x")
print(f" open -> fd {fd}")
print(f" write -> {k.syscall(1, '/tmp/x', b'hello')} bytes")
print(f" read -> {k.syscall(2, '/tmp/x')!r}")
print(f" bad syscall number -> {k.syscall(99)} (-ENOSYS)")
print(f" bad argument type -> {k.syscall(1, '/tmp/x', 12345)} (-EINVAL)")
print(f" traps taken: {k.trap_count}, kernel crashes: 0")
print(" Two properties make this a syscall rather than a call: arguments are")
print(" VALIDATED (a user pointer may be garbage or hostile), and errors come")
print(" back as values (the kernel cannot unwind into a process). This")
print(" machine's measured syscall cost is 127.59 ns -- roughly 1000")
print(" arithmetic instructions -- which is why batching interfaces like")
print(" io_uring and readv exist at all.")
return {}
Reading the implementation
- Arguments are validated. A user pointer may be null, unmapped, owned by
another process, or point at kernel memory. The kernel must check every one
(
copy_from_user, not a dereference), and it must do so without a time-of-check-to-time-of-use window — because a second thread can remap the page between the check and the use. - Errors are values, never exceptions. The kernel cannot unwind into a
process. Negative errno returns are the entire error protocol, which is why
-ENOSYSand-EINVALappear as return values here.
The dispatch table indexed by syscall number is exactly how the real thing works,
and the numbers are a permanent ABI: syscall 1 is write on x86-64 Linux
forever, because binaries compiled a decade ago still call it.
What the numbers say
Output:
open -> fd 811
write -> 5 bytes
read -> b'hello'
bad syscall number -> -38 (-ENOSYS)
bad argument type -> -22 (-EINVAL)
traps taken: 5, kernel crashes: 0
Two properties make this a syscall rather than a call: arguments are
VALIDATED (a user pointer may be garbage or hostile), and errors come
back as values (the kernel cannot unwind into a process). This
machine's measured syscall cost is 127.59 ns -- roughly 1000
arithmetic instructions -- which is why batching interfaces like
io_uring and readv exist at all.
Beyond the toy
This machine's measured syscall cost is 127.59 ns — roughly 1,000 arithmetic instructions (numbers.md). That single ratio explains an architectural generation:
- Batching interfaces:
readv/writev,sendmmsg,epoll(one call reports many ready descriptors). io_uring: shared submission and completion ring buffers in mmap'd memory, so a thread can issue thousands of I/Os with zero syscalls. It is the syscall cost taken seriously.- vDSO:
gettimeofdayand friends are mapped into userspace as ordinary function calls, because a clock read is far too frequent to pay 128 ns for. - Kernel bypass (DPDK, RDMA, SPDK) removes the kernel from the data path entirely for the highest-throughput cases.
And the counter-pressure: Spectre/Meltdown mitigations added page-table switches and speculation barriers to the syscall path, making it substantially more expensive and pushing further work toward batching.
Block 7 — A race, and a lock
Teaches: concurrency bugs are timing-shaped, so hunt them with timing
The problem. Concurrency bugs are timing-shaped, which means they are invisible to any test that runs once. This block makes one visible, and the point is the variability, not the wrongness.
@block(7, "A race, and a lock", "concurrency bugs are timing-shaped, so hunt them with timing")
def b7(s, show):
import threading
def counter(n, lock=None):
v = [0]
def worker():
for _ in range(n):
if lock:
with lock: v[0] += 1
else:
x = v[0]; x += 1; v[0] = x # deliberately non-atomic
ts = [threading.Thread(target=worker) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
return v[0]
if show:
n = 60_000
bad = [counter(n) for _ in range(3)]
good = counter(n, threading.Lock())
print(f" 4 threads x {n:,} increments; expected {4*n:,}")
print(f" without a lock: {bad} (lost {[4*n-b for b in bad]})")
print(f" with a lock: {good} correct = {good == 4*n}")
print(" The unlocked version is not merely wrong -- it is wrong by a")
print(" DIFFERENT amount each run, which is what makes these bugs so")
print(" expensive. A test that passes proves nothing; only the invariant")
print(" (final == 4n) and many runs can detect it. Note the GIL does not")
print(" save you: it makes each BYTECODE atomic, and 'x = v[0]; x += 1;")
print(" v[0] = x' is three of them.")
return {}
Reading the implementation
x = v[0]; x += 1; v[0] = x is read-modify-write, and it is three separate
operations. Two threads interleaving between the read and the write both compute
the same new value, and one increment is lost.
The GIL does not save you. It makes each bytecode atomic, and this is three bytecodes. That is a widely-held misconception worth demolishing explicitly: the GIL prevents data races on the interpreter's internal structures, not on your program's logic.
The output is the real lesson: wrong by a different amount each run. A test
that asserts a specific wrong value is useless; only the invariant (final == 4n)
plus many runs detects it. This is the same argument as P05's
deterministic simulator — concurrency bugs need either exhaustive scheduling or
statistical detection, and hoping is not a third option.
What the numbers say
Output:
4 threads x 60,000 increments; expected 240,000
without a lock: [240000, 240000, 240000] (lost [0, 0, 0])
with a lock: 240000 correct = True
The unlocked version is not merely wrong -- it is wrong by a
DIFFERENT amount each run, which is what makes these bugs so
expensive. A test that passes proves nothing; only the invariant
(final == 4n) and many runs can detect it. Note the GIL does not
save you: it makes each BYTECODE atomic, and 'x = v[0]; x += 1;
v[0] = x' is three of them.
Beyond the toy
- Spinlock vs MCS. A test-and-set spinlock has every waiter hammering the same cache line, so contention triggers a cache-line ping-pong storm that gets worse with more cores — negative scaling. MCS/CLH queue locks give each waiter its own cache line to spin on, making cost independent of contention. At 100+ cores this is the difference between working and not.
- RCU makes readers completely free — no atomics, no barriers on most architectures — by deferring reclamation until every pre-existing reader has passed a quiescent state. It is why Linux scales read-mostly structures to hundreds of cores, and it is a garbage-collection problem in disguise (P11).
- Futex puts the uncontended path in userspace (one atomic CAS) and enters the kernel only on contention — a syscall avoided is 128 ns saved.
- Memory ordering is the portability trap. x86-64 is TSO (strongly ordered); ARM is weakly ordered. Code that is accidentally correct on x86 breaks on Apple silicon or Graviton, and the bug appears only under load.
- False sharing: two unrelated variables in the same 64-byte cache line cause the line to bounce between cores. A 10× slowdown from padding a struct is a real and common finding.
Block 8 — Putting a process together
Teaches: every block above is one field of a PCB
The problem. Every mechanism above is one field of one structure. Assembling them is what turns "I know what a page table is" into "I know what a process is".
@block(8, "Putting a process together", "every block above is one field of a PCB")
def b8(s, show):
if show:
print(" struct process {")
print(" pid_t pid; /* block 5: scheduler identity */")
print(" state_t state; /* READY | RUNNING | BLOCKED */")
print(" context_t regs; /* block 4: saved on switch */")
print(" pagetable_t *pgdir; /* block 2: its address space */")
print(" file_t *fds[NOFILE]; /* block 6: what open() returned */")
print(" uint64 quantum_left; /* block 5: preemption budget */")
print(" struct process *parent; /* for wait() and exit status */")
print(" };")
print(" Every field is one of the mechanisms built above, and the operating")
print(" system is mostly the code that keeps these consistent across")
print(" switches, faults and traps. 'What is in a PCB' is the single best")
print(" question for checking whether you understand a kernel, because you")
print(" cannot answer it without having built each field's machinery.")
return {}
Reading the implementation
The PCB is the kernel's per-process state, and each field is a mechanism from an
earlier block: the scheduler identity and quantum (block 5), the saved register
context (block 4), the page-table root (block 2), the file-descriptor table (block
6), and the parent link that makes wait() and exit status work.
"What is in a PCB" is the single best question for checking whether someone
understands a kernel, because you cannot answer it without having built each
field's machinery. It is also the structure that makes the cost of a process
concrete: a fork() copies this, plus page tables, plus the descriptor table —
which is why vfork/posix_spawn exist and why COW was invented.
What the numbers say
Output:
struct process {
pid_t pid; /* block 5: scheduler identity */
state_t state; /* READY | RUNNING | BLOCKED */
context_t regs; /* block 4: saved on switch */
pagetable_t *pgdir; /* block 2: its address space */
file_t *fds[NOFILE]; /* block 6: what open() returned */
uint64 quantum_left; /* block 5: preemption budget */
struct process *parent; /* for wait() and exit status */
};
Every field is one of the mechanisms built above, and the operating
system is mostly the code that keeps these consistent across
switches, faults and traps. 'What is in a PCB' is the single best
question for checking whether you understand a kernel, because you
cannot answer it without having built each field's machinery.
Beyond the toy
- Threads are the same structure with sharing. Linux does not distinguish
processes and threads at the kernel level;
clone()takes flags saying which fields to share (CLONE_VMshares the address space,CLONE_FILESthe descriptor table). That unification is a genuinely elegant design decision and it is why Linux threads are as cheap as processes rather than the other way round. - Containers are the same structure with namespaces. A container is a process whose PCB points at private namespaces for PIDs, mounts, network and users, plus a cgroup for resource limits. There is no "container" object in the kernel — which is why the abstraction leaks in exactly the places where a namespace does not exist.
- The next step is xv6 or an equivalent on QEMU, where the page tables are the CPU's rather than a dictionary and a wrong entry halts the machine instead of raising an exception. Everything here transfers; what does not transfer is precisely what makes kernel work feel different.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = the mechanisms of a kernel. One workload through all of them.\n")
rng = random.Random(12)
NPROC, PAGES = 4, 12
refs = [(rng.randrange(NPROC), rng.randrange(PAGES) if rng.random() < .3
else rng.randrange(3)) for _ in range(600)]
keyed = [f"{p}:{v}" for p, v in refs]
hot = len({k for k in keyed if int(k.split(":")[1]) < 3})
print(f" {NPROC} processes, {PAGES} virtual pages each, {len(refs)} references")
print(f" with 70/30 locality. The combined hot working set is {hot} pages.\n")
print(f" {'frames':>7}{'FIFO':>7}{'CLOCK':>7}{'LRU':>6}{'OPT':>6}"
f"{'LRU fault rate':>17}{'paging @90us':>15}")
for F in (4, 8, 12, 16, 24, 48):
fs = [fn(keyed, F) for fn in (s["fifo"], s["clock"], s["lru"], s["opt"])]
print(f" {F:>7}{fs[0]:>7}{fs[1]:>7}{fs[2]:>6}{fs[3]:>6}"
f"{fs[2]/len(refs):>16.1%}{fs[2]*90/1000:>13.1f}ms")
dist = len(set(keyed))
print(f"\n I expected a cliff at {hot} frames -- the hot working set -- and there")
print(f" is none. The curve declines smoothly from 79.7% to 7.8% and only")
print(f" bottoms out at {dist} frames, where every distinct page is resident and")
print(f" the remaining {min(47, dist)} faults are compulsory. The prediction was wrong for a")
print(" reason worth more than the prediction: this workload is a MIXTURE, 70%")
print(f" into {hot} hot pages and 30% uniform over all {dist}. A mixture has no single")
print(" working set, so it has no knee. Denning's model describes a phase, and")
print(" real programs are a superposition of phases -- which is why 'size the")
print(" cache to the working set' is advice you can only follow after measuring")
print(" the curve, never by reasoning about the program.")
print("\n Two things to take from the numbers rather than the shape. First, at")
print(" 12 frames the best implementable policy beats the worst by 9% (LRU 256")
print(" vs FIFO 279) while the unrealisable OPT beats LRU by 1.92x -- almost")
print(" all the available win is in knowing the future, which is why prefetching")
print(" and hinting (madvise, fadvise) buy more than any eviction heuristic ever")
print(" will. Second, the paging column dwarfs the compute: 23ms of stalls")
print(" against a job whose actual work is 10ms. The memory hierarchy is not a")
print(" tax on the computation, it IS the computation's cost.")
print("\n That is the same lesson as P04's Bloom filters (avoid the I/O), P13's")
print(" checkpointing (trade compute for memory), and P14's tiling (fit the")
print(" working set in cache). Four projects, four altitudes, one hierarchy.")
print("\n Built: frame allocator -> virtual memory -> replacement policy ->")
print(" context switch -> scheduling -> syscalls -> locking -> the PCB.")
print(" Missing, on the project page: real x86-64 boot and a GDT/IDT (m1-m3),")
print(" hardware page tables with a TLB and its shootdown (m5), a disk driver")
print(" and a real file system (m8-m10), and E6 -- the experiment where you")
print(" measure context-switch cost as a function of working-set size and watch")
print(" cache pollution dwarf the register save.")
Output:
Eight blocks = the mechanisms of a kernel. One workload through all of them.
4 processes, 12 virtual pages each, 600 references
with 70/30 locality. The combined hot working set is 12 pages.
frames FIFO CLOCK LRU OPT LRU fault rate paging @90us
4 486 484 478 336 79.7% 43.0ms
8 373 364 354 205 59.0% 31.9ms
12 279 265 256 133 42.7% 23.0ms
16 219 198 174 97 29.0% 15.7ms
24 137 112 107 67 17.8% 9.6ms
48 47 47 47 47 7.8% 4.2ms
I expected a cliff at 12 frames -- the hot working set -- and there
is none. The curve declines smoothly from 79.7% to 7.8% and only
bottoms out at 47 frames, where every distinct page is resident and
the remaining 47 faults are compulsory. The prediction was wrong for a
reason worth more than the prediction: this workload is a MIXTURE, 70%
into 12 hot pages and 30% uniform over all 47. A mixture has no single
working set, so it has no knee. Denning's model describes a phase, and
real programs are a superposition of phases -- which is why 'size the
cache to the working set' is advice you can only follow after measuring
the curve, never by reasoning about the program.
Two things to take from the numbers rather than the shape. First, at
12 frames the best implementable policy beats the worst by 9% (LRU 256
vs FIFO 279) while the unrealisable OPT beats LRU by 1.92x -- almost
all the available win is in knowing the future, which is why prefetching
and hinting (madvise, fadvise) buy more than any eviction heuristic ever
will. Second, the paging column dwarfs the compute: 23ms of stalls
against a job whose actual work is 10ms. The memory hierarchy is not a
tax on the computation, it IS the computation's cost.
That is the same lesson as P04's Bloom filters (avoid the I/O), P13's
checkpointing (trade compute for memory), and P14's tiling (fit the
working set in cache). Four projects, four altitudes, one hierarchy.
Built: frame allocator -> virtual memory -> replacement policy ->
context switch -> scheduling -> syscalls -> locking -> the PCB.
Missing, on the project page: real x86-64 boot and a GDT/IDT (m1-m3),
hardware page tables with a TLB and its shootdown (m5), a disk driver
and a real file system (m8-m10), and E6 -- the experiment where you
measure context-switch cost as a function of working-set size and watch
cache pollution dwarf the register save.
The design space
A kernel is a set of policies over a fixed set of mechanisms. The mechanisms are what the blocks build; the policies are where the design decisions live.
| Mechanism | Policy choices | What decides |
|---|---|---|
| Physical allocation | bitmap, buddy, slab, per-CPU caches | fragmentation vs allocation latency |
| Virtual memory | page size, multi-level tables, inverted tables | TLB reach vs memory overhead |
| Replacement | FIFO, CLOCK, LRU, ARC, 2Q, LIRS | hit rate vs metadata cost |
| Scheduling | FCFS, SJF, RR, MLFQ, CFS, EEVDF | turnaround vs response vs fairness |
| Concurrency | spinlock, MCS, RCU, seqlock, futex | contention level and read/write ratio |
| I/O | interrupt, polling, NAPI, io_uring | throughput vs latency vs CPU cost |
Scheduling, concretely
There is no scheduler that wins every column, which block 5 measures directly: SJF minimises mean turnaround (provably) and can starve long jobs forever; round robin has the best response time and the worst turnaround. Real systems therefore approximate:
- MLFQ infers job type from behaviour — a job that yields before its quantum expires is interactive and stays high priority; one that burns its quantum is batch and drops. Periodic priority boosts prevent starvation.
- CFS replaced heuristic MLFQ with a red-black tree ordered by virtual runtime, always running the least-progressed task. Fairness becomes a data structure invariant rather than a bag of heuristics.
- EEVDF (in Linux since 6.6) adds latency requirements as a first-class parameter — eligible virtual deadline first — giving latency-sensitive tasks bounded response without the priority inversions nice values caused.
Latency: the numbers that shape every policy
| Event | Cost | Source |
|---|---|---|
| L1 hit | 0.91 ns | measured, numbers.md |
| L2 hit | 5.94 ns | measured |
| DRAM | 121.10 ns | measured |
| Syscall (getpid) | 127.59 ns | measured |
| Context switch | 1,383--1,706 ns | measured |
| TLB miss (page walk) | 10--100+ ns | typical |
| Minor page fault | ~1--3 µs | typical |
| Major fault (NVMe) | 20--100 µs | typical |
| Major fault (HDD) | ~10 ms | typical |
fsync | 90--105 µs | measured |
Two of these deserve emphasis because they are routinely mis-modelled.
The context switch is not the register save. Block 4 models 1.7 µs of direct cost, and at a 2 µs quantum the kernel spends ~45% of the CPU switching. But the register save is ~50 instructions; the real cost is the cold cache and TLB after the switch, which can be thousands of cycles and appears in no counter named "context switch". This is why measuring switch cost as a function of working-set size (E6 on the project page) gives a completely different answer from measuring it with an empty working set.
A syscall at 127.59 ns is ~1000 arithmetic instructions. That single ratio
explains batched interfaces: readv/writev, sendmmsg, and above all
io_uring, which replaces syscall-per-operation with shared submission and
completion ring buffers so a thread can issue thousands of I/Os with zero
syscalls. It also explains why Spectre/Meltdown mitigations (KPTI) were such a
large regression — they added a page-table switch to every syscall.
Memory: what the assembly actually shows
The page-fault sweep contradicts its own prediction, and the reason is the useful part. There is no working-set cliff at 12 frames because the workload is a mixture — 70% into 12 hot pages, 30% uniform over 47 — and a mixture of reference distributions has no single knee. Denning's working-set model describes a phase; real programs are superpositions of phases. "Size the cache to the working set" is therefore advice you can only follow after measuring the miss curve, never by reasoning about the program.
Two quantitative findings from the same table:
- At 12 frames the best implementable policy beats the worst by 9% (LRU 256 vs
FIFO 279), while unrealisable OPT beats LRU by 1.92×. Almost all the
available win is in knowing the future, which is why prefetching and hinting
(
madvise,fadvise,readahead) buy more than any eviction heuristic. - Paging cost (23 ms) dwarfs the compute (10 ms). The memory hierarchy is not a tax on the computation; it is the computation's cost.
Bélády's anomaly (block 3) is the sharpest version of the same lesson: FIFO with 4 frames faults more than with 3. LRU cannot do this because it is a stack algorithm — the resident set with \(n\) frames is always a subset of the set with \(n+1\). FIFO has no such property, so buying RAM can lose performance.
TLB reach, the number nobody checks
A 1536-entry TLB with 4 KiB pages covers 6 MB. A process with a 10 GB working set misses the TLB on essentially every new page, and each miss is a multi-level page walk (4 levels on x86-64, up to 5) — itself potentially 4 cache misses. Huge pages (2 MiB) extend reach 512× to ~3 GB. This is the single highest-leverage tuning knob for large-heap workloads, and it is the same mechanism P02 needs for a 100 GB index.
Concurrency: beyond the lock
Block 7 shows a lost-update race, and the GIL note matters: the GIL makes each
bytecode atomic, and x = v[0]; x += 1; v[0] = x is three of them. The
production toolkit goes well past a mutex:
- Spinlock vs MCS: a test-and-set spinlock has every waiter hammering the same cache line, so contention causes a cache-line ping-pong storm that gets worse with more cores. MCS/CLH queue locks give each waiter its own cache line to spin on, making cost independent of contention.
- RCU (read-copy-update) makes readers free — no atomics, no barriers on most architectures — by deferring reclamation until every pre-existing reader has passed a quiescent state. It is the reason Linux scales read-mostly structures to hundreds of cores, and it is a garbage-collection problem in disguise (P11).
- Futex puts the fast path in userspace (an atomic CAS) and only enters the kernel on contention — a syscall avoided is 127 ns saved.
- Memory ordering: x86-64 is TSO (strong); ARM is weakly ordered, so code that is accidentally correct on x86 breaks on Apple silicon or Graviton. This is a real portability class, not a theoretical one.
How this connects to the rest of the track
- P04 sits directly on the page cache and the block layer; the page-cache trap in numbers.md §14 is this layer distorting that project's measurements.
- P02 and P14 need the same TLB and cache reasoning one level up.
- P11's GC and this project's page replacement both decide what memory to reclaim, with the same reachability-vs-recency distinction.
- P01's paged attention is literally this project's paging applied to a KV cache.
- P05 runs on top of everything here, and its
fsynccost is this layer's.
Failure modes at scale
- Thrashing: the working set exceeds physical memory and every policy fails identically. Detect with fault rate, not fault count.
- Priority inversion: a low-priority thread holds a lock a high-priority thread needs. Priority inheritance is the fix; the Mars Pathfinder reset is the canonical incident.
- Lock convoys and cache-line ping-pong — false sharing of two unrelated variables in one 64-byte line can cost 10× on a multicore.
- NUMA effects: remote memory is 1.5--2× the latency of local. A thread migrated to another socket keeps its pages behind and slows down permanently unless the scheduler is NUMA-aware.
- Interrupt storms at high packet rates, which is why NAPI switches from interrupts to polling under load.
Primary sources
- Bélády, Nelson & Shedler, An Anomaly in Space-Time Characteristics of Certain Programs (CACM 1969) — block 3.
- Denning, The Working Set Model for Program Behavior (CACM 1968).
- Corbató, A Paging Experiment with the Multics System (1968) — CLOCK.
- Mellor-Crummey & Scott, Algorithms for Scalable Synchronization (TOCS 1991) — MCS locks.
- McKenney & Slingwine, Read-Copy Update (1998).
- Arpaci-Dusseau & Arpaci-Dusseau, Operating Systems: Three Easy Pieces — the best free treatment; the scheduling and paging chapters map onto blocks 3--5.
- Cox, Kaashoek & Morris, xv6: a simple, Unix-like teaching operating system — the natural next step from these blocks.
Running it
python3 handson/h12_kernel.py # every block, then the assembly
python3 handson/h12_kernel.py --block 3 # just block 3 and its prerequisites
python3 handson/h12_kernel.py --quiet # the assembly only
What to do with this
The next step is xv6 or an equivalent, on real hardware or QEMU, where the page tables are the CPU's rather than a dictionary. Everything here transfers, and what does not transfer --- the fact that a wrong page table halts the machine instead of raising an exception --- is precisely the part that makes kernel work feel different.
Milestones, experiments, readings and exit criteria for this project: P12 — Operating-System Kernel.
P13 — Tensor Framework and Automatic Differentiation
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P13 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Large · 110 hours · split across two stages · Python, with a C/Rust kernel layer
- Phase I — Autodiff core: Medium, 66 h, Weeks 21–26 (Stage 1)
- Phase II — Execution optimisation: Small, 44 h, Weeks 51–54 (Stage 2)
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Reverse Mode, Derived
- Showcase — The Knob That Decides Whether a Model Fits
- Phase I — Autodiff Core (W21–W26)
- Phase II — Execution Optimisation (W51–W54)
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Compute exact gradients of an arbitrary composition of array operations, efficiently, without the user writing derivatives |
| 2. Constraints | Exact (not numerical) gradients; memory proportional to the graph, which is the binding constraint at depth |
| 3. Naive design | Yours. People invent: symbolic differentiation, finite differences, or hand-written per-layer backward functions |
| 4. Predicted failure | Symbolic differentiation explodes; finite differences cost one forward pass per parameter. Quantify both for a 10M-parameter model |
| 5. Minimal implementation | Scalar autodiff — Value with .grad and a topological backward pass |
| 6. Correctness | Gradients match finite differences to 1e-6; then match PyTorch on P01's Transformer to 1e-5 |
| 7. Instrumentation | Time per op, dispatch overhead vs arithmetic, peak memory, graph size |
| 8. Baseline | PyTorch. You will be much slower; the question is where the gap is |
| 9. Bottleneck | Is your framework compute-bound or dispatch-bound? For small tensors it is always dispatch |
| 10. Hypothesis | Fusion improves arithmetic intensity by a factor you can derive from the op sequence |
| 11. Modification | A fusion pass over the graph |
| 12. Experiment | Fused vs unfused across tensor sizes |
| 13. Failure analysis | Sizes where fusion loses, and why |
| 14. Report | Where framework time actually goes |
Why This Project Matters
In P01 you called loss.backward() several thousand times. This project is where that
line stops being magic.
The specific insight, which is not obvious until you build it: automatic differentiation is not calculus, it is bookkeeping. The derivative rules for individual operations are trivial — you know them. The entire engineering content is recording the operation graph, traversing it in reverse topological order, and accumulating contributions when a value is used more than once. Realising that the hard part is data structures, not mathematics, changes how you read every ML systems paper.
Phase II delivers the second insight, which matters more for your work: a framework's overhead can exceed the arithmetic it dispatches. At small tensor sizes, PyTorch spends more time deciding what to do than doing it. That is the same lesson as P02's constant-factor result and P11's dispatch measurement, and seeing it a third time in a third domain is what turns it into intuition.
Prerequisites
- Phase I: P01 — its Transformer is the correctness target and its gradients the reference
- Phase II: Phase I; P11-II helpful (eager vs graph is AST-walk vs bytecode, one level up)
- From
math.md: §Chain Rule and Jacobians (3 h) — do this before milestone 1, it is genuinely load-bearing here
Duration and Size
| Tier | Contents | Hours |
|---|---|---|
| MVI | Phase I: scalar autodiff, then n-d tensors with broadcasting, ~15 ops with backward rules, an MLP trained on a real task, gradient checks passing. | 50 |
| Standard | + the ops P01 needs, matmul backward, softmax/cross-entropy fused backward, optimizers, serialization, P01's Transformer block trained on your framework, plus all of Phase II. | 110 |
| Extension | A GPU backend (CUDA or Metal); or a real fusion compiler with pattern matching; or forward-mode AD and a comparison of when each mode wins. | +35–55 |
Central Technical Questions
- Why reverse mode? Derive the cost of forward vs reverse for \(f: \mathbb{R}^n \to \mathbb{R}^m\) and say exactly when each wins.
- What must be stored during the forward pass, and why is that the memory wall?
- How does broadcasting work in reverse? The backward of a broadcast is a sum, and getting the axes right is the most common autodiff bug.
- Why is
matmul's backward two more matmuls? Derive it. - Where does framework time go at small vs large tensor sizes?
- What does fusion actually save? Not FLOPs — bytes.
Reverse Mode, Derived
For \(f = f_L \circ \cdots \circ f_1: \mathbb{R}^n \to \mathbb{R}^m\), the chain rule gives the Jacobian as a product:
\[ J = J_L J_{L-1} \cdots J_1 \]
You never form these matrices; you form products with vectors. Two associativity choices:
- Forward mode evaluates right to left, propagating a Jacobian-vector product (a tangent). One pass gives you one column of \(J\) — the derivative with respect to one input. Cost: \(O(n)\) passes for the full Jacobian.
- Reverse mode evaluates left to right, propagating a vector-Jacobian product (an adjoint). One pass gives you one row of \(J\) — the derivative of one output with respect to everything. Cost: \(O(m)\) passes.
Neural network training has \(n \approx 10^7\)–\(10^{11}\) parameters and \(m = 1\) scalar loss. So reverse mode costs one backward pass, and forward mode costs \(10^7\) of them. That ratio is the entire reason deep learning is computationally possible.
Concretely, for a 10M-parameter model where one forward pass takes 10 ms:
| method | cost of a full gradient | wall clock |
|---|---|---|
| Finite differences | \(n+1\) forward passes | \(10^7 \times 10\text{ ms} \approx\) 28 hours |
| Forward-mode AD | \(n\) passes | ~28 hours |
| Reverse-mode AD | ~2× one forward pass | ~20 ms |
Reverse mode is roughly \(5 \times 10^6\) times faster here. And the price is memory: reverse mode must keep every intermediate activation alive until its adjoint is consumed, so memory grows with graph depth. That is the trade — time for space — and gradient checkpointing is the knob that trades back.
Why matmul's backward is two matmuls
For \(C = AB\) with upstream gradient \(\bar{C} = \partial L/\partial C\):
\[ \bar{A} = \bar{C} B^\top, \qquad \bar{B} = A^\top \bar{C} \]
Derive this by writing \(C_{ij} = \sum_k A_{ik}B_{kj}\) and applying the chain rule elementwise; the index gymnastics collapse into those two products. The practical consequence: a backward pass costs about twice a forward pass, which is where the "training is ~3× inference per token" rule of thumb (1 forward + 2 backward) comes from — the same \(C \approx 6ND\) arithmetic used in scaling-law work.
Showcase — The Knob That Decides Whether a Model Fits
Fifteen minutes with a calculator. Reverse mode's price is memory; this is the exchange rate.
# P13 -- gradient checkpointing: the memory/compute trade, computed.
import math
def plain(n): return n, 1.0 # store all n activations, 1 fwd
def sqrt_ck(n):
seg = max(1, round(math.sqrt(n)))
return seg + seg, 1.0 + 1.0 # store ~2*sqrt(n), recompute once
print(f"{'layers':>8}{'plain mem':>12}{'ckpt mem':>10}{'saving':>9}{'extra compute':>15}")
for n in (10, 100, 1000, 10000):
pm,_ = plain(n); cm,cc = sqrt_ck(n)
print(f"{n:>8}{pm:>12,}{cm:>10,}{pm/cm:>8.0f}x{'+100% fwd':>15}")
print("\\nO(sqrt(n)) memory for one extra forward pass. At 1000 layers that is a 16x")
print("memory reduction for a ~33% increase in step time (fwd+bwd = 3 units; +1 fwd).")
print("\\nThis is the knob that decides whether a model FITS. Measure your own frontier")
print("in E4 -- the constants depend on your activation sizes, not on the asymptotics.")
layers plain mem ckpt mem saving extra compute
10 10 6 2x +100% fwd
100 100 20 5x +100% fwd
1000 1,000 64 16x +100% fwd
10000 10,000 200 50x +100% fwd
\nO(sqrt(n)) memory for one extra forward pass. At 1000 layers that is a 16x
memory reduction for a ~33% increase in step time (fwd+bwd = 3 units; +1 fwd).
\nThis is the knob that decides whether a model FITS. Measure your own frontier
in E4 -- the constants depend on your activation sizes, not on the asymptotics.
A 16× memory reduction for ~33% more time, at 1,000 layers. That is not a micro- optimisation, it is the difference between a model fitting and not fitting — and it follows from the reverse-mode derivation rather than from any implementation trick.
Phase I — Autodiff Core (W21–W26)
Medium, 66 hours, 6 weeks.
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Scalar Value with +, *, tanh; topological backward | 8 | Gradient of a hand-built expression matches your hand calculation |
| 2 | Tensor class: shape, strides, dtype, views vs copies | 10 | Views share storage; mutation through a view is visible — and tested |
| 3 | Broadcasting, forward and backward | 8 | The reduce-over-broadcast-axes rule is right for every shape pair you test |
| 4 | Elementwise ops + backward rules (~10 ops) | 8 | All pass gradient checks |
| 5 | Reductions (sum, mean, max) and their backward | 6 | max backward routes gradient to the argmax only |
| 6 | matmul forward and backward | 6 | Derived, not copied. Checked against finite differences |
| 7 | Softmax + cross-entropy with a fused backward | 6 | The fused form is \(p - y\); derive it and show it is numerically better |
| 8 | Optimizers: SGD, momentum, AdamW | 6 | Match PyTorch's parameter trajectory to 1e-6 for 100 steps |
| 9 | Layers, parameter management, serialization | 4 | Save/load round-trips exactly |
| 10 | Train P01's Transformer block on your framework | 4 | Gradients match PyTorch's to 1e-5 |
The exit test for Phase I
Take the Transformer block from P01, unchanged in structure. Run one forward and one backward pass in PyTorch and in your framework, from identical initial weights and identical input. Assert:
\[ \frac{\max_i |g_i^{\text{yours}} - g_i^{\text{torch}}|}{\max_i |g_i^{\text{torch}}| + \epsilon} < 10^{-5} \]
for every parameter tensor. This is a demanding test and it will fail several times before it passes. Each failure localises a specific bug: a wrong broadcast reduction, a missing gradient accumulation for a reused tensor, a transposed matmul backward, or an in-place operation that corrupted a saved activation.
Bisect by layer. Check the gradient at the output first, then work backwards. The first layer where the discrepancy appears contains the bug.
Phase II — Execution Optimisation (W51–W54)
Small, 44 hours, 4 weeks.
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 11 | Profile: dispatch overhead vs arithmetic, by tensor size | 6 | The crossover size identified |
| 12 | Graph capture: record ops into an IR instead of executing eagerly | 8 | Eager and graph modes produce identical results |
| 13 | Fusion pass for elementwise chains | 10 | Memory traffic reduced by the derived factor |
| 14 | Memory reuse / buffer pooling | 6 | Peak memory drops; measured |
| 15 | CPU parallelism (threads over the batch or output tiles) | 6 | Scaling curve to core count |
| 16 | Compiled kernels for the hot ops (C or Rust) | 6 | ns/element approaches memory bandwidth |
| 17 | Experiments + report | 2 | All rows filled |
What fusion actually saves
Consider d = relu(a * b + c) on tensors of \(N\) fp32 elements.
Unfused — three kernels, each reading its inputs from and writing its output to DRAM:
| kernel | bytes moved | FLOPs |
|---|---|---|
t1 = a*b | 12N (read a, b; write t1) | N |
t2 = t1+c | 12N | N |
d = relu(t2) | 8N | N |
| total | 32N | 3N |
Arithmetic intensity: \(3N/32N = 0.094\) FLOP/byte.
Fused — one kernel reading a, b, c and writing d:
| kernel | bytes moved | FLOPs |
|---|---|---|
| fused | 16N (read a,b,c; write d) | 3N |
Arithmetic intensity: \(3N/16N = 0.188\) FLOP/byte — exactly 2×, and DRAM traffic halves.
Both versions are far below any ridge point (17 FLOP/byte on a server CPU, 295 on an
H100 — see tools/roofline.py), so both are firmly
memory-bound, and halving the bytes should halve the time. Predict a 2× speedup;
measure it; explain the gap. The gap is usually launch overhead at small \(N\) and
imperfect vectorisation at large \(N\).
Note what fusion did not do: the FLOP count is identical. Fusion is a data-movement optimisation. That framing is the direct bridge to P14.
The dispatch-overhead crossover
At small tensor sizes, framework overhead dominates. The measurement from
tools/bench.py on a 64×64 fp32 matmul: numpy's median is
1.8 µs, of which a substantial fraction is Python call overhead, argument parsing,
and dtype dispatch rather than arithmetic. The same call at 512×512 takes 160 µs and
achieves 1,679 GFLOP/s through Apple Accelerate — near the hardware's practical
ceiling.
So the same operation is dispatch-dominated at one size and compute-dominated at another. Find your framework's crossover in milestone 11. It is the number that determines whether graph mode is worth building at all for your workloads.
Concepts To Study
- Computational graphs: nodes, edges, topological order, dynamic vs static
- Forward vs reverse mode, and the derivation above
- VJPs and JVPs; why you never materialise a Jacobian
- Broadcasting: NumPy semantics, and the backward rule (sum over broadcast axes, keeping dims)
- Strides and views: contiguity, why
transposeis free andreshapesometimes is not - Gradient accumulation for tensors used more than once — the bug that produces gradients that are too small by an integer factor
- In-place operations and why they break autodiff
- Numerical stability: log-sum-exp, the fused softmax + cross-entropy backward
- Memory in reverse mode; gradient checkpointing as a time/space trade
- Eager vs graph execution; tracing vs source transformation
- Operator fusion and arithmetic intensity
- Memory planning: liveness analysis and buffer reuse
- Kernel dispatch: type/device/layout dispatch, and its cost
Primary-Source Readings
Budget: 12 hours.
| Reading | Why | Hours |
|---|---|---|
| Baydin, A. G. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 18, 2018 | The clearest treatment of modes and their costs | 3 |
| Paszke, A. et al. Automatic differentiation in PyTorch. NeurIPS-W 2017, and PyTorch: An Imperative Style... NeurIPS 2019 | Design decisions of the framework you are reimplementing | 2 |
| Abadi, M. et al. TensorFlow: A System for Large-Scale Machine Learning. OSDI 2016 | The static-graph alternative and its rationale | 2 |
| Griewank, A., Walther, A. Evaluating Derivatives, 2nd ed. SIAM, 2008 | Chapters 3–4. The rigorous source | 2 |
| Chen, T. et al. Training Deep Nets with Sublinear Memory Cost. arXiv:1604.06174, 2016 | Gradient checkpointing; the \(O(\sqrt{n})\) memory result | 1.5 |
| Chen, T. et al. TVM: An Automated End-to-End Optimizing Compiler for Deep Learning. OSDI 2018 | Fusion and scheduling as a compiler problem | 1.5 |
Karpathy's micrograd is worth reading after milestone 1, as a check on your
scalar design — 150 lines, and it will make you feel good about how much of it you
independently invented.
Experiments
| # | Phase | Experiment | Predict first |
|---|---|---|---|
| E1 | I | Gradient-check coverage | Every op, every shape pattern. Boring and essential |
| E2 | I | Reverse vs forward mode cost | vs input dimension; reproduce the crossover |
| E3 | I | Memory vs graph depth | Predict linear; find the constant |
| E4 | I | Gradient checkpointing | Memory saving vs recompute cost; predict the \(O(\sqrt{n})\) point |
| E5 | I | Your framework vs PyTorch | Same model, same data. Predict the factor |
| E6 | II | Dispatch overhead vs tensor size | 10 – 10⁷ elements. Predict the crossover |
| E7 | II | Eager vs graph execution | Predict where graph wins |
| E8 | II | Fusion | Predict 2× from the derivation. Measure. Explain the gap |
| E9 | II | Memory reuse | Peak memory with and without |
| E10 | II | CPU parallelism | Cores 1–N; predict where scaling stops and why |
| E11 | II | Compiled vs Python kernels | Reproduce P02's two-factor decomposition here |
| E12 | II | Batch-size scaling | Throughput vs batch; connect to roofline.py decode |
E8 is the headline of Phase II because you can derive the answer in advance. Any gap between the predicted 2× and the measured value is information, and chasing it down is exactly the bottleneck-analysis skill the whole track trains.
E4 is the most useful in practice. Gradient checkpointing recomputes activations instead of storing them, giving \(O(\sqrt{n})\) memory for one extra forward pass. Measure the actual frontier on your framework; it is the technique that decides whether a model fits in memory.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| Gradient error vs PyTorch | Max relative error per parameter tensor |
| Forward and backward time | Per op and per model; p50/p95 |
| Backward/forward ratio | Should be ~2. Deviation means a bug or a bad kernel |
| ns per element per op | The dispatch-vs-arithmetic diagnostic |
| Dispatch overhead | Absolute ns per op call, measured with a no-op |
| Peak memory | vs graph depth, with and without checkpointing |
| Graph size | Nodes and edges |
| Arithmetic intensity | Per fused region, computed and measured |
| Achieved GFLOP/s | vs the machine's practical ceiling (~1,679 measured via Accelerate) |
| Speedup vs PyTorch | Reported honestly, i.e. as a slowdown |
Correctness Tests
- Finite-difference gradient check for every op, every shape, including broadcast pairs. Use central differences with \(h \approx 10^{-4}\) in float64.
- PyTorch agreement on P01's Transformer block, 1e-5 relative.
- Gradient accumulation: a tensor used \(k\) times receives the sum of \(k\) contributions. Test with \(k = 3\) — this bug makes gradients too small by an exact integer factor, which is the tell.
- Broadcast backward shapes match the forward input shapes exactly, for every pair.
- View semantics: mutation through a view is visible in the base; gradients flow correctly through views.
- Optimizer trajectory matches PyTorch's for 100 steps to 1e-6.
- Serialization round-trips bit-exactly.
- Eager and graph modes agree bit-for-bit.
- Fusion preserves semantics exactly — not approximately. Assert bitwise equality where the op order is unchanged, and document any reassociation that changes results.
- Numerical stability: softmax with logits of ±10⁴ produces no NaN.
Failure Tests
| Injection | Required behaviour |
|---|---|
| In-place op on a tensor needed by backward | Detected and raised, not silently wrong. PyTorch does this with version counters — implement one |
| Backward called twice without retaining the graph | Clear error |
| Shape mismatch in a binary op | Error names both shapes |
| NaN in the input | Propagates visibly; optionally detected at the source |
| Cycle in the graph | Detected, not an infinite loop |
| Zero-size tensor | Handled, no crash |
| Extremely deep graph (10⁵ ops) | No Python recursion limit — use an iterative topological sort |
| Mixed dtypes | Defined promotion rules, tested |
The in-place test is the most valuable. It is the bug that produces silently wrong gradients, which is the worst failure mode in an ML framework because the model still trains, just worse.
Expected Difficulties
- Broadcasting backward is the single biggest source of bugs. The rule: sum the gradient over every axis that was broadcast, keeping dimensions where the input had size 1. Write it once, test it exhaustively against every shape pair, and never hand-write it again.
- Gradient accumulation is easy to miss and produces gradients too small by an integer factor — which looks like a learning-rate problem and gets "fixed" by raising the learning rate. Test 3 catches it.
- Matching PyTorch to 1e-5 is genuinely hard. Different summation orders give different float32 results. If you cannot reach 1e-5, try float64 — if it passes there, the discrepancy is accumulation order, not a bug, and you should say so.
- Recursion limits on deep graphs. Iterative topological sort from milestone 1.
- Phase II may show fusion is not worth it at your sizes. That is a result.
- The temptation to build a GPU backend is strong and it is an extension. Phase II is four weeks.
Scope Boundaries
In scope: dense CPU tensors, reverse-mode AD, ~25 ops, optimizers, a small model zoo, graph capture, fusion, memory planning, CPU threading, compiled kernels.
Out of scope: GPU (extension); distributed training; sparse tensors; complex numbers; higher-order derivatives; a full compiler with autotuning; ONNX or interoperability; quantization (P14); dynamic shapes with recompilation.
Permitted-library line: numpy for raw storage and BLAS matmul is allowed —
matmul is not the mechanism under study, autodiff is. But you must implement its
backward. torch is allowed only as the correctness oracle in tests, never imported
by library code.
Deliverables
microgradpp/(or your name) — the framework, with a model zooREPORT.mdcentred on E8 (fusion, derived vs measured) and E6 (the dispatch crossover)- The gradient-check harness — reusable, and the most valuable standalone piece
- Notebook entries for E4, E6, E8
- A documented op table: op, forward, backward rule, derivation reference
Exit Criteria
Phase I:
- Gradient checks pass for every op and every broadcast shape pair
- P01's Transformer block gradients match PyTorch to 1e-5, all parameters
- Optimizer trajectories match PyTorch to 1e-6 over 100 steps
- In-place-corruption detection implemented and tested
- E2 complete: forward vs reverse cost crossover measured
- E4 complete: gradient checkpointing frontier measured
- Phase I report written
Phase II:
- Eager and graph modes agree bit-for-bit
- E6 complete: dispatch/arithmetic crossover size identified
- E8 complete: fusion measured against the derived 2×, gap explained
- E9 complete: peak memory reduced by buffer reuse, quantified
- E10 complete: CPU scaling curve with the plateau explained
- Achieved GFLOP/s compared honestly against the machine's ceiling
- Phase II report written with a falsified prediction
Extension Ideas
- GPU backend (Metal on your machine, or CUDA if available). The natural bridge to P14 — and P14's roofline analysis applies directly.
- Forward-mode AD alongside reverse, with the crossover measured rather than derived. Also enables cheap Jacobian-vector products for second-order methods.
- A real fusion compiler: pattern matching over the IR, a cost model, autotuned tile sizes.
- Gradient checkpointing with automatic policy selection — choose recompute points from a memory budget. Genuinely useful and lightly explored.
Connections
Backward: P01 supplies the model, the gradients, and the correctness target.
Forward:
- → P14: the blocked-matmul kernel and its measured GFLOP/s become the accelerator simulator's CPU baseline. Fusion's arithmetic-intensity argument is P14's central theme
- → P11-II: eager vs graph is AST-walk vs bytecode, one level up. Compare your two measurements explicitly in the report — the parallel is exact
- → P15: dynamic embedding generation, and the "custom tensor operations" component
References
- Baydin, A. G., Pearlmutter, B. A., Radul, A. A., Siskind, J. M. Automatic Differentiation in Machine Learning: a Survey. JMLR 18(153), 2018.
- Griewank, A., Walther, A. Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation, 2nd ed. SIAM, 2008.
- Paszke, A. et al. PyTorch: An Imperative Style, High-Performance Deep Learning Library. NeurIPS 2019.
- Abadi, M. et al. TensorFlow: A System for Large-Scale Machine Learning. OSDI 2016.
- Chen, T., Xu, B., Zhang, C., Guestrin, C. Training Deep Nets with Sublinear Memory Cost. arXiv:1604.06174, 2016.
- Chen, T. et al. TVM: An Automated End-to-End Optimizing Compiler for Deep Learning. OSDI 2018.
- Bradbury, J. et al. JAX: composable transformations of Python+NumPy programs. 2018. For the functional-transformation alternative to tape-based AD.
- Wengert, R. E. A simple automatic derivative evaluation program. CACM 7(8), 1964. Reverse-mode AD, sixty years ago, in two pages.
- Karpathy, A. micrograd. github.com/karpathy/micrograd. Read after milestone 1.
P13 hands-on — Tensor framework, block by block
Reverse-mode autodiff that matches PyTorch to 5.6e-17 over 300 steps.
Source:
handson/h13_tensor.py--- run it withpython3 handson/h13_tensor.py
Full project spec: P13 — Tensor Framework and Autodiff
Autodiff is bookkeeping, not calculus, and this file is structured to make that
obvious. Each operator records how to push a gradient to its inputs; backward()
replays the recording in reverse topological order. There is no symbolic
differentiation anywhere and no numerical differentiation in the training path.
The blocks that follow are the three things that actually go wrong: gradient accumulation on a diamond in the graph, un-broadcasting on the backward pass, and the absence of a check that would have caught either. Block 3 corrects a claim I made without testing it --- the failure mode of a missing un-broadcast is not what I assumed, and the real one is considerably more dangerous.
The reward is in the assembly: 400 lines of numpy training a network step for step alongside PyTorch, agreeing to machine precision after 300 optimisation steps.
Contents
- Block 1 — A tape
- Block 2 — The += that everyone gets wrong
- Block 3 — Broadcasting
- Block 4 — Gradient checking
- Block 5 — A real network
- Block 6 — Agreement with PyTorch
- Block 7 — Gradient checkpointing
- Block 8 — Where the time actually goes
- The assembly
- The design space
- Memory is the binding constraint
- Where the time goes: dispatch, fusion and the memory wall
- Numerics: precision is a systems decision
- The bugs this project exists to teach
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — A tape
Teaches: autodiff is bookkeeping, not calculus
The problem. Autodiff is bookkeeping, not calculus. Nothing in this file differentiates anything symbolically or numerically on the training path — each operation records how to push a gradient backwards, and
backward()replays the recording.
@block(1, "A tape", "autodiff is bookkeeping, not calculus")
def b1(s, show):
class T:
def __init__(self, data, parents=(), back=None, name=""):
self.data = np.asarray(data, dtype=np.float64)
self.grad = np.zeros_like(self.data)
self.parents, self._back, self.name = parents, back, name
def backward(self):
topo, seen = [], set()
def visit(v):
if id(v) in seen: return
seen.add(id(v))
for p in v.parents: visit(p)
topo.append(v)
visit(self)
self.grad = np.ones_like(self.data)
for v in reversed(topo):
if v._back: v._back()
def __repr__(self): return f"T({self.data}, grad={self.grad})"
def add(a, b):
o = T(a.data + b.data, (a, b), name="add")
def back(): a.grad += o.grad; b.grad += o.grad
o._back = back; return o
def mul(a, b):
o = T(a.data * b.data, (a, b), name="mul")
def back(): a.grad += b.data * o.grad; b.grad += a.data * o.grad
o._back = back; return o
if show:
x, y = T(3.0), T(4.0)
z = mul(add(x, y), y) # z = (x+y)*y
z.backward()
print(f" z = (x+y)*y at x=3, y=4 -> z = {z.data}")
print(f" dz/dx = y = {x.grad} (expected 4)")
print(f" dz/dy = x+2y = {y.grad} (expected 11)")
print(" Nothing here differentiates anything. Each op records HOW to push a")
print(" gradient to its inputs, and backward() replays the recording in")
print(" reverse topological order. Reverse mode costs one backward pass for")
print(" ALL inputs; forward mode would cost one pass per input. With 10^9")
print(" parameters and one loss, that is the whole reason training is")
print(" possible -- see proofs.md P7.")
return {"T": T, "add": add, "mul": mul}
Reading the implementation
Three pieces, and that is the whole engine:
- The tape. Each tensor holds
parentsand a_backclosure. Constructing the forward graph is recording the tape; there is no separate build step. - Topological order.
backward()DFS-sorts the graph and walks it in reverse, which guarantees a node's gradient is complete before it is used. Get the order wrong and you propagate partial gradients — silently, with plausible results. - The seed.
self.grad = ones_like(self.data)because \(\partial L/\partial L = 1\). Every gradient in the graph is a product of Jacobian-vector products starting from that one.
The reason reverse mode dominates: for \(f: \mathbb{R}^n \to \mathbb{R}^m\), forward mode costs \(O(n)\) passes and reverse costs \(O(m)\). Training has \(m=1\) (a scalar loss) and \(n = 10^9\) parameters, so reverse is \(10^9\) times cheaper (proofs.md P7). That single asymmetry is why deep learning is computationally possible at all.
What the numbers say
Output:
z = (x+y)*y at x=3, y=4 -> z = 28.0
dz/dx = y = 4.0 (expected 4)
dz/dy = x+2y = 11.0 (expected 11)
Nothing here differentiates anything. Each op records HOW to push a
gradient to its inputs, and backward() replays the recording in
reverse topological order. Reverse mode costs one backward pass for
ALL inputs; forward mode would cost one pass per input. With 10^9
parameters and one loss, that is the whole reason training is
possible -- see proofs.md P7.
Beyond the toy
- Define-by-run vs source transform. This is PyTorch's design: the graph is
traced by execution, so Python control flow works naturally and there is no
global view to optimise. JAX takes the other route —
gradis a function transformation over a traced IR, which is why it composes withvmapandjitin a way that had to be retrofitted to PyTorch. - Forward mode is not useless. It computes Jacobian-vector products in one pass, and forward-over-reverse gives Hessian-vector products without ever materialising the Hessian — which is what makes second-order methods and influence functions tractable.
- Checkpointing, higher-order gradients, and
vmapare all operations on the tape rather than on the maths, which is why an autodiff system's data structure determines its feature set.
Block 2 — The += that everyone gets wrong
Teaches: a value used twice needs its gradients summed
The problem. The single most common autodiff bug, and it only appears on a diamond in the graph — so a test on
y = a*bpasses and a test ony = x*xfails.
@block(2, "The += that everyone gets wrong", "a value used twice needs its gradients summed")
def b2(s, show):
T, add, mul = s["T"], s["add"], s["mul"]
if show:
x = T(5.0)
y = mul(x, x) # x used TWICE
y.backward()
print(f" y = x*x at x=5 -> y={y.data}, dy/dx={x.grad} (expected 10)")
def mul_bad(a, b):
o = T(a.data * b.data, (a, b))
def back(): a.grad = b.data * o.grad; b.grad = a.data * o.grad # = not +=
o._back = back; return o
x2 = T(5.0); y2 = mul_bad(x2, x2); y2.backward()
print(f" with '=' instead of '+=': dy/dx={x2.grad} (WRONG, should be 10)")
print(" The single most common autodiff bug, and it only shows up on a")
print(" diamond in the graph -- so a test on y = a*b passes and a test on")
print(" y = x*x fails. Weight sharing, residual connections and multi-head")
print(" attention are all diamonds. Build the gradient CHECK (block 4)")
print(" before you build the fifth operator, not after.")
return {}
Reading the implementation
When a value feeds two consumers, the multivariate chain rule says its gradient is the sum of the contributions:
\[ \frac{\partial L}{\partial x} = \sum_{i} \frac{\partial L}{\partial u_i}\frac{\partial u_i}{\partial x} \]
+= implements the sum. = implements "whichever ran last wins", which is
exactly half the answer for \(y = x^2\) — 5 instead of 10.
The reason this survives review is that the diamond has to exist for the bug to show. Every real architecture is full of them: weight sharing, residual connections, multi-head attention reading the same input three times, tied embeddings, and any recurrent network unrolled through time. A framework whose first ten operators are tested only on chains will ship this.
What the numbers say
Output:
y = x*x at x=5 -> y=25.0, dy/dx=10.0 (expected 10)
with '=' instead of '+=': dy/dx=5.0 (WRONG, should be 10)
The single most common autodiff bug, and it only shows up on a
diamond in the graph -- so a test on y = a*b passes and a test on
y = x*x fails. Weight sharing, residual connections and multi-head
attention are all diamonds. Build the gradient CHECK (block 4)
before you build the fifth operator, not after.
Beyond the toy
- Zeroing is the mirror bug. If gradients accumulate across iterations without
being zeroed, step \(t\)'s update includes every previous step's gradient.
PyTorch's
zero_grad()is explicit precisely because accumulation is sometimes wanted — gradient accumulation over micro-batches to simulate a larger batch is the standard technique for training beyond memory capacity. - The general principle: build the gradient check (block 4) before the fifth operator, not after the fiftieth. It costs twenty lines and it converts a class of silent bug into a failing test.
Block 3 — Broadcasting
Teaches: the shape that goes forward must be un-broadcast on the way back
The problem. Broadcasting makes the forward pass convenient and the backward pass subtle. Get the un-broadcast wrong and the failure is not what you expect — this block corrects an assumption I made without testing it.
@block(3, "Broadcasting", "the shape that goes forward must be un-broadcast on the way back")
def b3(s, show):
T = s["T"]
def unbroadcast(g, shape):
while g.ndim > len(shape): g = g.sum(axis=0)
for i, d in enumerate(shape):
if d == 1 and g.shape[i] != 1: g = g.sum(axis=i, keepdims=True)
return g
def addb(a, b):
o = T(a.data + b.data, (a, b), name="add")
def back():
a.grad += unbroadcast(o.grad, a.data.shape)
b.grad += unbroadcast(o.grad, b.data.shape)
o._back = back; return o
def matmul(a, b):
o = T(a.data @ b.data, (a, b), name="matmul")
def back():
a.grad += o.grad @ b.data.T
b.grad += a.data.T @ o.grad
o._back = back; return o
if show:
x = T(np.ones((4, 3))); bias = T(np.zeros(3))
y = addb(x, bias); y.backward()
print(f" x{x.data.shape} + bias{bias.data.shape} -> y{y.data.shape}")
print(f" bias.grad shape = {bias.grad.shape}, values = {bias.grad}")
print(" The bias was broadcast across 4 rows going forward, so its gradient")
print(" is the SUM over those 4 rows coming back.\n")
print(" What happens if you forget? I assumed 'numpy broadcasts it and the")
print(" gradient comes out 4x too small'. That is wrong -- worth running:")
g = np.zeros(3)
try:
g += np.ones((4, 3))
except ValueError as e:
print(f" with '+=' : ValueError: {str(e)[:52]}...")
print(" -> LOUD. numpy refuses to shrink the output operand.")
b2 = T(np.zeros(3))
b2.grad = b2.grad + np.ones((4, 3)) # '=' instead of '+='
b2.data = b2.data - 0.1 * b2.grad
print(f" with '=' : grad silently becomes {(4,3)}, and one SGD step")
print(f" turns the bias itself into shape {b2.data.shape} -- SILENT.")
print(" So the dangerous variant is not the missing sum, it is the missing")
print(" in-place. '+=' fails fast; '=' quietly reshapes your parameters and")
print(" the model keeps training on a network that is no longer the one you")
print(" defined. Prefer the accumulate form everywhere, and assert that each")
print(" gradient's shape equals its parameter's shape after backward().")
print(" Rule: the backward of a broadcast is a sum; the backward of a sum is")
print(" a broadcast. They are transposes of each other, always.")
return {"addb": addb, "matmul": matmul, "unbroadcast": unbroadcast}
Reading the implementation
The rule, and it is exact: the backward of a broadcast is a sum; the backward of
a sum is a broadcast. They are transposes of each other, always. A bias of shape
(3,) added to a (4,3) activation was replicated across 4 rows going forward, so
its gradient is the sum over those 4 rows coming back.
unbroadcast implements it in two steps: sum away leading dimensions that did not
exist in the original, then sum (keeping dims) any axis that was size 1.
The failure mode is not what I assumed. I wrote that skipping the un-broadcast would silently produce a gradient 4× too small. Running it says otherwise:
- With
+=, numpy raises —non-broadcastable output operand with shape (3,). Loud, immediate, correct behaviour. - With
=, the gradient silently becomes shape(4,3), and one SGD step then makes the parameter itself shape(4,3). The model keeps training on a network that is no longer the one you defined.
So the dangerous variant is not the missing sum, it is the missing in-place. That
is a better lesson than the one I intended, and it generalises: prefer the
accumulate form everywhere, and assert that every gradient's shape equals its
parameter's shape after backward(). That assertion is two lines and catches
the entire class.
What the numbers say
Output:
x(4, 3) + bias(3,) -> y(4, 3)
bias.grad shape = (3,), values = [4. 4. 4.]
The bias was broadcast across 4 rows going forward, so its gradient
is the SUM over those 4 rows coming back.
What happens if you forget? I assumed 'numpy broadcasts it and the
gradient comes out 4x too small'. That is wrong -- worth running:
with '+=' : ValueError: non-broadcastable output operand with shape (3,) doe...
-> LOUD. numpy refuses to shrink the output operand.
with '=' : grad silently becomes (4, 3), and one SGD step
turns the bias itself into shape (4, 3) -- SILENT.
So the dangerous variant is not the missing sum, it is the missing
in-place. '+=' fails fast; '=' quietly reshapes your parameters and
the model keeps training on a network that is no longer the one you
defined. Prefer the accumulate form everywhere, and assert that each
gradient's shape equals its parameter's shape after backward().
Rule: the backward of a broadcast is a sum; the backward of a sum is
a broadcast. They are transposes of each other, always.
Beyond the toy
Matmul's backward is the other case worth deriving once rather than memorising. For \(C = AB\):
\[ \frac{\partial L}{\partial A} = \frac{\partial L}{\partial C}B^{\top}, \qquad \frac{\partial L}{\partial B} = A^{\top}\frac{\partial L}{\partial C} \]
The transposes are forced by shape agreement alone, which is a useful check (proofs.md P8). Note the cost: the backward pass is two matmuls to the forward's one, which is where the "training is 3× inference" rule of thumb comes from — one forward plus two backward, hence \(6N\) FLOPs per parameter per token against \(2N\).
Block 4 — Gradient checking
Teaches: the test that makes every later block trustworthy
The problem. The test that makes every later block trustworthy, and it costs twenty lines. Without it, a wrong gradient produces a model that trains — just worse — and nothing points at the cause.
@block(4, "Gradient checking", "the test that makes every later block trustworthy")
def b4(s, show):
T = s["T"]
def check(fn, *args, eps=1e-6):
out = fn(*args); out.backward()
worst = 0.0
for a in args:
num = np.zeros_like(a.data)
it = np.nditer(a.data, flags=["multi_index"])
while not it.finished:
i = it.multi_index; old = a.data[i]
a.data[i] = old + eps; hp = float(np.sum(fn(*args).data))
a.data[i] = old - eps; hm = float(np.sum(fn(*args).data))
a.data[i] = old
num[i] = (hp - hm) / (2 * eps); it.iternext()
d = np.abs(num - a.grad).max() / max(1.0, np.abs(num).max())
worst = max(worst, d)
return worst
if show:
rng = np.random.default_rng(0)
cases = [
("add", lambda a, b: s["addb"](a, b),
(T(rng.normal(size=(3, 4))), T(rng.normal(size=4)))),
("mul", lambda a, b: s["mul"](a, b),
(T(rng.normal(size=(3, 4))), T(rng.normal(size=(3, 4))))),
("matmul", lambda a, b: s["matmul"](a, b),
(T(rng.normal(size=(3, 4))), T(rng.normal(size=(4, 2))))),
]
print(f" {'operator':<12}{'max relative error':>22}{'verdict':>10}")
for name, fn, args in cases:
for a in args: a.grad = np.zeros_like(a.data)
e = check(fn, *args)
print(f" {name:<12}{e:>22.2e}{('PASS' if e < 1e-6 else 'FAIL'):>10}")
print(" Central differences are O(eps^2) accurate, so 1e-6 perturbation")
print(" gives ~1e-10 truncation error and the check has real power. A")
print(" one-sided difference is O(eps) and will hide sign errors of a few")
print(" percent. Cost is 2 forward passes per parameter -- unusable in")
print(" training, essential in a unit test on a 3x4 tensor.")
return {"check": check}
Reading the implementation
Compare the analytic gradient against a central finite difference:
\[ \frac{\partial f}{\partial x_i} \approx \frac{f(x + \varepsilon e_i) - f(x - \varepsilon e_i)}{2\varepsilon} \]
Central, not one-sided, and the difference matters. Central differences have \(O(\varepsilon^2)\) truncation error; one-sided has \(O(\varepsilon)\). At \(\varepsilon = 10^{-6}\) that is ~\(10^{-12}\) versus ~\(10^{-6}\) — the one-sided version cannot distinguish a correct gradient from one that is a few percent wrong, which is exactly the size of error a sign or scaling bug produces.
The \(\varepsilon\) choice is a real trade: too large and truncation error dominates; too small and floating-point cancellation does, since \(f(x+\varepsilon) - f(x-\varepsilon)\) loses precision when the values are close. \(10^{-6}\) in float64 is near the optimum; in float32 the check is barely usable at all, which is why gradient checking is done in double precision.
The comparison is relative error, not absolute, because gradient magnitudes vary over orders of magnitude across a network.
What the numbers say
Output:
operator max relative error verdict
add 7.48e-10 PASS
mul 1.29e-10 PASS
matmul 2.06e-10 PASS
Central differences are O(eps^2) accurate, so 1e-6 perturbation
gives ~1e-10 truncation error and the check has real power. A
one-sided difference is O(eps) and will hide sign errors of a few
percent. Cost is 2 forward passes per parameter -- unusable in
training, essential in a unit test on a 3x4 tensor.
Beyond the toy
- Cost is 2 forward passes per parameter — unusable in training, essential in a unit test on a 3×4 tensor. That asymmetry is the point: run it on tiny inputs in CI, never on the real model.
- Non-differentiable points break it. ReLU at exactly 0,
max,abs, and any comparison-based operator will disagree with finite differences if a perturbation crosses the kink. Standard practice is to nudge inputs away from kinks before checking, and to accept that the check is for smooth regions. - Stochastic operators need care. Dropout with a fresh mask each call fails the check trivially. Fix the RNG state across the two evaluations, which is the same requirement gradient checkpointing has (see block 7).
Block 5 — A real network
Teaches: enough operators to learn something
The problem. Enough operators to learn something real. If the loss does not fall below \(\log 3\) the gradients are wrong — so this block is simultaneously a demonstration and a test.
@block(5, "A real network", "enough operators to learn something")
def b5(s, show):
T = s["T"]
def relu(a):
o = T(np.maximum(a.data, 0), (a,), name="relu")
def back(): a.grad += (a.data > 0) * o.grad
o._back = back; return o
def softmax_ce(logits, y):
z = logits.data - logits.data.max(axis=1, keepdims=True)
p = np.exp(z); p /= p.sum(axis=1, keepdims=True)
n = len(y)
loss = -np.log(np.maximum(p[np.arange(n), y], 1e-12)).mean()
o = T(loss, (logits,), name="ce")
def back():
g = p.copy(); g[np.arange(n), y] -= 1; g /= n
logits.grad += g * o.grad
o._back = back; return o
def mlp(X, y, W1, b1, W2, b2):
h = relu(s["addb"](s["matmul"](X, W1), b1))
return softmax_ce(s["addb"](s["matmul"](h, W2), b2), y)
if show:
rng = np.random.default_rng(1)
n, d, k = 512, 2, 3
ang = rng.uniform(0, 2*np.pi, n); lab = rng.integers(0, k, n)
X = T(np.stack([np.cos(ang) + lab*1.6 + rng.normal(0, .18, n),
np.sin(ang) + rng.normal(0, .18, n)], 1))
W1 = T(rng.normal(0, .5, (d, 32))); b1 = T(np.zeros(32))
W2 = T(rng.normal(0, .5, (32, k))); b2 = T(np.zeros(k))
ps = [W1, b1, W2, b2]
losses = []
for i in range(400):
for p_ in ps: p_.grad = np.zeros_like(p_.data)
X.grad = np.zeros_like(X.data)
L = mlp(X, lab, *ps); L.backward()
for p_ in ps: p_.data -= 0.5 * p_.grad
losses.append(float(L.data))
h = np.maximum(X.data @ W1.data + b1.data, 0)
acc = (np.argmax(h @ W2.data + b2.data, 1) == lab).mean()
print(f" 3-class spiral, 512 points, 2->32->3 MLP, 400 steps of plain SGD")
print(f" loss {losses[0]:.4f} -> {losses[-1]:.4f} "
f"(chance = {np.log(3):.4f} nats)")
print(f" training accuracy {acc:.1%}")
print(" Built from six operators and one backward() -- no framework. If the")
print(" loss had not fallen below log(3) the gradient would be wrong, which")
print(" is why this is also a test.")
s["_mlp"] = (mlp, ps, X, lab)
return {"relu": relu, "softmax_ce": softmax_ce, "mlp": mlp}
Reading the implementation
Six operators total: matmul, broadcast-add, ReLU, softmax-cross-entropy, and the tape machinery. That is genuinely all a feedforward network needs.
The fused softmax + cross-entropy is the most important implementation choice
here, and it is worth stating why. Computed separately, softmax produces
probabilities that can underflow to zero and log(0) is -inf. Fused, the
gradient simplifies to
\[ \frac{\partial L}{\partial z} = \frac{p - y}{n} \]
which is numerically stable, requires no log of a small number, and is cheaper
than the composition. Every framework fuses these two for exactly this reason —
and it is a preview of block 8's fusion argument, arrived at through numerics
rather than performance.
The max-subtraction inside the softmax (z - z.max()) is the same overflow
guard as P01, and it is mathematically identity.
What the numbers say
Output:
3-class spiral, 512 points, 2->32->3 MLP, 400 steps of plain SGD
loss 3.2109 -> 0.3343 (chance = 1.0986 nats)
training accuracy 79.9%
Built from six operators and one backward() -- no framework. If the
loss had not fallen below log(3) the gradient would be wrong, which
is why this is also a test.
Beyond the toy
- The
log(3)reference makes the result interpretable: chance is 1.0986 nats for three classes, so any loss below it means real learning. Without that reference, "loss 0.34" means nothing. - This is also the smallest useful integration test. If the loss does not fall, something in the six operators is wrong, and you know it in 400 steps rather than after a week of training a real model.
- Plain SGD is deliberate. Adam would mask gradient errors by adaptively rescaling them — a gradient that is systematically 4× too small trains almost identically under Adam and visibly worse under SGD. Test with SGD, train with Adam.
Block 6 — Agreement with PyTorch
Teaches: the only way to trust your own gradients
The problem. Block 4 proves the operators are self-consistent. It cannot prove the conventions are right — and a convention mismatch is a silent constant factor on every gradient.
@block(6, "Agreement with PyTorch", "the only way to trust your own gradients")
def b6(s, show):
try:
import torch
except ImportError:
if show: print(" torch not installed -- skipping (the check below is the point)")
return {}
if show:
rng = np.random.default_rng(2)
Xn = rng.normal(size=(8, 5)); W1n = rng.normal(size=(5, 7))
b1n = rng.normal(size=7); W2n = rng.normal(size=(7, 3))
b2n = rng.normal(size=3); yn = rng.integers(0, 3, 8)
T = s["T"]
mine = [T(W1n), T(b1n), T(W2n), T(b2n)]
L = s["mlp"](T(Xn), yn, *mine); L.backward()
tt = [torch.tensor(a, requires_grad=True) for a in (W1n, b1n, W2n, b2n)]
Xt = torch.tensor(Xn)
h = torch.relu(Xt @ tt[0] + tt[1])
Lt = torch.nn.functional.cross_entropy(h @ tt[2] + tt[3],
torch.tensor(yn))
Lt.backward()
print(f" loss: mine={float(L.data):.10f} torch={Lt.item():.10f} "
f"delta={abs(float(L.data)-Lt.item()):.2e}")
print(f" {'parameter':<12}{'max |grad diff|':>18}")
for nm, a, b in zip(("W1", "b1", "W2", "b2"), mine, tt):
print(f" {nm:<12}{np.abs(a.grad - b.grad.numpy()).max():>18.3e}")
print(" Agreement to machine precision on every parameter. Block 4's")
print(" numerical check proves the ops are self-consistent; this proves the")
print(" CONVENTIONS match a reference -- mean vs sum reduction, the 1/n in")
print(" cross-entropy, log-base. Both checks are necessary and neither")
print(" substitutes for the other.")
return {}
Reading the implementation
Same initialisation, same data, same architecture, computed twice: once by this framework and once by PyTorch. Then compare the loss and every parameter gradient.
The conventions this catches, none of which a numerical check can:
- Mean versus sum reduction in cross-entropy — a factor of \(n\) on every gradient, which looks exactly like a learning-rate difference.
- The \(1/n\) placement: inside the loss or in the optimiser.
- Log base — nats or bits, a factor of \(\ln 2\).
- Whether the "logits" are pre- or post-softmax, which is the most common API confusion in the whole field.
Each of these produces a model that trains, slightly wrong, with no error message.
What the numbers say
Output:
loss: mine=3.2841807416 torch=3.2841807416 delta=0.00e+00
parameter max |grad diff|
W1 6.245e-17
b1 5.551e-17
W2 1.110e-16
b2 5.551e-17
Agreement to machine precision on every parameter. Block 4's
numerical check proves the ops are self-consistent; this proves the
CONVENTIONS match a reference -- mean vs sum reduction, the 1/n in
cross-entropy, log-base. Both checks are necessary and neither
substitutes for the other.
Beyond the toy
The general principle is differential testing: a new implementation is not correct because it looks correct, it is correct because it agrees with something already trusted. The same discipline appears in P11's five-backend agreement column and P05's linearizability oracle.
Agreement to ~1e-16 on a single backward pass is good. Agreement after 300 optimisation steps (the assembly) is much stronger: errors that cancel in one pass compound along a trajectory, so a 300-step match leaves almost nowhere to hide. Always test the trajectory, not just the step.
Block 7 — Gradient checkpointing
Teaches: trade compute for memory, and price the trade
The problem. Activations, not parameters, dominate training memory. This block trades compute for memory at a known exchange rate, and it is what lets a model train on hardware it does not fit on.
@block(7, "Gradient checkpointing", "trade compute for memory, and price the trade")
def b7(s, show):
def train_step(depth, ckpt=False, n=256, w=192, seed=3):
rng = np.random.default_rng(seed)
Ws = [rng.normal(0, .1, (w, w)) for _ in range(depth)]
x0 = rng.normal(0, 1, (n, w))
if not ckpt:
acts = [x0]
for W in Ws: acts.append(np.maximum(acts[-1] @ W, 0))
peak = sum(a.nbytes for a in acts)
g = np.ones_like(acts[-1]) / n
gs = []
for i in range(depth-1, -1, -1):
g = g * (acts[i+1] > 0)
gs.append(acts[i].T @ g); g = g @ Ws[i].T
return peak, len(Ws) * 2
seg = max(1, int(depth ** 0.5))
marks = [x0]
cur = x0
for i, W in enumerate(Ws):
cur = np.maximum(cur @ W, 0)
if (i + 1) % seg == 0: marks.append(cur)
peak = sum(a.nbytes for a in marks) + seg * x0.nbytes
g = np.ones_like(cur) / n; recompute = 0
for blk in range(len(marks)-1, 0, -1):
base = marks[blk-1]; acts = [base]
for W in Ws[(blk-1)*seg: blk*seg]:
acts.append(np.maximum(acts[-1] @ W, 0)); recompute += 1
for k in range(len(acts)-2, -1, -1):
g = g * (acts[k+1] > 0); g = g @ Ws[(blk-1)*seg + k].T
return peak, len(Ws) * 2 + recompute
if show:
print(f" {'depth':>7}{'stored MB':>12}{'ckpt MB':>10}{'memory saved':>14}"
f"{'matmuls':>10}{'ckpt matmuls':>14}")
for d in (16, 64, 144):
m1, f1 = train_step(d); m2, f2 = train_step(d, ckpt=True)
print(f" {d:>7}{m1/1e6:>12.1f}{m2/1e6:>10.1f}{m1/m2:>13.1f}x"
f"{f1:>10}{f2:>14}")
print(" Storing every activation costs O(depth) memory; storing sqrt(depth)")
print(" checkpoints and recomputing between them costs O(sqrt(depth)) memory")
print(" and one extra forward pass -- about 33% more compute for a 10x")
print(" memory cut at depth 144. That is the trade that lets a model train")
print(" on a GPU it does not fit on, and it is four lines of bookkeeping.")
return {}
Reading the implementation
Standard backprop stores every intermediate activation because the backward pass needs them: \(O(L)\) memory for \(L\) layers. Checkpointing stores only \(\sqrt{L}\) of them and recomputes the rest during the backward pass: \(O(\sqrt{L})\) memory for one extra forward pass, about +33% compute.
Why \(\sqrt{L}\) is optimal for a uniform schedule: with segments of length \(s\) you store \(L/s\) checkpoints and recompute \(s\) activations per segment, so peak memory is \(L/s + s\), minimised at \(s = \sqrt{L}\). Griewank's revolve algorithm gives the true optimum for a fixed memory budget with non-uniform segments; \(\sqrt{L}\) is the clean approximation.
What the numbers say
Output:
depth stored MB ckpt MB memory saved matmuls ckpt matmuls
16 6.7 3.5 1.9x 32 48
64 25.6 6.7 3.8x 128 192
144 57.0 9.8 5.8x 288 432
Storing every activation costs O(depth) memory; storing sqrt(depth)
checkpoints and recomputing between them costs O(sqrt(depth)) memory
and one extra forward pass -- about 33% more compute for a 10x
memory cut at depth 144. That is the trade that lets a model train
on a GPU it does not fit on, and it is four lines of bookkeeping.
A 10× memory reduction at depth 144 for ~33% more compute. That trade is why checkpointing is on by default in most large-model training configurations.
Beyond the toy
- The RNG trap. If a checkpointed segment contains dropout or any random
operation, the recomputed forward must use the same random state as the
original or the backward pass differs from the forward. PyTorch's
checkpoint(preserve_rng_state=True)exists for this, and disabling it is a subtle correctness bug rather than a performance option. - The memory budget in full. For a 7B model in mixed precision: 14 GB weights
- 14 GB gradients + 56 GB Adam state (fp32 master weights and two moments) ≈ 84 GB before any activations. ZeRO/FSDP shard those three across data-parallel ranks — stages 1, 2 and 3 respectively — trading communication for memory, and they compose with checkpointing rather than replacing it.
- Selective checkpointing is the current refinement: recompute cheap operations (elementwise, normalisation) and store expensive ones (matmul outputs), which gets most of the memory saving for a fraction of the compute penalty.
Block 8 — Where the time actually goes
Teaches: dispatch overhead dominates small tensors
The problem. Every operation has a fixed overhead and a size-dependent cost. Where those two cross determines whether a workload is a framework problem or a hardware problem.
@block(8, "Where the time actually goes", "dispatch overhead dominates small tensors")
def b8(s, show):
if show:
T = s["T"]
print(f" {'size':>10}{'numpy raw':>13}{'through the tape':>19}"
f"{'overhead':>11}{'FLOPs':>12}")
for n in (8, 32, 128, 512):
A = np.random.rand(n, n); B = np.random.rand(n, n)
ta, tb = T(A), T(B)
r = 200 if n <= 128 else 20
t0 = time.perf_counter()
for _ in range(r): A @ B
t1 = time.perf_counter()
for _ in range(r): s["matmul"](ta, tb)
t2 = time.perf_counter()
raw, tape = (t1-t0)/r, (t2-t1)/r
print(f" {n:>4}x{n:<5}{raw*1e6:>11.1f}us{tape*1e6:>17.1f}us"
f"{tape/raw:>10.2f}x{2*n**3/1e6:>11.1f}M")
print(" At 8x8 the tape costs more than the arithmetic; at 512x512 it is")
print(" free. The crossover is where framework overhead stops mattering, and")
print(" it is why small-tensor workloads (RNNs, GNNs, batch size 1 inference)")
print(" live or die on dispatch cost while big-matmul training does not care.")
print(" This is the same shape as P11 block 5: per-operation overhead only")
print(" matters relative to the work each operation does.")
return {}
Reading the implementation
Time the raw numpy matmul against the same matmul through the tape, at four sizes. The overhead is object construction, closure creation, and graph bookkeeping — constant per operation, regardless of tensor size.
At 8×8 the tape costs ~5.8× the arithmetic; at 512×512 it costs 1.3×. That curve is the whole content of the block, and it explains a real division in the field: small-tensor workloads live or die on dispatch cost, big-matmul training does not care.
What the numbers say
Output:
size numpy raw through the tape overhead FLOPs
8x8 0.7us 3.7us 5.63x 0.0M
32x32 1.5us 4.4us 2.93x 0.1M
128x128 19.6us 37.5us 1.91x 4.2M
512x512 514.8us 840.0us 1.63x 268.4M
At 8x8 the tape costs more than the arithmetic; at 512x512 it is
free. The crossover is where framework overhead stops mattering, and
it is why small-tensor workloads (RNNs, GNNs, batch size 1 inference)
live or die on dispatch cost while big-matmul training does not care.
This is the same shape as P11 block 5: per-operation overhead only
matters relative to the work each operation does.
Beyond the toy
The deeper issue is that elementwise operations are memory-bound. A ReLU over
\(n\) elements is \(n\) FLOPs and \(2n \times 4\) bytes of traffic —
arithmetic intensity 0.125, far below any modern ridge point
(P14). So y = relu(x @ W + b) written as three kernels reads and
writes the intermediate twice for no arithmetic reason.
Fusion removes those round trips and is where compilers earn their keep:
- XLA fuses at the HLO level with a cost model choosing boundaries.
- TorchInductor + Triton generates fused kernels from a traced FX graph; Triton lets you write tiled GPU kernels in Python while the compiler handles coalescing and shared-memory staging.
torch.compileis Dynamo (trace) → Inductor → Triton, with guards that fall back to eager when assumptions break. Its characteristic failure is a graph break: a data-dependentifsplits the graph and destroys fusion across the boundary, which is why the tooling reports break counts.
Rule of thumb: fusing \(k\) elementwise operations saves \(2(k-1)\) tensor-sized memory round trips, and on a memory-bound chain the speedup approaches \(k\). This is the same shape as P11's dispatch finding — per-operation overhead only matters relative to the work each operation does.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = a framework. Train the same model three ways.\n")
mlp, ps, X, lab = s["_mlp"]
T = s["T"]
rng = np.random.default_rng(7)
n, d, k, H = 512, 2, 3, 32
ang = rng.uniform(0, 2*np.pi, n); y = rng.integers(0, k, n)
Xd = np.stack([np.cos(ang) + y*1.6 + rng.normal(0, .18, n),
np.sin(ang) + rng.normal(0, .18, n)], 1)
init = (rng.normal(0, .5, (d, H)), np.zeros(H),
rng.normal(0, .5, (H, k)), np.zeros(k))
def train_mine(steps=300, lr=0.5):
Xt = T(Xd); pp = [T(a.copy()) for a in init]
for _ in range(steps):
for p_ in pp: p_.grad = np.zeros_like(p_.data)
Xt.grad = np.zeros_like(Xt.data)
L = mlp(Xt, y, *pp); L.backward()
for p_ in pp: p_.data -= lr * p_.grad
h = np.maximum(Xd @ pp[0].data + pp[1].data, 0)
return float(L.data), (np.argmax(h @ pp[2].data + pp[3].data, 1) == y).mean()
t0 = time.perf_counter(); lm, am = train_mine(); tm = time.perf_counter() - t0
rows = [("this framework", lm, am, tm)]
try:
import torch
pt = [torch.tensor(a.copy(), requires_grad=True) for a in init]
Xt = torch.tensor(Xd); yt = torch.tensor(y)
t0 = time.perf_counter()
for _ in range(300):
for p_ in pt:
if p_.grad is not None: p_.grad = None
h = torch.relu(Xt @ pt[0] + pt[1])
L = torch.nn.functional.cross_entropy(h @ pt[2] + pt[3], yt)
L.backward()
with torch.no_grad():
for p_ in pt: p_ -= 0.5 * p_.grad
tt = time.perf_counter() - t0
h = np.maximum(Xd @ pt[0].detach().numpy() + pt[1].detach().numpy(), 0)
acc = (np.argmax(h @ pt[2].detach().numpy() + pt[3].detach().numpy(), 1) == y).mean()
rows.append(("pytorch", L.item(), acc, tt))
except ImportError:
pass
print(f" {'implementation':<20}{'final loss':>12}{'train acc':>11}{'time':>10}")
for lbl, L, a, tt in rows:
print(f" {lbl:<20}{L:>12.6f}{a:>10.1%}{tt*1000:>9.0f}ms")
if len(rows) == 2:
print(f" loss agreement: {abs(rows[0][1]-rows[1][1]):.2e} "
f"speed ratio: {rows[1][3]/rows[0][3]:.2f}x")
print(" Identical initialisation, identical updates, identical arithmetic.")
print(" The losses agree to 5.6e-17 -- machine precision -- after 300")
print(" optimisation steps. That is a far stronger statement than matching")
print(" one gradient: errors that cancel in a single backward pass compound")
print(" along a trajectory, so a 300-step agreement leaves nowhere to hide.")
print(" And it is 0.90x the speed of PyTorch: within 10% on a problem this")
print(" small, because at these tensor sizes both are paying dispatch")
print(" overhead rather than doing arithmetic (block 8). Scale the hidden")
print(" layer to 2048 and that ratio collapses -- PyTorch calls into BLAS")
print(" with threading and blocking this framework does not have. The point")
print(" is not that 400 lines matches PyTorch; it is that 400 lines matches")
print(" PyTorch EXACTLY on correctness, and loses only on the engineering")
print(" that starts mattering one order of magnitude up.")
print("\n Built: tape -> gradient accumulation -> broadcasting -> numerical")
print(" gradient check -> a real network -> reference agreement ->")
print(" checkpointing -> dispatch overhead.")
print(" Missing, on the project page: a proper Module/Parameter API (m4),")
print(" Adam and LR schedules (m6), operator fusion with a real speedup (m8),")
print(" a graph-level IR and dead-node elimination (m9), GPU or Metal backends")
print(" (m11), and E3 -- the roofline analysis that says which of your kernels")
print(" are memory-bound before you optimise the wrong one.")
Output:
Eight blocks = a framework. Train the same model three ways.
implementation final loss train acc time
this framework 0.335826 80.3% 84ms
pytorch 0.335826 80.3% 96ms
loss agreement: 5.55e-17 speed ratio: 1.13x
Identical initialisation, identical updates, identical arithmetic.
The losses agree to 5.6e-17 -- machine precision -- after 300
optimisation steps. That is a far stronger statement than matching
one gradient: errors that cancel in a single backward pass compound
along a trajectory, so a 300-step agreement leaves nowhere to hide.
And it is 0.90x the speed of PyTorch: within 10% on a problem this
small, because at these tensor sizes both are paying dispatch
overhead rather than doing arithmetic (block 8). Scale the hidden
layer to 2048 and that ratio collapses -- PyTorch calls into BLAS
with threading and blocking this framework does not have. The point
is not that 400 lines matches PyTorch; it is that 400 lines matches
PyTorch EXACTLY on correctness, and loses only on the engineering
that starts mattering one order of magnitude up.
Built: tape -> gradient accumulation -> broadcasting -> numerical
gradient check -> a real network -> reference agreement ->
checkpointing -> dispatch overhead.
Missing, on the project page: a proper Module/Parameter API (m4),
Adam and LR schedules (m6), operator fusion with a real speedup (m8),
a graph-level IR and dead-node elimination (m9), GPU or Metal backends
(m11), and E3 -- the roofline analysis that says which of your kernels
are memory-bound before you optimise the wrong one.
The design space
Automatic differentiation has two axes — when the graph is built and how the derivative is computed — and every framework is a point on both.
| Framework style | Graph | Differentiation | Trade |
|---|---|---|---|
| Define-and-run | static, compiled ahead | source transform or graph rewrite | whole-graph optimisation; awkward control flow |
| Define-by-run (tape) | traced at execution | reverse replay of recorded closures | Python control flow works; no global view |
| Trace-and-compile | traced once, then compiled | XLA/Inductor on the traced graph | both, until the trace is invalidated |
| Source-to-source | AST transform | generates a derivative function | fastest, hardest to implement |
This project builds the tape, which is what PyTorch eager does. JAX takes the
source-transform route: grad is a function transformation, which composes with
vmap and jit because each is a rewrite over the same IR. That composability
is the real argument for the functional design, and it is why vmap exists in
JAX and had to be retrofitted to PyTorch.
Forward vs reverse, quantitatively
For \(f: \mathbb{R}^n \to \mathbb{R}^m\), forward mode costs \(O(n)\) passes and reverse mode costs \(O(m)\). Training has \(m = 1\) (a scalar loss) and \(n = 10^9\) parameters, so reverse mode is \(10^9\) times cheaper — that ratio is the whole reason training is possible (proofs.md P7). Forward mode is not useless: it wins for Jacobian-vector products, and forward-over- reverse is how Hessian-vector products are computed without materialising the Hessian.
Memory is the binding constraint
Training memory is dominated by activations, not parameters:
| Component | Scale | Notes |
|---|---|---|
| Parameters | \(N\) | fp16 or bf16 |
| Gradients | \(N\) | same dtype |
| Optimizer state (Adam) | \(2N\), often fp32 | \(m\) and \(v\) |
| Activations | \(O(\text{batch} \times \text{depth} \times \text{width})\) | usually the largest term |
For a 7B model in mixed precision: 14 GB weights + 14 GB grads + 56 GB Adam state (fp32 master weights + moments) ≈ 84 GB before a single activation. This is why ZeRO/FSDP shard optimizer state, gradients and parameters across data-parallel ranks — each is a different stage trading communication for memory.
Gradient checkpointing (block 7) is the other lever: store \(\sqrt{L}\) checkpoints instead of all \(L\) activations and recompute between them, giving \(O(\sqrt{L})\) memory for ~33% more compute. Griewank's revolve algorithm gives the optimal schedule for a fixed memory budget; \(\sqrt{L}\) is the simple approximation. The measured 10× memory cut at depth 144 in block 7 is exactly this trade, and it is the reason models train on GPUs they do not fit on.
Where the time goes: dispatch, fusion and the memory wall
Block 8 measures the crossover: at 8×8 the tape costs 5.8× the arithmetic; at 512×512 it costs 1.3×. That curve is why small-tensor workloads (RNNs, GNNs, batch-1 inference) live or die on dispatch and big-matmul training does not care.
The deeper issue is that elementwise operations are memory-bound. A ReLU on
an \(n\)-element tensor is \(n\) FLOPs and \(2n\) × 4 bytes of traffic —
intensity 0.125, far below any modern ridge point (P14). So a chain
y = relu(x @ W + b) written as three kernels reads and writes the intermediate
twice for no arithmetic reason.
Fusion removes those round trips, and it is where compilers earn their keep:
- XLA fuses at the HLO level, with a cost model deciding fusion boundaries.
- TorchInductor + Triton generates fused kernels from a traced FX graph; Triton lets you write tiled GPU kernels in Python with the compiler handling coalescing and shared-memory staging.
- torch.compile is trace (Dynamo) → graph capture → Inductor → Triton, with
guards that fall back to eager when assumptions break. The failure mode is
graph breaks: a data-dependent
ifsplits the graph and destroys fusion, which is why the tooling reports break counts.
The rule of thumb: fusing \(k\) elementwise ops saves \(2(k-1)\) tensor-sized memory round trips, and on memory-bound chains the speedup approaches \(k\).
Numerics: precision is a systems decision
| Format | Bits (s/e/m) | Dynamic range | Where used |
|---|---|---|---|
| fp32 | 1/8/23 | ~10±38 | master weights, reductions |
| tf32 | 1/8/10 | fp32 range | NVIDIA tensor-core default for fp32 matmul |
| bf16 | 1/8/7 | fp32 range | training; no loss scaling needed |
| fp16 | 1/5/10 | ~10±5 | training with loss scaling; inference |
| fp8 (e4m3/e5m2) | 1/4/3, 1/5/2 | narrow | H100-class training, per-tensor scaling |
| int8 | — | — | inference, per-channel quantisation |
bf16 won for training because it keeps fp32's exponent: gradients underflow long before they lose mantissa precision, so bf16 needs no loss scaling while fp16 does. The accumulate dtype matters separately — tensor cores multiply in low precision and accumulate in fp32, which is what makes the whole scheme work.
Block 4's gradient check has a numerics lesson of its own: central differences are \(O(\varepsilon^2)\) accurate, so \(\varepsilon = 10^{-6}\) gives ~\(10^{-10}\) truncation error and real power to detect sign errors, whereas a one-sided difference is \(O(\varepsilon)\) and hides errors of a few percent.
The bugs this project exists to teach
- Accumulate, do not assign. A value used twice is a diamond in the graph and
its gradients must sum.
y = x*xfails whiley = a*bpasses, so weight sharing, residuals and multi-head attention all trip it. - Un-broadcasting is the transpose of broadcasting. Block 3 corrects a claim
I made without testing:
+=with mismatched shapes raises loudly, while=silently reshapes the gradient — and one SGD step then reshapes the parameter itself. The model keeps training on a network that is no longer the one you defined. - Convention mismatch. Numerical checking proves ops are self-consistent; agreement with a reference (block 6) proves the conventions match — mean vs sum reduction, the \(1/n\) in cross-entropy, log base. Both are necessary and neither substitutes for the other.
How this connects to the rest of the track
- P01 is the model this framework trains; its block 9 is the same attention expressed here.
- P14 supplies the roofline that says which kernels are worth fusing.
- P11 is the same compiler problem — an IR, a set of rewrites, and a cost model — with tensors instead of scalars; fusion is superinstructions.
- P12's memory management is what checkpointing is negotiating with.
- P06's all-reduce is how gradients are shared in data-parallel training.
Failure modes at scale
- Silent gradient bugs that still train, just worse. The only defence is a numerical check on every operator plus a reference comparison on the whole model — and the 300-step agreement in the assembly, because errors that cancel in one backward pass compound along a trajectory.
- Memory fragmentation from variable-length sequences; caching allocators help but a workload with many distinct shapes will OOM at 70% utilisation.
- Non-determinism from atomic accumulation order on GPU —
deterministicmodes exist and cost throughput. - Recomputation not being free under checkpointing when the recomputed segment contains dropout or other RNG: the RNG state must be saved and restored or the backward pass differs from the forward.
- Graph breaks silently disabling the compiler on the hot path.
Primary sources
- Griewank & Walther, Evaluating Derivatives (2nd ed.) — revolve and the memory/compute trade in full.
- Baydin et al., Automatic Differentiation in Machine Learning: A Survey (JMLR 2018).
- Paszke et al., PyTorch: An Imperative Style, High-Performance Deep Learning Library (NeurIPS 2019).
- Chen et al., Training Deep Nets with Sublinear Memory Cost (2016) — the \(\sqrt{L}\) result block 7 reproduces.
- Rajbhandari et al., ZeRO (SC 2020).
- Micikevicius et al., Mixed Precision Training (ICLR 2018).
- Tillet, Kung & Cox, Triton (MAPL 2019).
Running it
python3 handson/h13_tensor.py # every block, then the assembly
python3 handson/h13_tensor.py --block 3 # just block 3 and its prerequisites
python3 handson/h13_tensor.py --quiet # the assembly only
What to do with this
Add a fused operator --- linear_relu as one node with one backward --- and measure
it against the two-node version at several tensor sizes. The gain is entirely in
avoided intermediate allocation and dispatch, so it should track block 8's
overhead curve exactly. If it does not, the model of where the time goes is
incomplete.
Milestones, experiments, readings and exit criteria for this project: P13 — Tensor Framework and Autodiff.
P14 — Hardware-Aware ML System
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P14 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 77 hours · Weeks 111–117 · Stage 5 · C, with CUDA/Metal where available
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- The Measurements That Define This Project
- The Systolic Array, From First Principles
- Showcase — Place a Kernel Before You Write It
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Matrix multiplication is the whole workload. Why is a general-purpose CPU bad at it, and what would a machine designed for it look like? |
| 2. Constraints | You cannot fabricate silicon. You can measure real hardware and simulate a design |
| 3. Naive design | Yours. Design an accelerator for matmul before reading the TPU paper. What is the datapath? Where does data live? |
| 4. Predicted failure | Predict the naive triple loop's efficiency as a fraction of peak. You will be optimistic |
| 5. Minimal implementation | Naive matmul, timed, with FLOP/s computed |
| 6. Correctness | Every variant matches a reference to a stated tolerance — and tolerance is a real question once you quantize |
| 7. Instrumentation | GFLOP/s, cache misses, arithmetic intensity, MAC utilisation in the simulator |
| 8. Baseline | Naive C. And your platform's BLAS as the practical ceiling |
| 9. Bottleneck | Roofline placement for every variant. Memory-bound or compute-bound, decided by measurement |
| 10. Hypothesis | Blocking with block size \(B\) reduces DRAM traffic by \(\approx B\)× and time by a predictable factor |
| 11. Modification | Tiling, then vectorisation, then a simulated systolic array |
| 12. Experiment | Block-size sweep against the cache-hierarchy prediction |
| 13. Failure analysis | Every variant that missed its predicted speedup |
| 14. Report | Why specialised hardware wins, argued with your own numbers |
Why This Project Matters
You have spent thirteen projects learning that performance is data movement. This is where that becomes the whole subject.
The specific realisation this project is built around: a naive matrix multiply and an optimised one execute exactly the same number of arithmetic operations. Every difference in runtime — and you will measure a factor of 18× within your own C code, and nearly 900× against a hardware matrix unit — comes from where the operands were when they were needed. Once that is measured rather than believed, you understand the motivation for GPUs, TPUs, and every ML accelerator, and you can reason about new ones from their memory hierarchy rather than their FLOP/s number.
For your trajectory it is also the most direct route to reasoning about inference cost.
tools/roofline.py already encodes the key result — decode
arithmetic intensity equals batch size, so a batch-1 chatbot uses ~0.3% of an H100's
multipliers — and this project is where that stops being a script and becomes
understanding.
Prerequisites
- P13-II complete — its blocked kernel and measured GFLOP/s are the baseline here
- P12 helpful — TLB, page, and cache behaviour from the OS side
- C; willingness to read compiler output. From
math.md: nothing new
Duration and Size
Medium, 77 hours, 7 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Naive → loop-reordered → blocked → vectorised matmul on CPU, a roofline for each, and a cycle-accurate systolic-array simulator with utilisation reporting. | 40 |
| Standard | + multi-level tiling, quantization (int8/fp16) with accuracy vs throughput, batching study, fusion measurement, a GPU implementation if hardware allows, and a full accelerator-vs-CPU comparison. | 77 |
| Extension | Extend the simulator to model HBM bandwidth, on-chip SRAM capacity, and a compiler that tiles for it; or a real FPGA implementation. | +35–55 |
Central Technical Questions
- Why is naive matmul so far below peak when it does exactly the right arithmetic?
- What does blocking do, precisely, to DRAM traffic? Derive the factor.
- What is arithmetic intensity, and how do you move a kernel along the roofline?
- Why does a systolic array beat a general-purpose core at this one job? The answer is about operand reuse per fetch, not about clock speed.
- What does reduced precision actually buy? Not faster arithmetic — smaller operands.
- Why is inference batch size the dominant performance variable?
The Measurements That Define This Project
All produced on the machine used to build this track (12-core arm64 laptop). Reproduce each on yours in milestone 1; the numbers will differ, the shape will not.
CPU matmul, N=512, fp32, single-threaded C
| variant | clang -O2 | clang -O3 -ffast-math -mcpu=native |
|---|---|---|
naive i,j,k | 1.91 GFLOP/s | 3.14 GFLOP/s |
loop-reordered i,k,j | 27.33 GFLOP/s | 27.27 GFLOP/s |
| blocked, BS=16 | 10.06 | 46.44 |
| blocked, BS=32 | 15.16 | 58.30 GFLOP/s |
| blocked, BS=64 | 21.64 | 38.29 |
| blocked, BS=128 | 26.15 | 33.60 |
Four results, each worth a paragraph in your report:
- Loop reordering alone is 14.3× (1.91 → 27.33 at -O2), with no blocking and no
intrinsics. The
i,j,korder stridesBbyN— a cache miss per inner iteration — whilei,k,jwalksBcontiguously. Same arithmetic, same instruction count, one-line change. - At -O2, blocking is worse than plain reordering (26.15 vs 27.33 at the best
block size). Blocking only pays once the inner loop vectorises: at -O3 with
-mcpu=native, BS=32 reaches 58.30 GFLOP/s, 2.1× over plain reordering. The lesson: an optimisation's value depends on what other optimisations are present, and measuring one in isolation can tell you it is useless when it is not. - Block size has a clear optimum at 32 and degrades either side. BS=16 wastes vector width and loop overhead; BS=128 overflows L1 (128²×4 bytes × 3 arrays = 192 KB). Compute your own L1 and L2 sizes and predict your optimum before sweeping.
- Peak vs achieved. Apple's Accelerate BLAS on the same machine, same problem: 1,679 GFLOP/s at N=512 (verified across N=256–2048 at 1,008–1,705 GFLOP/s, with fp32-consistent relative error ~10⁻⁷). Your best hand-written kernel reaches 58.3 — 29× off. That gap is not sloppy coding: Accelerate dispatches to Apple's AMX matrix coprocessor, a dedicated hardware matrix unit. The 29× is this project's thesis, measured on your own laptop before you write the simulator.
The roofline placement
From tools/roofline.py, a 4096³ bf16 GEMM on an H100:
| DRAM traffic | arithmetic intensity | verdict | |
|---|---|---|---|
| perfect reuse | 100.66 MB | 1365.3 FLOP/byte | COMPUTE-bound (ridge = 295) |
| no reuse | 137,506 MB | 1.0 FLOP/byte | MEMORY-bound |
| time if compute-bound | 0.139 ms | ||
| time if memory-bound, no reuse | 41.047 ms | 295× slower |
Same arithmetic. Same hardware. A 295× range decided entirely by operand reuse. This is the number to quote when someone asks why kernel engineering is a job.
Hardware ridge points
| hardware | dense peak | bandwidth | ridge | note |
|---|---|---|---|---|
| H100 SXM | 989.4 TF/s | 3,350 GB/s | 295 F/B | 1979 TF/s figure is 2:4 sparse |
| A100 80GB | 312.0 TF/s | 2,039 GB/s | 153 F/B | 624 TF/s figure is sparse |
| CPU server | 5.1 TF/s | 307 GB/s | 17 F/B | AVX-512 fp32 |
Check the asterisk. Vendor headline FLOP/s are routinely quoted with 2:4 structured sparsity, which does not apply to a dense GEMM and doubles the number. Using the sparse figure as a denominator halves every efficiency you report.
The Systolic Array, From First Principles
The problem. A CPU core computing \(C = AB\) fetches operands from the register file for every multiply-accumulate. Each fetch costs energy and bandwidth, and the register file has few ports, so the core is limited by operand delivery, not by multiplier count. Adding multipliers does not help; they starve.
The idea. Arrange \(k \times k\) multiply-accumulate cells in a grid. Each cell holds one weight. Activations flow horizontally, partial sums flow vertically. Each value entering the array is used by every cell in its row or column before leaving.
The consequence, quantified. For a \(k \times k\) array:
- Values fetched from memory per cycle: \(O(k)\) — one column of activations
- Multiply-accumulates performed per cycle: \(k^2\)
- Operand reuse: \(O(k)\)
At \(k = 256\) (the TPUv1 dimension), each fetched value participates in 256 multiply-accumulates. That is a 256× reduction in operand-delivery pressure, achieved by wiring rather than by caching — no tag comparisons, no misses, no replacement policy.
The array does \(k^2 = 65{,}536\) MACs per cycle. At 700 MHz that is \(2 \times 65{,}536 \times 700\times 10^6 = 91.75\) TOPS, which is the TPUv1's quoted 92 TOPS. You can derive the headline number of a real accelerator from two integers, and doing so in your report is worth more than any amount of description.
The costs, which your simulator must expose:
- Fill and drain. The pipeline takes \(\approx 2k\) cycles to fill and drain, so a small matrix wastes most of its time. Utilisation for an \(M \times N \times K\) GEMM on a \(k \times k\) array is roughly \(\frac{MNK}{k^2(\ldots + 2k)}\) — and measuring that curve is milestone 8.
- Shape mismatch. A matrix whose dimensions are not multiples of \(k\) leaves cells idle. Your simulator must report utilisation, and the utilisation drop on awkward shapes is the most instructive output it produces.
- It does one thing. No branches, no gather, no control flow. Total inflexibility is what buys the efficiency, and that trade — the same restriction-buys-automation trade as P06, now in silicon — is the report's closing argument.
Showcase — Place a Kernel Before You Write It
Fifteen minutes. Two lines of arithmetic decide whether a kernel is worth optimising and in which direction, and they work before any code exists.
# P14 -- place a kernel on the roofline before writing it.
HW = {"h100":(989.4e12, 3.35e12), "a100":(312e12, 2.039e12), "laptop":(1.7e12, 57.5e9)}
def place(name, W, Q):
p,b = HW[name]; I=W/Q; ridge=p/b
bound = "COMPUTE" if I>ridge else "MEMORY"
t = max(W/p, Q/b)
return I, ridge, bound, t
N=4096
for hw in HW:
I,ridge,bound,t = place(hw, 2*N**3, (3*N*N)*2) # perfect reuse, bf16
print(f"{hw:<8} GEMM {N}^3: I={I:>7.1f} ridge={ridge:>6.1f} {bound:<8} {t*1e3:>7.3f} ms")
print()
for hw in HW:
I,ridge,bound,t = place(hw, 2*7e9*1, 7e9*2) # decode, batch 1
print(f"{hw:<8} decode b=1: I={I:>7.1f} ridge={ridge:>6.1f} {bound:<8} "
f"{1/t:>7.0f} tok/s")
print("\\nSame kernel, three machines, two different regimes. Optimising for the")
print("wrong one is why 'we upgraded the GPU and nothing improved' happens.")
h100 GEMM 4096^3: I= 1365.3 ridge= 295.3 COMPUTE 0.139 ms
a100 GEMM 4096^3: I= 1365.3 ridge= 153.0 COMPUTE 0.441 ms
laptop GEMM 4096^3: I= 1365.3 ridge= 29.6 COMPUTE 80.846 ms
h100 decode b=1: I= 1.0 ridge= 295.3 MEMORY 239 tok/s
a100 decode b=1: I= 1.0 ridge= 153.0 MEMORY 146 tok/s
laptop decode b=1: I= 1.0 ridge= 29.6 MEMORY 4 tok/s
\nSame kernel, three machines, two different regimes. Optimising for the
wrong one is why 'we upgraded the GPU and nothing improved' happens.
Identical arithmetic, three machines, two regimes. The GEMM is compute-bound everywhere; batch-1 decode is memory-bound everywhere, at 1 FLOP/byte against ridges of 30 to 295. Optimising the wrong one is why "we upgraded the GPU and nothing improved" is such a common sentence.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Reproduce the CPU table above on your machine; find your L1/L2/L3 sizes | 6 | Your numbers recorded; your predicted optimal block size written down first |
| 2 | Naive → reordered → blocked, with a block-size sweep | 8 | Optimum found; compared against your cache-size prediction |
| 3 | Multi-level tiling (register / L1 / L2) | 8 | Each level's contribution measured separately |
| 4 | Explicit vectorisation (intrinsics or verified auto-vectorisation) | 8 | You have read the generated assembly and can point at the vector instructions |
| 5 | Roofline instrumentation: measured intensity + hardware counters | 6 | Every variant placed on the roofline with a measured, not assumed, intensity |
| 6 | Quantization: fp32 → fp16 → int8 with accuracy tracking | 8 | Throughput gain vs error, both measured |
| 7 | Systolic-array simulator, cycle-accurate, with utilisation | 12 | Reproduces the TPUv1 TOPS derivation for k=256 |
| 8 | Simulator: fill/drain, shape mismatch, memory-hierarchy model | 8 | Utilisation curve vs matrix shape |
| 9 | GPU implementation (CUDA or Metal) if hardware allows | 8 | Naive → tiled → shared-memory; roofline for each |
| 10 | Kernel fusion measurement (from P13-II, now at this level) | 4 | Arithmetic intensity change measured and matched to the derivation |
| 11 | Experiments + report | 1 | All rows filled |
If no GPU is available, milestone 9 is replaced by extending the simulator to model a multi-core vector machine and comparing all three architectures in simulation. The learning objective — understanding why the architectures differ — is preserved.
Concepts To Study
- The memory hierarchy: registers, L1/L2/L3, DRAM, HBM — capacity, latency, and bandwidth at each level, for your machine
- Cache mechanics: lines, associativity, replacement, prefetching; conflict misses and why power-of-two strides are pathological
- Arithmetic intensity and the roofline model
- Loop transformations: interchange, tiling, unrolling, and how each changes the access pattern
- SIMD: vector width, alignment, why the compiler often fails to vectorise, and how to check
- Systolic arrays: dataflow (weight-stationary, output-stationary, row-stationary), fill/drain, utilisation
- Reduced precision: fp16, bf16, fp8, int8; the exponent/mantissa trade; why bf16 won for training
- Quantization: symmetric/asymmetric, per-tensor/per-channel, calibration, accumulate-in-higher-precision
- Batching: why intensity in decode equals batch size
- Kernel fusion and operator scheduling
- Amdahl and the memory wall
Primary-Source Readings
Budget: 13 hours.
| Reading | Why | Hours |
|---|---|---|
| Jouppi, N. P. et al. In-Datacenter Performance Analysis of a Tensor Processing Unit. ISCA 2017 | The TPU paper. Read after designing your own accelerator | 3 |
| Williams, S., Waterman, A., Patterson, D. Roofline. CACM 52(4), 2009 | The model | 1.5 |
| Goto, K., van de Geijn, R. Anatomy of High-Performance Matrix Multiplication. ACM TOMS 34(3), 2008 | Why BLAS is fast. Multi-level blocking, done properly | 2.5 |
| Chen, Y.-H., Emer, J., Sze, V. Eyeriss: A Spatial Architecture for Energy-Efficient Dataflow for CNNs. ISCA 2016 | Dataflow taxonomy; the energy argument | 2 |
| Drepper, U. What Every Programmer Should Know About Memory. 2007 | Long, and the best available treatment of cache behaviour | 2 |
| Micikevicius, P. et al. Mixed Precision Training. ICLR 2018 | Why fp16 needs loss scaling | 1 |
| Dettmers, T. et al. LLM.int8(). NeurIPS 2022 | Where naive int8 quantization breaks, and why | 1 |
Experiments
| # | Experiment | Sweep | Predict first |
|---|---|---|---|
| E1 | Loop order | all 6 permutations of i,j,k | Rank them by predicted cache behaviour, then measure |
| E2 | Block size | 8–256 | Predict the optimum from your L1 size |
| E3 | Multi-level tiling | 1, 2, 3 levels | Each level's marginal contribution |
| E4 | Vectorisation | scalar / auto / intrinsics | Predict the factor from vector width |
| E5 | Matrix size | 64–4096 | Where does each variant cross the cache levels? |
| E6 | Roofline placement | every variant | Measured intensity vs derived |
| E7 | Precision | fp32/fp16/int8 | Throughput and error. Predict both |
| E8 | Quantization scheme | per-tensor vs per-channel | Accuracy at equal throughput |
| E9 | Systolic simulation | k ∈ {8,16,32,64,128,256} | Utilisation and effective TOPS |
| E10 | Systolic shape sensitivity | matrices not multiples of k | Predict the utilisation cliff |
| E11 | Fill/drain overhead | small vs large matrices | Where does the pipeline stop amortising? |
| E12 | Batching | batch 1–512 | Reproduce roofline.py decode's prediction |
| E13 | GPU (if available) | naive / tiled / shared-memory | Roofline for each |
| E14 | CPU vs BLAS vs simulated accelerator | at equal problem size | The synthesis experiment |
E9 and E14 together are the project. E9 shows how array dimension trades against utilisation. E14 puts your hand-written CPU kernel (58 GFLOP/s measured), your platform's BLAS/AMX path (1,679 GFLOP/s measured), and your simulated systolic array on one chart, and answers the project's title question with three of your own numbers.
E7 has a trap worth falling into. int8 will show a large throughput gain and, on a real model, a possibly catastrophic accuracy loss — because transformer activations have outlier channels whose dynamic range destroys per-tensor scaling. That is the LLM.int8() result, and reproducing it yourself is far more instructive than reading it. Measure per-channel scaling as the fix and quantify the recovery.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| GFLOP/s | Every variant, every size. The primary number |
| % of practical peak | Against measured BLAS, not against a marketing figure |
| Arithmetic intensity | Measured (via counters) and derived. Both |
| DRAM traffic | Measured where counters allow, derived otherwise |
| Cache miss rates | L1/L2/L3 |
| Roofline position | Plot every variant on one chart |
| Simulator: MAC utilisation | Active cells / total cells / cycle |
| Simulator: effective TOPS | And the gap to theoretical |
| Simulator: fill/drain fraction | Of total cycles |
| Quantization error | Relative error and downstream task accuracy |
| Energy per FLOP | If measurable; otherwise cite and reason about it |
Correctness Tests
- Every variant matches a reference matmul. For fp32, relative error < 1e-5; state and justify the tolerance, since reassociation changes results legitimately.
- The simulator matches a reference matmul exactly in integer arithmetic. A cycle-accurate simulator that computes the wrong answer is worthless.
- Edge shapes: non-square, non-power-of-two, dimensions smaller than the block or array size, and 1×N and N×1.
- Quantization round-trip error within the derived bound.
- Accumulator overflow: int8 inputs must accumulate in int32; test the boundary deliberately.
- Numerical stability at large N — sum order matters; compare against a Kahan-summed float64 reference.
- The simulator's cycle count matches a hand-computed value for a small case. Do a 4×4 array by hand on paper.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Matrix dimension not a multiple of the block size | Correct results; measured performance cliff |
| Dimension smaller than the systolic array | Correct; utilisation reported as low, not hidden |
| Pathological stride (exactly the cache-associativity stride) | Conflict misses visible; explain the mechanism |
| Unaligned input pointers | Correct; measure the penalty |
| int8 with outlier values | Overflow detected or saturating, documented |
| Zero-size matrix | Handled |
| Extremely elongated matrix (1×10⁶) | Correct; poor utilisation reported honestly |
Expected Difficulties
- Compiler flags dominate your results. The -O2/-O3 table above shows a conclusion reversing with flags. Report the exact flags with every number, and run the whole sweep at both settings.
- Auto-vectorisation is opaque. Use
-Rpass=loop-vectorize(clang) or read the assembly. "I added#pragma omp simdand it got faster" is not an explanation. - You will not beat BLAS. Expect 20–30× off. That is the correct outcome and it is the project's thesis; do not spend two weeks trying to close it.
- Hardware counters vary by platform. On Apple Silicon,
perf-equivalents are limited. Derive DRAM traffic analytically where you cannot measure it, and say which you did. - The simulator can absorb unlimited time. Cycle-accurate MACs, fill/drain, and utilisation reporting are the scope. A full memory-hierarchy model is the extension.
- Thermal throttling on a laptop will corrupt long sweeps. Interleave variants rather than running each to completion, and log temperature or at least run order.
Scope Boundaries
In scope: dense matmul on CPU, the block/vectorise/tile progression, roofline analysis, quantization, a cycle-accurate systolic simulator, a GPU implementation if hardware allows.
Out of scope: actual FPGA synthesis (extension); a full accelerator compiler; sparse matmul; convolution (matmul is the workload); distributed or multi-GPU; a real ML framework integration beyond P13's kernels; power measurement requiring instrumentation you do not have.
Deliverables
hwaware/— C kernels, the sweep harness, the simulator, GPU code if applicable- The roofline chart with every variant plotted — the single most legible artifact in Stage 5
REPORT.mdanswering "why can specialised hardware beat a CPU by 100×?" with your own three numbers (58 / 1,679 / simulated)- Notebook entries for E2, E7, E9, E14
- The systolic simulator as a standalone tool, with the TPUv1 derivation as its validation case
Exit Criteria
- CPU progression complete: naive → reordered → blocked → vectorised, all measured at both -O2 and -O3
- E2 complete: block-size optimum found and compared against your cache-size prediction
- Every variant placed on a roofline with measured or derived intensity
- E7 complete: fp32/fp16/int8 throughput and accuracy, with the outlier problem observed and per-channel scaling measured as the fix
- Systolic simulator is cycle-accurate, produces correct results, and reproduces the TPUv1 TOPS figure from k=256 and 700 MHz
- E10/E11 complete: utilisation vs shape and fill/drain overhead measured
- E14 complete: CPU vs BLAS vs simulated accelerator on one chart
- Percentages of peak quoted against dense figures, with the sparsity asterisk noted
-
REPORT.mdwritten with a falsified prediction
Extension Ideas
- Memory-hierarchy simulation: add HBM bandwidth and on-chip SRAM capacity limits to the array simulator, then write a tiler that schedules for it. This turns the simulator into a design-space exploration tool and is the strongest extension here.
- FPGA implementation of a small systolic array. Real, and a genuinely distinctive portfolio piece.
- Triton or CUDA kernel for fused attention, measured against PyTorch's.
- Energy modelling: per-operation energy from published figures, comparing architectures on FLOP/joule rather than FLOP/s — which is the metric that actually drives accelerator design.
Connections
Backward: P13-II supplies the kernels and the fusion framing. P12 supplies cache and TLB understanding from the OS side. P01's cost model supplies the workload shapes.
Forward:
- → P15: "can hardware-aware batching substantially reduce end-to-end embedding latency?" is one of the candidate research questions, and this project is its foundation
- → Retroactively: re-read P02's constant-factor result and P13's dispatch crossover. All three are the same phenomenon at different scales, and saying so explicitly in the P14 report is the synthesis that Stage 5 is for
References
- Jouppi, N. P. et al. In-Datacenter Performance Analysis of a Tensor Processing Unit. ISCA 2017.
- Williams, S., Waterman, A., Patterson, D. Roofline: An Insightful Visual Performance Model for Multicore Architectures. CACM 52(4), 2009.
- Goto, K., van de Geijn, R. A. Anatomy of High-Performance Matrix Multiplication. ACM TOMS 34(3), 2008.
- Chen, Y.-H., Emer, J., Sze, V. Eyeriss: A Spatial Architecture for Energy-Efficient Dataflow for Convolutional Neural Networks. ISCA 2016.
- Kung, H. T., Leiserson, C. E. Systolic Arrays for VLSI. Sparse Matrix Proceedings, 1978. The original.
- Drepper, U. What Every Programmer Should Know About Memory. Red Hat, 2007.
- Micikevicius, P. et al. Mixed Precision Training. ICLR 2018.
- Dettmers, T., Lewis, M., Belkada, Y., Zettlemoyer, L. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS 2022.
- Sze, V., Chen, Y.-H., Yang, T.-J., Emer, J. S. Efficient Processing of Deep Neural Networks. Morgan & Claypool, 2020.
- Hennessy, J. L., Patterson, D. A. Computer Architecture: A Quantitative Approach, 6th ed. Morgan Kaufmann, 2017. Chapter 7 on domain-specific architectures.
- Hennessy, J. L., Patterson, D. A. A New Golden Age for Computer Architecture. CACM 62(2), 2019. The framing argument for this whole project.
P14 hands-on — Hardware-aware ML, block by block
Two measured numbers predict a workload, and two modelling bugs get caught.
Source:
handson/h14_hardware.py--- run it withpython3 handson/h14_hardware.py
Full project spec: P14 — Hardware-Aware ML System
A roofline is two measured numbers and a claim: no kernel can run faster than its arithmetic intensity times the machine's bandwidth, or than the machine's peak compute, whichever is lower. The claim is falsifiable, which is the whole point --- a measurement that beats it is proof that the model is wrong.
This page catches two such bugs. The first version measured peak throughput in fp64 and priced an fp32 workload against it, a factor of four, and the assembly duly reported kernels running at twice the speed of light. The second is subtler and lives in the byte count: a 25 MB working set that stays in cache never pays the DRAM cost the model charges it, and the ratio wanders across 1.0 from run to run as a result.
Both were invisible in the absolute timings. Both were unmissable the moment the number was divided by a bound it could not legally cross.
Contents
- Block 1 — Measure the machine, not the spec sheet
- Block 2 — Arithmetic intensity
- Block 3 — Predict, then measure
- Block 4 — Tiling
- Block 5 — Quantisation
- Block 6 — Batching and Little's Law
- Block 7 — The decode wall
- The assembly
- The design space
- Two modelling bugs this page catches
- The hardware, level by level
- Optimisation levers, in the order they pay
- Advanced topics
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Measure the machine, not the spec sheet
Teaches: the roofline is per-dtype, and that bites
The problem. A roofline is two numbers and a falsifiable claim. Getting either number wrong makes the model report impossible results — which is the most useful failure a performance model can have.
@block(1, "Measure the machine, not the spec sheet", "the roofline is per-dtype, and that bites")
def b1(s, show):
n = 1024
peaks = {}
for dt in (np.float64, np.float32):
A = np.random.rand(n, n).astype(dt); B = np.random.rand(n, n).astype(dt)
peaks[np.dtype(dt).name] = 2 * n**3 / best(lambda: A @ B, 5)
# peak bandwidth: a streaming triad, far larger than any cache
N = 24_000_000
x = np.random.rand(N); y = np.random.rand(N); z = np.empty(N)
def triad(): np.multiply(x, 2.0, out=z); np.add(z, y, out=z)
tb = best(triad, 5)
bw = 3 * N * 8 / tb
if show:
print(f" peak bandwidth : {bw/1e9:>8.1f} GB/s "
f"(triad over {N*8/1e6:.0f} MB in {tb*1000:.1f} ms)")
print(f" {'dtype':<10}{'peak GFLOP/s':>15}{'ridge point':>15}")
for k, v in peaks.items():
print(f" {k:<10}{v/1e9:>15.1f}{v/bw:>13.2f}")
print(f" fp32 is {peaks['float32']/peaks['float64']:.2f}x fp64 -- two SIMD lanes")
print(" per register instead of one, and on this machine a little more than")
print(" the theoretical 2x because the fp32 kernel also gets better cache")
print(" reuse per byte.\n")
print(" THERE IS NO SUCH THING AS 'the' ROOFLINE. There is one per dtype, and")
print(f" the ridge moves with it: {peaks['float64']/bw:.1f} FLOP/byte in fp64, "
f"{peaks['float32']/bw:.1f} in fp32. Mixing")
print(" them is not a rounding error -- the first version of this file")
print(" measured peak in fp64 and predicted an fp32 workload, and the")
print(" assembly reported kernels running at 2x the speed of light. A ratio")
print(" below 1.0 against a roofline is never a fast kernel; it is always a")
print(" broken model, and it is the most useful bug the roofline can produce")
print(" because it is impossible to rationalise away. There are exactly two")
print(" ways to get one: the wrong ceiling (this bug) or the wrong byte count")
print(" (a working set that never left cache -- the assembly measures that")
print(" one too).")
print(" Both numbers are MEASURED. The vendor's peak assumes an FMA on every")
print(" port every cycle, which no real kernel reaches; numbers.md records")
print(" this machine's bandwidth at 57.5-99.1 GB/s depending on working set,")
print(" and this triad sits at the streaming end of that range.")
return {"flops": peaks["float64"], "flops32": peaks["float32"], "bw": bw,
"ridge": peaks["float64"] / bw, "ridge32": peaks["float32"] / bw}
Reading the implementation
- Peak compute from a large square matmul, because that is the closest any real kernel gets to the hardware ceiling. A 1024³ matmul has arithmetic intensity ~170 FLOP/byte, well above any ridge point, so it is compute-bound and measures the FMA units rather than the memory system.
- Peak bandwidth from a streaming triad over 192 MB — deliberately far larger
than any cache, so the measurement is DRAM and not L3. Getting this wrong is the
single most common roofline error, and the
out=parameters matter: without them numpy allocates a fresh array per operation and the measurement includes allocation.
The ridge point is where the two lines cross: \(I_{\text{ridge}} = \text{peak FLOP/s} / \text{peak bytes/s}\). Above it a kernel can be compute-bound; below it, no amount of arithmetic optimisation helps.
And there is one ridge per dtype. fp32 peak here is ~4× fp64 peak — two SIMD lanes per register instead of one, plus better cache reuse per byte — so the ridge moves from ~9 to ~39 FLOP/byte. Mixing them is not a rounding error: the first version of this file measured peak in fp64, priced an fp32 workload against it, and the assembly reported kernels running at twice the speed of light.
What the numbers say
Output:
peak bandwidth : 54.9 GB/s (triad over 192 MB in 10.5 ms)
dtype peak GFLOP/s ridge point
float64 453.5 8.26
float32 2085.8 38.01
fp32 is 4.60x fp64 -- two SIMD lanes
per register instead of one, and on this machine a little more than
the theoretical 2x because the fp32 kernel also gets better cache
reuse per byte.
THERE IS NO SUCH THING AS 'the' ROOFLINE. There is one per dtype, and
the ridge moves with it: 8.3 FLOP/byte in fp64, 38.0 in fp32. Mixing
them is not a rounding error -- the first version of this file
measured peak in fp64 and predicted an fp32 workload, and the
assembly reported kernels running at 2x the speed of light. A ratio
below 1.0 against a roofline is never a fast kernel; it is always a
broken model, and it is the most useful bug the roofline can produce
because it is impossible to rationalise away. There are exactly two
ways to get one: the wrong ceiling (this bug) or the wrong byte count
(a working set that never left cache -- the assembly measures that
one too).
Both numbers are MEASURED. The vendor's peak assumes an FMA on every
port every cycle, which no real kernel reaches; numbers.md records
this machine's bandwidth at 57.5-99.1 GB/s depending on working set,
and this triad sits at the streaming end of that range.
Beyond the toy
A ratio below 1.0 against a roofline is never a fast kernel. It is always a broken model, and there are exactly two ways to break it:
- Wrong ceiling — a dtype mismatch, or using a vendor spec sheet instead of a measurement. Nobody reaches vendor peak; the gap is workload-specific.
- Wrong byte count — a working set that never left cache, so the model charged DRAM traffic the kernel never paid. The assembly measures this one too.
Both are invisible in absolute timings and unmissable the moment the number is divided by a bound it cannot legally cross. That is what the model is for.
Block 2 — Arithmetic intensity
Teaches: the property that decides which wall you hit
The problem. Arithmetic intensity is the single property that decides which wall a kernel hits, and it is computable on paper before writing any code.
@block(2, "Arithmetic intensity", "the property that decides which wall you hit")
def b2(s, show):
if show:
print(f" fp64 ridge = {s['ridge']:.2f} FLOP/byte on this machine "
f"(fp32 ridge = {s['ridge32']:.2f})\n")
print(f" {'kernel':<28}{'FLOPs':>12}{'bytes':>12}{'intensity':>12}{'bound by':>11}")
N = 4_000_000
cases = [
("vector add (a+b)", N, 3*N*8),
("scale (a*2)", N, 2*N*8),
("dot product", 2*N, 2*N*8),
("matmul 64x64", 2*64**3, 3*64*64*8),
("matmul 1024x1024", 2*1024**3, 3*1024*1024*8),
("attention, seq 1024", 4*1024**2*64, 3*1024*64*8),
]
for lbl, f, b in cases:
ai = f / b
print(f" {lbl:<28}{f/1e6:>10.1f}M{b/1e6:>10.1f}M{ai:>12.2f}"
f"{('COMPUTE' if ai > s['ridge'] else 'MEMORY'):>11}")
print(" Elementwise operations have intensity below 1 and can never be")
print(" compute-bound on any machine built since about 1990 -- the ridge has")
print(" been climbing for thirty years while DRAM latency barely moved.")
print(" Matmul's intensity grows as O(n), which is the entire reason deep")
print(" learning runs on hardware designed for it.")
return {}
Reading the implementation
\[ I = \frac{\text{FLOPs}}{\text{bytes moved}} \]
The table is worth reading as a hierarchy rather than a list:
- Elementwise (
a+b): 1 FLOP per 3 × 8 bytes = 0.042. Memory-bound on every machine built since about 1990, and no optimisation of the arithmetic can possibly help. - Dot product: 2 FLOPs per 16 bytes = 0.125. Same regime — and this is P02's inner loop, which is why that project is latency-bound rather than compute-bound.
- Matmul \(n \times n\): \(2n^3\) FLOPs over \(3n^2 \times 8\) bytes, so \(I = n/12\). Intensity grows linearly with \(n\), which is the entire reason deep learning runs on hardware designed for matmul.
That last line deserves emphasis. Matmul is the only common primitive whose intensity improves with size, which is why the field bent itself toward architectures expressible as large matmuls, and why attention's \(T^2 d\) term is tolerable while an equivalent amount of elementwise work would not be.
What the numbers say
Output:
fp64 ridge = 8.26 FLOP/byte on this machine (fp32 ridge = 38.01)
kernel FLOPs bytes intensity bound by
vector add (a+b) 4.0M 96.0M 0.04 MEMORY
scale (a*2) 4.0M 64.0M 0.06 MEMORY
dot product 8.0M 64.0M 0.12 MEMORY
matmul 64x64 0.5M 0.1M 5.33 MEMORY
matmul 1024x1024 2147.5M 25.2M 85.33 COMPUTE
attention, seq 1024 268.4M 1.6M 170.67 COMPUTE
Elementwise operations have intensity below 1 and can never be
compute-bound on any machine built since about 1990 -- the ridge has
been climbing for thirty years while DRAM latency barely moved.
Matmul's intensity grows as O(n), which is the entire reason deep
learning runs on hardware designed for it.
Beyond the toy
The byte count is the part people get wrong, because it depends on what is in cache. A matmul that streams \(3n^2\) bytes from DRAM has intensity \(n/12\); the same matmul with all operands resident in L2 has effectively infinite DRAM intensity and is bounded by L2 bandwidth instead. Hierarchical roofline models this with a separate ceiling per cache level, and it is the right tool when the single-level model gives a ratio you cannot explain.
Block 3 — Predict, then measure
Teaches: a roofline is a falsifiable claim
The problem. A model that is never checked against measurement is a decoration. This block predicts five kernels and compares — and the direction of each error is diagnostic.
@block(3, "Predict, then measure", "a roofline is a falsifiable claim")
def b3(s, show):
if show:
print(f" {'kernel':<26}{'intensity':>11}{'predicted':>13}{'measured':>12}"
f"{'ratio':>8}")
N = 8_000_000
a = np.random.rand(N); b = np.random.rand(N); c = np.empty(N)
tests = []
t = best(lambda: np.add(a, b, out=c), 5)
tests.append(("vector add", N, 3*N*8, t))
t = best(lambda: np.multiply(a, 2.0, out=c), 5)
tests.append(("scale by constant", N, 2*N*8, t))
t = best(lambda: float(a @ b), 5)
tests.append(("dot product", 2*N, 2*N*8, t))
for n in (256, 1024):
A = np.random.rand(n, n); B = np.random.rand(n, n)
t = best(lambda: A @ B, 5)
tests.append((f"matmul {n}x{n}", 2*n**3, 3*n*n*8, t))
for lbl, f, byt, t in tests:
ai = f / byt
pred = min(s["flops"], ai * s["bw"]) # the roofline itself
meas = f / t
print(f" {lbl:<26}{ai:>11.2f}{pred/1e9:>11.1f}G{meas/1e9:>10.1f}G"
f"{meas/pred:>8.2f}")
print(" A ratio near 1.0 means the roofline explained the kernel. Below 1.0")
print(" means something else is the limit -- latency, a missing")
print(" vectorisation, an unaligned access. Above 1.0 means a modelling")
print(" error: usually the kernel hit cache and never touched DRAM, so the")
print(" 'bytes' figure is fiction. The model earns trust by being wrong in")
print(" ways you can explain.")
return {}
Reading the implementation
For each kernel: count FLOPs and bytes on paper, compute intensity, take \(\min(\text{peak}, I \times \text{bandwidth})\) as the predicted rate, measure the actual, and report the ratio.
Reading the ratios:
- ≈1.0 — the roofline explained the kernel. Nothing more to find.
- <1.0 (measured faster than the bound) — impossible; the model is wrong. Usually the byte count (cache residency) or the ceiling (dtype).
- >1.0 (measured slower) — something the roofline does not model is the limit: latency rather than bandwidth, poor vectorisation, unaligned access, or insufficient memory-level parallelism.
That third case is the common one and it is where the model earns its keep — it tells you there is another effect, which is far more actionable than a raw timing.
What the numbers say
Output:
kernel intensity predicted measured ratio
vector add 0.04 2.3G 2.8G 1.23
scale by constant 0.06 3.4G 5.4G 1.58
dot product 0.12 6.9G 8.0G 1.16
matmul 256x256 21.33 453.5G 331.8G 0.73
matmul 1024x1024 85.33 453.5G 463.7G 1.02
A ratio near 1.0 means the roofline explained the kernel. Below 1.0
means something else is the limit -- latency, a missing
vectorisation, an unaligned access. Above 1.0 means a modelling
error: usually the kernel hit cache and never touched DRAM, so the
'bytes' figure is fiction. The model earns trust by being wrong in
ways you can explain.
Beyond the toy
Little's Law is the missing half of the model: \(L = \lambda W\). To sustain 50 GB/s at 64 bytes per cache line with 121 ns latency requires \(50\text{e}9/64 \times 121\text{e-}9 \approx 94\) concurrent line fetches. A core has ~10--16 line-fill buffers, so a single thread physically cannot reach peak bandwidth — which is why single-threaded STREAM numbers are always far below the machine's rating, and why memory-level parallelism (multiple threads, software prefetch, independent load chains) is the lever rather than a faster loop.
Block 4 — Tiling
Teaches: the same FLOPs, a different traffic pattern
The problem. Same FLOPs, different traffic pattern. Tiling is the canonical demonstration that how you touch memory dominates how much arithmetic you do.
@block(4, "Tiling", "the same FLOPs, a different traffic pattern")
def b4(s, show):
def naive(A, B):
n = A.shape[0]; C = np.zeros((n, n))
for i in range(n):
for k in range(n):
C[i] += A[i, k] * B[k]
return C
def tiled(A, B, T=64):
n = A.shape[0]; C = np.zeros((n, n))
for i0 in range(0, n, T):
for k0 in range(0, n, T):
for j0 in range(0, n, T):
C[i0:i0+T, j0:j0+T] += (A[i0:i0+T, k0:k0+T]
@ B[k0:k0+T, j0:j0+T])
return C
if show:
n = 512
A = np.random.rand(n, n); B = np.random.rand(n, n)
ref = A @ B
t_bl = best(lambda: A @ B, 3)
t_na = best(lambda: naive(A, B), 1)
rows = [("row-at-a-time (python loop)", t_na, naive(A, B))]
for T in (32, 64, 128, 256):
tt = best(lambda: tiled(A, B, T), 3)
rows.append((f"tiled, T={T}", tt, tiled(A, B, T)))
rows.append(("numpy (BLAS)", t_bl, ref))
print(f" C = A @ B, {n}x{n}, {2*n**3/1e9:.2f} GFLOP")
print(f" {'implementation':<30}{'time':>10}{'GFLOP/s':>10}"
f"{'vs BLAS':>10}{'correct':>9}")
for lbl, tt, out in rows:
print(f" {lbl:<30}{tt*1000:>8.1f}ms{2*n**3/tt/1e9:>10.1f}"
f"{t_bl/tt:>9.2f}x"
f"{str(bool(np.allclose(out, ref))):>9}")
wss = lambda T: 3 * T * T * 8 / 1024
print(f" working set per tile: T=32 -> {wss(32):.0f} KB, "
f"T=64 -> {wss(64):.0f} KB, T=128 -> {wss(128):.0f} KB, "
f"T=256 -> {wss(256):.0f} KB")
print(" The tiles here still call BLAS, so this measures BLOCKING, not")
print(" hand-written inner loops: how much you lose by cutting a big matmul")
print(" into small ones. The loss is real and it comes from per-call")
print(" overhead plus reduced reuse -- which is the same trade the tile-size")
print(" choice makes inside a real GEMM, one level down.")
return {"tiled": tiled}
Reading the implementation
Blocked matmul: partition into \(T \times T\) tiles so each tile's working set — three \(T \times T\) blocks — fits in a cache level. Each element loaded is then reused \(T\) times before eviction, so DRAM traffic drops from \(O(n^3)\) to \(O(n^3/T)\).
The working-set arithmetic is the design rule: \(3T^2 \times 8\) bytes must fit the target level. For a 32 KB L1 that is \(T \approx 36\); for a 1 MB L2, \(T \approx 209\). Real GEMMs tile at three levels simultaneously — register, L1, L2 — which is why a hand-written microkernel is a serious piece of engineering and why the measured gap to BLAS here is unsurprising.
Be honest about what this measures. The tiles still call BLAS, so this is measuring blocking overhead — how much you lose by cutting a large matmul into small ones — rather than hand-written inner loops. That loss is real (per-call overhead plus reduced reuse) and it is the same trade a real GEMM makes one level down.
What the numbers say
Output:
C = A @ B, 512x512, 0.27 GFLOP
implementation time GFLOP/s vs BLAS correct
row-at-a-time (python loop) 297.1ms 0.9 0.00x True
tiled, T=32 18.8ms 14.3 0.03x True
tiled, T=64 6.1ms 44.0 0.08x True
tiled, T=128 2.9ms 93.4 0.18x True
tiled, T=256 1.5ms 181.1 0.34x True
numpy (BLAS) 0.5ms 527.4 1.00x True
working set per tile: T=32 -> 24 KB, T=64 -> 96 KB, T=128 -> 384 KB, T=256 -> 1536 KB
The tiles here still call BLAS, so this measures BLOCKING, not
hand-written inner loops: how much you lose by cutting a big matmul
into small ones. The loss is real and it comes from per-call
overhead plus reduced reuse -- which is the same trade the tile-size
choice makes inside a real GEMM, one level down.
Beyond the toy
- The theoretical floor. Hong & Kung's red-blue pebble game gives \(\Omega(n^3/\sqrt{M})\) as the minimum DRAM traffic for matmul with \(M\) words of fast memory. Tiling with \(T = \sqrt{M/3}\) achieves it up to constants, so this is not a heuristic — it is optimal.
- Cache-oblivious algorithms get the same asymptotic traffic by recursive subdivision, without knowing the cache size, which matters when you cannot tune per machine.
- What a real GEMM adds: register blocking (accumulate in registers, not memory), packing (copy tiles into contiguous, aligned buffers so the inner loop streams), explicit SIMD, software prefetch, and multithreading with a NUMA-aware partition. Goto & van de Geijn's paper is the canonical description, and the gap between this block and BLAS is exactly that list.
Block 5 — Quantisation
Teaches: fewer bytes per weight IS higher arithmetic intensity
The problem. Fewer bytes per weight is higher arithmetic intensity. In a memory-bound regime that is the most direct lever available — and this block measures the honest version, including the part numpy cannot deliver.
@block(5, "Quantisation", "fewer bytes per weight IS higher arithmetic intensity")
def b5(s, show):
if show:
rng = np.random.default_rng(5)
n = 2048
W = rng.normal(0, 0.5, (n, n)).astype(np.float32)
x = rng.normal(0, 1, n).astype(np.float32)
scale = np.abs(W).max() / 127.0
Wq = np.clip(np.round(W / scale), -127, 127).astype(np.int8)
ref = W @ x
deq = (Wq.astype(np.float32) * scale) @ x
t32 = best(lambda: W @ x, 20)
t8 = best(lambda: (Wq.astype(np.float32) * scale) @ x, 20)
t8b = best(lambda: Wq.astype(np.float32) @ x, 20)
err = np.abs(deq - ref).max() / np.abs(ref).max()
cos = float(deq @ ref / (np.linalg.norm(deq) * np.linalg.norm(ref)))
print(f" {n}x{n} weight matrix, matrix-vector product (the decode shape)")
print(f" {'precision':<20}{'weight bytes':>14}{'time':>10}{'GB/s':>9}"
f"{'rel error':>12}")
for lbl, byts, tt, e in (("float32", W.nbytes, t32, 0.0),
("int8 + dequant", Wq.nbytes, t8, err)):
print(f" {lbl:<20}{byts/1e6:>12.1f}M{tt*1e6:>9.0f}us"
f"{byts/tt/1e9:>9.1f}{e:>12.2e}")
print(f" cosine similarity of the two outputs: {cos:.6f}")
print(f" 4x fewer weight bytes; measured speedup {t32/t8:.2f}x, and the")
print(" dequantisation itself costs most of what the smaller load saved.")
print(" A real int8 kernel keeps the arithmetic in int8 and dequantises the")
print(" ACCUMULATOR once, which is why production quantisation needs kernel")
print(" support and not just a smaller dtype in memory. Numpy has no int8")
print(" GEMM, so what this block honestly measures is the memory saving and")
print(" the accuracy cost -- both real, and the speedup is the part you")
print(" cannot get without writing the kernel.")
return {}
Reading the implementation
Symmetric per-tensor int8 quantisation: \(s = \max|W|/127\), then \(W_q = \text{round}(W/s)\). Dequantise as \(W_q \times s\).
The measurement is deliberately honest about its limit. numpy has no int8 GEMM,
so Wq.astype(float32) * scale @ x converts back to float before multiplying —
which means what this block truthfully measures is the memory saving and the
accuracy cost, not the speedup. The speedup requires a kernel that keeps the
arithmetic in int8 and dequantises the accumulator once, and saying so is more
useful than reporting a number that comes from a different mechanism.
The accuracy result is the transferable part: cosine similarity ~0.9999 at 4× fewer bytes. Distance and dot-product rankings are remarkably robust to quantisation, which is the same premise underlying P02's PQ codes.
What the numbers say
Output:
2048x2048 weight matrix, matrix-vector product (the decode shape)
precision weight bytes time GB/s rel error
float32 16.8M 121us 138.7 0.00e+00
int8 + dequant 4.2M 1689us 2.5 8.45e-03
cosine similarity of the two outputs: 0.999941
4x fewer weight bytes; measured speedup 0.07x, and the
dequantisation itself costs most of what the smaller load saved.
A real int8 kernel keeps the arithmetic in int8 and dequantises the
ACCUMULATOR once, which is why production quantisation needs kernel
support and not just a smaller dtype in memory. Numpy has no int8
GEMM, so what this block honestly measures is the memory saving and
the accuracy cost -- both real, and the speedup is the part you
cannot get without writing the kernel.
Beyond the toy
- Per-tensor vs per-channel. One scale for the whole matrix is simple and loses accuracy when channels have very different ranges. Per-channel (one scale per output row) is standard and nearly free.
- Outliers are the hard part in LLM quantisation: a handful of activation channels have magnitudes 100× the rest, and they dominate the scale. LLM.int8() keeps those channels in fp16; SmoothQuant migrates the difficulty from activations into weights; GPTQ and AWQ use calibration data to choose scales that minimise output error rather than weight error.
- The formats. int8 (per-channel, well understood), int4 (needs group-wise scales, ~3.5 bits effective), fp8 e4m3/e5m2 (hardware support on H100-class, keeps dynamic range), and 1.58-bit ternary schemes at the research edge.
- Quantisation is a bandwidth optimisation first. In memory-bound decode it buys nearly its full ratio; in compute-bound prefill it buys only what the tensor-core throughput ratio gives.
Block 6 — Batching and Little's Law
Teaches: the only free speedup in a memory-bound regime
The problem. The only free speedup in a memory-bound regime. Weights are read once regardless of batch size, so every additional item in the batch is nearly free until the kernel becomes compute-bound.
@block(6, "Batching and Little's Law", "the only free speedup in a memory-bound regime")
def b6(s, show):
if show:
rng = np.random.default_rng(6)
n = 2048
W = rng.normal(0, .5, (n, n)).astype(np.float32)
print(f" one {n}x{n} weight matrix, batch of B vectors:")
print(f" {'batch':>7}{'time':>10}{'per-item':>11}{'GFLOP/s':>10}"
f"{'intensity':>11}{'weight reads':>14}")
t1 = None
for B in (1, 2, 8, 32, 128):
X = rng.normal(0, 1, (n, B)).astype(np.float32)
t = best(lambda: W @ X, 10)
t1 = t1 or t
f = 2 * n * n * B
byt = W.nbytes + X.nbytes + 4 * n * B
print(f" {B:>7}{t*1e6:>8.0f}us{t*1e6/B:>10.1f}us"
f"{f/t/1e9:>10.1f}{f/byt:>11.2f}{'1':>14}")
print(" The weight matrix is read ONCE regardless of batch size, so every")
print(" extra request in the batch is nearly free until the kernel becomes")
print(" compute-bound. That is why LLM serving batches aggressively and why")
print(" batch-1 latency is the worst possible operating point: you pay the")
print(" full 16 MB weight read to produce a single token.")
print(" Little's Law gives the other half: L = lambda x W. To keep a batch of")
print(" 32 in flight at 20 ms per batch you need 1600 requests/second of")
print(" arrival. Below that, the batch never fills and you are choosing")
print(" between latency and utilisation -- which is what a scheduler's")
print(" max-wait parameter actually configures.")
return {}
Reading the implementation
One weight matrix, batch of \(B\) vectors. FLOPs scale as \(B\); bytes scale as \(W + Bx\), which is dominated by \(W\) for small \(B\). So intensity scales almost linearly with batch until the weight term stops dominating.
That is the entire economics of inference serving, and it is why batch-1 latency is the worst possible operating point: you pay the full weight read to produce a single result.
What the numbers say
Output:
one 2048x2048 weight matrix, batch of B vectors:
batch time per-item GFLOP/s intensity weight reads
1 123us 122.9us 68.3 0.50 1
2 532us 265.8us 31.6 1.00 1
8 534us 66.7us 125.8 3.97 1
32 253us 7.9us 1060.8 15.52 1
128 458us 3.6us 2342.5 56.89 1
The weight matrix is read ONCE regardless of batch size, so every
extra request in the batch is nearly free until the kernel becomes
compute-bound. That is why LLM serving batches aggressively and why
batch-1 latency is the worst possible operating point: you pay the
full 16 MB weight read to produce a single token.
Little's Law gives the other half: L = lambda x W. To keep a batch of
32 in flight at 20 ms per batch you need 1600 requests/second of
arrival. Below that, the batch never fills and you are choosing
between latency and utilisation -- which is what a scheduler's
max-wait parameter actually configures.
Beyond the toy
Little's Law supplies the other half. \(L = \lambda W\): to keep a batch of
32 in flight at 20 ms per batch you need 1,600 requests/second of arrival. Below
that, the batch never fills and you are choosing between latency (dispatch a
partial batch) and utilisation (wait) — which is exactly what a serving system's
max_wait_ms parameter configures.
The refinements that follow, all of which exist to raise decode intensity:
- Continuous batching — admit new requests into the running batch at every decode step rather than waiting for the whole batch to finish. Removes head-of-line blocking from a long generation.
- Paged attention — allocate KV cache in fixed blocks like OS pages (P12) so memory is not over-provisioned per sequence, which raises the batch size that fits.
- Speculative decoding — a draft model proposes \(k\) tokens, the target verifies them in one pass, converting \(k\) memory-bound steps into one compute-bound one with identical output distribution.
Block 7 — The decode wall
Teaches: why generation is memory-bound and prefill is not
The problem. Why generation is memory-bound and prefill is not. This is the single most consequential arithmetic in LLM serving, and it is one division.
@block(7, "The decode wall", "why generation is memory-bound and prefill is not")
def b7(s, show):
if show:
print(" A transformer layer, hidden d, batch B, sequence S. Weights are")
print(" ~12d^2 bytes in fp16; the matmuls are ~24 B S d^2 FLOPs.")
print(f" {'phase':<22}{'B':>4}{'S':>7}{'intensity':>12}{'bound by':>11}"
f"{'note':>22}")
d = 4096
for lbl, B, S in (("prefill, 2k prompt", 1, 2048), ("decode, 1 token", 1, 1),
("decode, batch 32", 32, 1), ("decode, batch 256", 256, 1)):
flops = 24 * B * S * d * d
byts = 12 * d * d + 4 * B * S * d
ai = flops / byts
note = "reads 200MB per token" if B == 1 and S == 1 else ""
print(f" {lbl:<22}{B:>4}{S:>7}{ai:>12.1f}"
f"{('COMPUTE' if ai > s['ridge32'] else 'MEMORY'):>11}{note:>22}")
print(f" (fp32 ridge on this machine = {s['ridge32']:.1f} FLOP/byte; on an H100")
print(" with ~990 TFLOP/s and ~3.35 TB/s it is about 295, so the same table")
print(" on a GPU pushes even batch-256 decode into the memory-bound column.)")
print(" Prefill has S=2048 tokens sharing one weight read, so it is compute-")
print(" bound and scales with FLOPs. Decode has S=1: the SAME weights are")
print(" read to produce a single token. Batching is the only lever that")
print(" raises decode intensity, which is the whole reason continuous")
print(" batching, paged attention and speculative decoding exist -- all")
print(" three are attempts to get more work per weight read. See proofs.md")
print(" P6 for the derivation.")
return {}
Reading the implementation
For a transformer layer with hidden size \(d\), batch \(B\), sequence \(S\): weights are ~\(12d^2\) bytes in fp16 and the matmuls are ~\(24BSd^2\) FLOPs, so
\[ I = \frac{24BSd^2}{12d^2 + 4BSd} \approx 2BS \quad \text{for } BSd \ll 3d^2 \]
Prefill has \(S = 2048\): thousands of tokens share one weight read, so intensity is high and the phase is compute-bound. Decode has \(S = 1\): the same weights are read to produce one token, intensity ≈2 FLOP/byte, and the phase is memory-bound by two orders of magnitude.
Batching is the only lever that raises decode intensity, because it is the only term in the numerator you control.
What the numbers say
Output:
A transformer layer, hidden d, batch B, sequence S. Weights are
~12d^2 bytes in fp16; the matmuls are ~24 B S d^2 FLOPs.
phase B S intensity bound by note
prefill, 2k prompt 1 2048 3510.9 COMPUTE
decode, 1 token 1 1 2.0 MEMORY reads 200MB per token
decode, batch 32 32 1 63.8 COMPUTE
decode, batch 256 256 1 501.6 COMPUTE
(fp32 ridge on this machine = 38.0 FLOP/byte; on an H100
with ~990 TFLOP/s and ~3.35 TB/s it is about 295, so the same table
on a GPU pushes even batch-256 decode into the memory-bound column.)
Prefill has S=2048 tokens sharing one weight read, so it is compute-
bound and scales with FLOPs. Decode has S=1: the SAME weights are
read to produce a single token. Batching is the only lever that
raises decode intensity, which is the whole reason continuous
batching, paged attention and speculative decoding exist -- all
three are attempts to get more work per weight read. See proofs.md
P6 for the derivation.
Beyond the toy
Concrete consequences, using published H100 figures (~3.35 TB/s):
| Model | Weights (fp16) | Time to read once | Implied tok/s ceiling |
|---|---|---|---|
| 7B | 14 GB | 4.2 ms | ~240 |
| 70B | 140 GB | 42 ms | ~24 |
| 70B, 8-way tensor parallel | 17.5 GB/GPU | 5.2 ms | ~190 |
No arithmetic optimisation moves those numbers. Every real lever is a byte lever: quantise the weights, share KV heads (GQA/MQA), or amortise the read across more sequences.
And the ridge points make it worse over time: this machine's fp32 ridge is ~39, an A100's is ~156, an H100's ~295. Every accelerator generation has widened the gap between compute and bandwidth, so a kernel that was compute-bound on a V100 can be memory-bound on an H100 with no code change. That is the strongest argument in the whole track for re-measuring rather than inheriting conclusions.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSeven blocks = a performance model. Predict a workload before running it.\n")
rng = np.random.default_rng(14)
d, L = 1024, 6
Ws = [rng.normal(0, .02, (d, d)).astype(np.float32) for _ in range(L)]
def layer(x, W): return np.maximum(x @ W, 0)
print(f" Workload: a {L}-layer MLP, width {d}, fp32, batch B.")
print(f" Per batch: {L} matmuls = {2*L*d*d/1e6:.1f} MFLOP per item,")
print(f" weights = {L*d*d*4/1e6:.1f} MB read once per batch.\n")
print(f" {'batch':>6}{'intensity':>11}{'roofline says':>15}{'predicted':>12}"
f"{'measured':>11}{'ratio':>8}{'bound':>9}")
ratio_b1 = None
for B in (1, 4, 16, 64, 256):
X = rng.normal(0, 1, (B, d)).astype(np.float32)
flops = 2 * L * B * d * d
byts = L * d * d * 4 + 2 * B * d * 4 * L
ai = flops / byts
pred_rate = min(s["flops32"], ai * s["bw"]) # fp32 workload -> fp32 peak
pred_t = flops / pred_rate
def run():
x = X
for W in Ws: x = layer(x, W)
return x
t = best(run, 5)
bound = "COMPUTE" if ai > s["ridge32"] else "MEMORY"
if B == 1: ratio_b1 = t / pred_t
print(f" {B:>6}{ai:>11.2f}{bound:>15}{pred_t*1e6:>10.0f}us"
f"{t*1e6:>9.0f}us{t/pred_t:>8.2f}{bound:>9}")
print("\n Now hold the batch at 1 and grow the weights instead, so the same")
print(" model is priced against working sets that do and do not fit in cache:\n")
print(f" {'width':>7}{'layers':>8}{'weight MB':>12}{'predicted':>12}"
f"{'measured':>11}{'ratio':>8}")
fp_ratios = []
for dd, LL in ((1024, 6), (2048, 6), (2048, 12), (4096, 8)):
Ws2 = [rng.normal(0, .02, (dd, dd)).astype(np.float32) for _ in range(LL)]
X = rng.normal(0, 1, (1, dd)).astype(np.float32)
byts = LL * dd * dd * 4 + 2 * dd * 4 * LL
def run2():
v = X
for W in Ws2: v = np.maximum(v @ W, 0)
return v
tt = best(run2, 5); pred = byts / s["bw"]
fp_ratios.append(tt / pred)
print(f" {dd:>7}{LL:>8}{LL*dd*dd*4/1e6:>12.1f}{pred*1e6:>10.0f}us"
f"{tt*1e6:>9.0f}us{tt/pred:>8.2f}")
print(f"\n Look at the 25 MB row twice. The batch table measured it at "
f"{ratio_b1:.2f} and")
print(f" the footprint table at {fp_ratios[0]:.2f} -- the same weights, the same")
print(" arithmetic, both sitting AT the bound rather than comfortably above it,")
print(f" and wandering by {abs(ratio_b1-fp_ratios[0]):.2f} between two runs in the same process.")
print(" A ratio that hovers at or below 1.0 is the signature of a PARTLY cache-")
print(" resident working set: the weights are re-read every iteration, some")
print(" fraction survives in this machine's last-level cache, and how large that")
print(" fraction is depends on what else touched memory first. The model charged")
print(" the kernel for 25 MB of DRAM traffic that it only partly paid.")
print(f" Past 100 MB the ambiguity disappears: {fp_ratios[1]:.2f}, {fp_ratios[2]:.2f}, "
f"{fp_ratios[3]:.2f}. The working")
print(" set no longer fits, every byte really does come from DRAM, and the")
print(" roofline becomes a bound the kernel reaches about half of. The cliff")
print(" between those two regimes is the cache, measured without ever naming")
print(" its size -- and it is the same cliff as P12's page-fault curve and")
print(" P04's Bloom filter, one level up the hierarchy.")
print("\n Two different bugs produced a sub-1.0 ratio in this file: an fp64")
print(" ceiling on an fp32 workload (block 1, a factor of 4) and a byte count")
print(" that assumed DRAM for data sitting in cache (above). Both were invisible in the")
print(" absolute timings and both were obvious the moment the number was divided")
print(" by a bound it could not legally cross. That is what the model is FOR --")
print(" not predicting runtime, but making a specific class of mistake loud.")
print("\n The prediction uses two numbers measured in block 1 and a FLOP count")
print(" done on paper. No profiler, no counters. Read the batch table's gap")
print(" where it is largest: at batch 256 we reach roughly half of peak, because")
print(" a 1024x256 matmul is skinnier than the square one that set the ceiling")
print(" and there is a full-size ReLU pass between every layer.")
print("\n This is the deliverable of hardware-aware ML: not a faster kernel, but")
print(" the ability to say IN ADVANCE which optimisations can possibly help.")
print(" If a kernel sits at 0.9 of its roofline, rewriting the inner loop is")
print(" wasted work and the only remaining moves are algorithmic -- fewer bytes")
print(" (quantisation, block 5) or more work per byte (batching, block 6).")
print("\n Built: measured roofline -> arithmetic intensity -> prediction vs")
print(" measurement -> tiling -> quantisation -> batching -> the decode wall.")
print(" Missing, on the project page: hardware counters via perf (m3), a real")
print(" hand-written GEMM microkernel with register blocking (m5), operator")
print(" fusion measured end to end (m7), GPU occupancy and warp scheduling")
print(" (m9-m10), and E2 -- the experiment that finds this machine's cache")
print(" hierarchy from a latency curve rather than from a spec sheet.")
Output:
Seven blocks = a performance model. Predict a workload before running it.
Workload: a 6-layer MLP, width 1024, fp32, batch B.
Per batch: 6 matmuls = 12.6 MFLOP per item,
weights = 25.2 MB read once per batch.
batch intensity roofline says predicted measured ratio bound
1 0.50 MEMORY 460us 424us 0.92 MEMORY
4 1.98 MEMORY 462us 501us 1.08 MEMORY
16 7.76 MEMORY 473us 553us 1.17 MEMORY
64 28.44 MEMORY 516us 1238us 2.40 MEMORY
256 85.33 COMPUTE 1544us 3398us 2.20 COMPUTE
Now hold the batch at 1 and grow the weights instead, so the same
model is priced against working sets that do and do not fit in cache:
width layers weight MB predicted measured ratio
1024 6 25.2 460us 551us 1.20
2048 6 100.7 1836us 3711us 2.02
2048 12 201.3 3672us 7625us 2.08
4096 8 536.9 9788us 27413us 2.80
Look at the 25 MB row twice. The batch table measured it at 0.92 and
the footprint table at 1.20 -- the same weights, the same
arithmetic, both sitting AT the bound rather than comfortably above it,
and wandering by 0.28 between two runs in the same process.
A ratio that hovers at or below 1.0 is the signature of a PARTLY cache-
resident working set: the weights are re-read every iteration, some
fraction survives in this machine's last-level cache, and how large that
fraction is depends on what else touched memory first. The model charged
the kernel for 25 MB of DRAM traffic that it only partly paid.
Past 100 MB the ambiguity disappears: 2.02, 2.08, 2.80. The working
set no longer fits, every byte really does come from DRAM, and the
roofline becomes a bound the kernel reaches about half of. The cliff
between those two regimes is the cache, measured without ever naming
its size -- and it is the same cliff as P12's page-fault curve and
P04's Bloom filter, one level up the hierarchy.
Two different bugs produced a sub-1.0 ratio in this file: an fp64
ceiling on an fp32 workload (block 1, a factor of 4) and a byte count
that assumed DRAM for data sitting in cache (above). Both were invisible in the
absolute timings and both were obvious the moment the number was divided
by a bound it could not legally cross. That is what the model is FOR --
not predicting runtime, but making a specific class of mistake loud.
The prediction uses two numbers measured in block 1 and a FLOP count
done on paper. No profiler, no counters. Read the batch table's gap
where it is largest: at batch 256 we reach roughly half of peak, because
a 1024x256 matmul is skinnier than the square one that set the ceiling
and there is a full-size ReLU pass between every layer.
This is the deliverable of hardware-aware ML: not a faster kernel, but
the ability to say IN ADVANCE which optimisations can possibly help.
If a kernel sits at 0.9 of its roofline, rewriting the inner loop is
wasted work and the only remaining moves are algorithmic -- fewer bytes
(quantisation, block 5) or more work per byte (batching, block 6).
Built: measured roofline -> arithmetic intensity -> prediction vs
measurement -> tiling -> quantisation -> batching -> the decode wall.
Missing, on the project page: hardware counters via perf (m3), a real
hand-written GEMM microkernel with register blocking (m5), operator
fusion measured end to end (m7), GPU occupancy and warp scheduling
(m9-m10), and E2 -- the experiment that finds this machine's cache
hierarchy from a latency curve rather than from a spec sheet.
The design space
The roofline is the simplest useful performance model, and its value is that it is falsifiable. Richer models exist and each buys a specific kind of accuracy.
| Model | Inputs | Predicts | Misses |
|---|---|---|---|
| Roofline | peak FLOP/s, peak bandwidth, arithmetic intensity | upper bound on rate | latency, occupancy, which cache level |
| Hierarchical roofline | per-level bandwidths (L1/L2/HBM) | which level binds | instruction mix |
| Cache-aware roofline | working-set-dependent bandwidth | the cache cliff | irregular access |
| ECM (execution–cache–memory) | in-core cycles + transfer cycles | per-loop cycle counts | complex control flow |
| Little's Law | concurrency, latency | required in-flight requests | anything non-steady-state |
Little's Law deserves equal billing: \(L = \lambda W\). To sustain \(\lambda\) requests per second at latency \(W\), you need \(L\) in flight. With DRAM at 121 ns and a need for 50 GB/s at 64 B per line, you need \(50\text{e}9 / 64 \times 121\text{e-}9 \approx 94\) concurrent line fetches. A core with ~10--16 line-fill buffers cannot reach that alone — which is why single-threaded bandwidth is always far below peak, and why the memory-level parallelism argument in P02 matters more than any arithmetic tweak.
Two modelling bugs this page catches
Both produced a ratio below 1.0 — measured faster than the bound — which is physically impossible and therefore diagnostic.
- Wrong ceiling (dtype). fp32 peak is ~4× fp64 peak on this machine (1937 vs 469 GFLOP/s measured), so pricing an fp32 workload against an fp64 roofline reports kernels running at twice the speed of light. There is no single roofline for a machine; there is one per dtype, and the ridge moves with it (9.4 vs 38.8 FLOP/byte here).
- Wrong byte count (cache residency). A 25 MB working set re-read every iteration stays in last-level cache, so the model charges DRAM traffic the kernel never paid. The ratio then wanders across 1.0 from run to run. Past 100 MB the ambiguity disappears and the ratio settles near 2.
The general rule: a sub-1.0 ratio is never a fast kernel, it is a broken model — and the two ways to break it are the ceiling and the byte count.
The hardware, level by level
CPU
| Property | Typical | Consequence |
|---|---|---|
| Cache line | 64 B | a 4-byte strided read wastes 94% of the transfer |
| L1 | 32--48 KB, ~1 ns | tile to fit here for innermost blocks |
| L2 | 0.5--2 MB, ~6 ns | mid-level tile target |
| L3/SLC | 8--96 MB shared, ~20--40 ns | where the 25 MB case above lived |
| DRAM | 121 ns, 50--100 GB/s | the wall |
| SIMD | AVX-512: 16 fp32 lanes; NEON: 4 | peak needs FMA on every port |
| Prefetchers | detect strides, not pointer chases | defeated by numbers.md §14's Sattolo cycle |
GPU
The mental model is occupancy and coalescing, not cores. A warp of 32 threads
issues one memory transaction if their addresses fall in the same lines
(coalesced) and up to 32 if they do not. Shared memory is a programmer-managed
scratchpad (up to 228 KB/SM on H100) that plays the role of L1 tiling in a CPU
GEMM. Tensor cores execute small matrix ops (mma.m16n8k16) and are the only way
to reach quoted peak — a hand-written FMA loop tops out around 5% of it.
TPU
A 128×128 systolic MXU with compiler-scheduled data movement and no cache hierarchy to speak of. Each value read from memory is reused 128 times inside the array — the hardware expression of the reuse argument in proofs.md P15. Consequences: matmul dimensions that are not multiples of 128 waste the array, and the compiler (XLA) must know the shapes statically, which is why dynamic shapes are painful on TPU and fine on GPU.
Comparative ridges
| Device | Peak (dense bf16/fp32) | Bandwidth | Ridge (FLOP/byte) |
|---|---|---|---|
| This machine (fp32) | 1.94 TFLOP/s measured | ~50 GB/s measured | ~39 |
| This machine (fp64) | 0.47 TFLOP/s measured | ~50 GB/s measured | ~9 |
| A100 80GB | ~312 TFLOP/s bf16 | ~2.0 TB/s | ~156 |
| H100 SXM | ~990 TFLOP/s bf16 | ~3.35 TB/s | ~295 |
| TPU v4 | ~275 TFLOP/s bf16 | ~1.2 TB/s | ~229 |
Every accelerator generation has made the memory wall worse, because FLOP/s grows faster than bandwidth. A kernel that was compute-bound on a V100 can be memory-bound on an H100 with no code change — which is the strongest possible argument for re-measuring the model rather than inheriting conclusions.
Optimisation levers, in the order they pay
- Fewer bytes. Quantisation (fp16 → int8 → int4) raises intensity directly. Block 5 measures the honest version: numpy has no int8 GEMM, so what is demonstrated is the memory saving and the accuracy cost, and the speedup is the part that requires writing the kernel.
- More work per byte. Batching (P01, block 6 here) is the only lever that raises decode intensity, and it is why continuous batching, paged attention and speculative decoding all exist.
- Better reuse. Tiling/blocking so the working set fits a cache level. Block 4 measures blocking specifically — how much you lose by cutting a big matmul into small ones — which is the same trade a real GEMM makes one level down with register blocking.
- Fusion. Remove intermediate round trips (P13).
- Only then, the inner loop. If a kernel sits at 0.9 of its roofline, rewriting it is wasted work, and the remaining moves are all algorithmic.
That ordering is the deliverable of hardware-aware ML: not a faster kernel, but the ability to say in advance which optimisations can possibly help.
Advanced topics
- Sparsity: 2:4 structured sparsity is supported in hardware (2× on tensor cores) because unstructured sparsity's irregular access destroys coalescing — a case where the hardware dictates the algorithm's shape.
- Communication-avoiding algorithms: the \(\Omega(n^3/\sqrt{M})\) lower bound on matmul memory traffic (Hong–Kung) is the theoretical floor tiling approaches; 2.5D and CARMA algorithms extend it to distributed memory.
- Arithmetic intensity of the whole pipeline, not one kernel — a fused attention block has very different intensity from its parts, which is exactly FlashAttention's argument.
- Amdahl and Gustafson bound the whole exercise: with 95% of runtime parallelised, the maximum speedup is 20× no matter the machine.
- Energy is increasingly the real constraint: a DRAM access costs ~100× the energy of an fp32 add, so intensity is also an energy metric.
How this connects to the rest of the track
- P01's decode wall is this model applied to a transformer.
- P13 supplies the kernels and the fusion opportunities.
- P02 is the memory-bound extreme — 0.25 FLOP/byte, latency-bound rather than bandwidth-bound.
- P12 is the same hierarchy one level down, where the miss goes to disk instead of DRAM.
- P15 uses this model to put a hardware ceiling beside every measured QPS number.
Failure modes at scale
- Benchmarking the cache rather than the memory system — the second bug
above, and the reason
numbers.md§14 exists. - Peak from a spec sheet instead of a measurement; nobody reaches vendor peak, and the gap is workload-specific.
- Ignoring the dtype, as above.
- Measuring a constant-folded loop: the compiler deletes work that has no
observable effect. This track hit it twice — a timed loop removed entirely, and
a Rust benchmark reporting 0.0000 ms until
black_boxwas added. - Optimising the wrong kernel because the profile was taken at the wrong batch size; intensity, and therefore which wall you hit, changes with batch.
Primary sources
- Williams, Waterman & Patterson, Roofline: An Insightful Visual Performance Model (CACM 2009).
- Hong & Kung, I/O Complexity: The Red-Blue Pebble Game (STOC 1981).
- Little, A Proof for the Queuing Formula \(L = \lambda W\) (1961).
- Goto & van de Geijn, Anatomy of High-Performance Matrix Multiplication (TOMS 2008) — what block 4 is a shadow of.
- Jouppi et al., In-Datacenter Performance Analysis of a Tensor Processing Unit (ISCA 2017).
- Hennessy & Patterson, Computer Architecture: A Quantitative Approach (6th ed.), chapters 2 and 4.
Running it
python3 handson/h14_hardware.py # every block, then the assembly
python3 handson/h14_hardware.py --block 3 # just block 3 and its prerequisites
python3 handson/h14_hardware.py --quiet # the assembly only
What to do with this
Apply it to something you already run. Count the FLOPs and the bytes on paper, measure the two machine constants once, and predict the runtime before you measure it. The value is not the prediction --- it is that a ratio far from 1.0 tells you which of your assumptions is wrong, and it does so before you have spent a week optimising a kernel that was already at its bound.
Milestones, experiments, readings and exit criteria for this project: P14 — Hardware-Aware ML System.
P15 — Integrated Final System
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P15 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Large · 143 hours · Weeks 118–130 · Stage 6 · mixed
The integrated system must not merely connect components. It must answer a specific research or engineering question. Everything on this page is organised around that requirement.
The architecture, the six candidate questions and their full evaluation designs are in Final System. This page is the project specification: schedule, milestones, criteria.
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Choosing the Question
- Architecture
- Showcase — Sum the Latency Budget in Week 119
- Implementation Milestones
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Connections
- References
The Loop, Instantiated
At this scale the loop runs at the level of the question, not the code.
| Step | For this project |
|---|---|
| 1. Problem | One question about how these subsystems interact that cannot be answered by any of them alone |
| 2. Constraints | 13 weeks. Eight existing codebases in four languages. One machine |
| 3. Naive design | Connect everything and see. This is the failure mode, and naming it is the point |
| 4. Predicted failure | Integration surfaces, not components, will consume the time. Predict which interface costs most |
| 5. Minimal implementation | A thin end-to-end path — one article in, one recommendation out — by week 5 |
| 6. Correctness | End-to-end invariants that no single component can check |
| 7. Instrumentation | Distributed tracing: one request, every stage, one timeline |
| 8. Baseline | A degenerate configuration of your own system, plus each component's standalone number |
| 9. Bottleneck | Where does the end-to-end latency budget actually go? It will not be where you expect |
| 10. Hypothesis | The chosen research question, stated falsifiably with its falsifier |
| 11. Modification | The intervention the question is about |
| 12. Experiment | Ablations, baselines, repeated trials, confidence intervals |
| 13. Failure analysis | Under fault injection, end to end |
| 14. Report | A paper. Not a README |
Why This Project Matters
Three reasons, in increasing order of importance.
Integration is a distinct skill. Fourteen projects have each been correct in isolation. Composing them exposes a different class of problem: interface mismatches, backpressure that propagates the wrong way, error semantics that do not compose, latency budgets that were each reasonable and collectively impossible. You cannot practise this on one component.
Cross-cutting questions are where the interesting results live. "How does index staleness affect recommendation quality?" is not answerable inside the index or inside the recommender. It lives at the seam, and seams are under-studied precisely because they require someone to have built both sides.
This is the artifact that represents the journey. Not fifteen repositories — one system, one question, one paper, one demonstration. It is the thing you point at.
Prerequisites
At least eight upstream projects past their exit criteria, and specifically every project the chosen question depends on. Starting P15 with half-built inputs produces a demo rather than a contribution, and it is one of the two illegal reorderings.
Duration and Size
Large, 143 hours, 13 weeks. The budget is deliberately weighted away from building:
| Phase | Weeks | Hours | Content |
|---|---|---|---|
| Question and design | 118–119 | 22 | Lock the question, the falsifier, the metrics, and the ablation design before writing integration code |
| Integration | 120–124 | 55 | End-to-end path, then hardening |
| Experiment | 125–127 | 33 | Baselines, ablations, repeated trials, CIs |
| Writing and demonstration | 128–130 | 33 | The paper, the reproducibility pass, the video |
23% of the budget is writing. That is not padding. A result nobody can read is not a result, and the paper is the deliverable that outlives the code.
Choosing the Question
Lock it in week 118 and write the falsifier down. A question whose answer you cannot imagine being "no" is not a research question.
The six candidates, with what each requires and what makes it good. Full evaluation designs in Final System.
| # | Question | Needs | Why it is good | Risk |
|---|---|---|---|---|
| Q1 | How should a recency-sensitive recommender adapt when user interests drift? | P02,03,08,09,10 | Directly your domain; P09 gives ground-truth drift | Answer may be "use a middling α", which is dull |
| Q2 | Can an adaptive ANN search policy reduce latency while preserving recall? | P02,03,08 | Clean, self-contained, genuinely novel-adjacent, publishable | Adaptive policy may not beat a well-tuned fixed one |
| Q3 | When does dynamic embedding generation beat precomputed embeddings? | P01,13,14,02,03,08 | Uses the most projects; a real production question | Needs careful cost accounting to be fair |
| Q4 | Can simulated users predict the relative performance of ranking algorithms? | P08,09,10 | Highest scientific value; a methodological result | Requires a real-data validation set you may not have |
| Q5 | How do storage and indexing choices affect recommendation freshness? | P03,04,05,07,08 | Uses the systems stack; the seam nobody studies | Needs a defensible freshness metric |
| Q6 | Can hardware-aware batching substantially reduce end-to-end embedding latency? | P01,13,14,07 | Most concrete; the arithmetic is already in roofline.py | Closest to "engineering", least like research |
Recommendation: Q2 or Q5, for a reason that is about you rather than the questions. Both sit at the intersection of retrieval and systems — the seam where your professional experience and this journey's new skills overlap — and neither requires external data. Q4 is the most scientifically interesting and the most likely to end in "I cannot validate this", which is itself a publishable finding but a harder one to sit with.
Whichever you choose, the question must be written as:
Claim: ⟨specific, quantified⟩ Falsifier: ⟨the observation that would make me abandon this⟩ Baseline: ⟨what I am comparing against, and why it is the fair comparison⟩ Ablations: ⟨which components I will remove to attribute the effect⟩
Architecture
The full system. Components you have already built are named with their project.
article source ──► ingestion (P07 streaming: partitions, offsets, watermarks)
│
├──► item store (P04 LSM engine)
├──► interaction log (P07 log / P05 replicated log)
│
▼
embedding service (P01 model on P13 framework,
P14 batching policy)
│
▼
index writer ──► ANN index (P02) inside vector DB (P03)
│ segments · filters · snapshots
▼
request ──► recommendation service (P08) ──► retrieve → filter → rank → diversify
│
├──► simulated users (P09)
├──► A/B assignment (P10)
└──► observability: tracing, metrics, fault injection (P05)
You do not need all of it. The question decides the subset. Q2 needs P02/P03/P08 and a thin harness. Q5 needs the ingestion and storage path and can stub the ranker. Building components the question does not need is the single most common way this project overruns — and is exactly the "connecting things is not a contribution" failure the brief warns about.
Showcase — Sum the Latency Budget in Week 119
Twenty minutes, in milestone 2, before any integration code. The most common way this project overruns is a budget that was never added up.
# P15 -- the latency budget, summed in week 119 rather than discovered in week 128.
budget_ms = 150.0
stages = [("ingest -> visible", "P07", 8.0),
("embed (batched)", "P13/14", 22.0),
("index insert", "P02/03", 5.0),
("retrieve (ANN)", "P02", 12.0),
("filter", "P03", 6.0),
("rank", "P08", 18.0),
("diversify + dedupe", "P08", 9.0),
("serialise + IPC x4", "-", 24.0)]
tot = sum(s[2] for s in stages)
print(f"{'stage':<22}{'from':>8}{'ms':>8}{'% of budget':>13}")
for name, src, ms in stages:
print(f"{name:<22}{src:>8}{ms:>8.1f}{ms/budget_ms*100:>12.1f}%")
print(f"{'TOTAL':<22}{'':>8}{tot:>8.1f}{tot/budget_ms*100:>12.1f}%")
print(f"\\nHeadroom: {budget_ms-tot:.1f} ms ({(budget_ms-tot)/budget_ms*100:.0f}%)")
print("\\nNote the largest single line is not a component -- it is the 4 process")
print("boundaries at 6 ms each. Cross-language integration cost is a line item, and")
print("if you do not budget it in week 119 you discover it in week 128 with no time")
print("to change the architecture.")
stage from ms % of budget
ingest -> visible P07 8.0 5.3%
embed (batched) P13/14 22.0 14.7%
index insert P02/03 5.0 3.3%
retrieve (ANN) P02 12.0 8.0%
filter P03 6.0 4.0%
rank P08 18.0 12.0%
diversify + dedupe P08 9.0 6.0%
serialise + IPC x4 - 24.0 16.0%
TOTAL 104.0 69.3%
\nHeadroom: 46.0 ms (31%)
\nNote the largest single line is not a component -- it is the 4 process
boundaries at 6 ms each. Cross-language integration cost is a line item, and
if you do not budget it in week 119 you discover it in week 128 with no time
to change the architecture.
The largest line is not a component — it is the four process boundaries. Integration cost is a line item, and a budget that omits it is a budget that will be wrong by 16%. If your stages do not sum to something under your target, the architecture is wrong now, while changing it is cheap.
Implementation Milestones
| # | Milestone | Week | Hours | Done when |
|---|---|---|---|---|
| 1 | Question locked: claim, falsifier, baseline, ablations, metrics, power analysis | 118 | 12 | Written, dated, and not revisable without recording why |
| 2 | Integration design: interfaces, data contracts, latency budget per stage | 119 | 10 | A budget that sums to your target — if it does not, the design is wrong now, not later |
| 3 | Thin end-to-end path: one article in, one recommendation out | 120–121 | 22 | Works, is slow, is instrumented |
| 4 | Distributed tracing across every stage | 121 | 8 | One request renders as one timeline |
| 5 | Harden the subsystems the question depends on | 122–123 | 20 | Only those. Resist the others |
| 6 | Fault injection wired end to end (P05's injector) | 124 | 10 | Component failures produce defined system behaviour |
| 7 | Baselines implemented | 125 | 10 | Including the degenerate configuration of your own system |
| 8 | The experiment: ablations, repeated trials, CIs | 125–127 | 23 | Pre-registered design executed without modification |
| 9 | Reproducibility pass | 128 | 8 | A stranger clones and reproduces the headline number |
| 10 | The paper | 128–130 | 17 | Written to templates/report.md's long form |
| 11 | Demonstration video | 130 | 3 | 5–10 minutes, showing the system and the result |
Milestone 3's deadline is real. A thin end-to-end path by the end of week 121 (4 weeks in) is the checkpoint that determines whether the scope is right. If it has not happened, cut components until it does. A system that works end to end at low quality can be improved; a system with three excellent components and no path between them cannot be finished in the remaining time.
Experiments
The specific experiment set depends on the question — see Final System. Every version must include:
| Category | Requirement |
|---|---|
| Baselines | ≥2. One must be a degenerate configuration of your own system (the intervention turned off), so the comparison is not confounded by implementation differences |
| Ablations | Remove each component the claim depends on, separately. If removing a component does not change the result, it is not part of the mechanism and the claim should not mention it |
| Repeated trials | ≥5 seeds per configuration, with bootstrap CIs. A single run is an anecdote |
| Sensitivity | Vary the two parameters most likely to be doing the work. A result that only holds at one setting is a coincidence |
| Failure conditions | The claim under fault injection. Systems papers that only report the happy path are not believed |
| Cost accounting | Latency, memory, storage, and compute for every arm. An improvement that costs 10× the compute is a different claim |
| Negative controls | A configuration where you predict no effect. If it shows one, your harness is measuring itself |
The negative control is the one people skip and the one reviewers ask about first.
Benchmarks and Metrics
| Family | Metrics |
|---|---|
| End-to-end latency | p50/p95/p99, decomposed by stage — the decomposition is the interesting part |
| Freshness | Publish time → first eligible for recommendation. Distribution, not mean |
| Quality | The full P08 suite, including coverage and Gini |
| Throughput | Ingest rate, query rate, embedding rate |
| Resource | CPU, memory, disk, and disk growth over time |
| Reliability | Behaviour and recovery time under each injected fault |
| Cost | Compute per recommendation, storage per article |
| Statistical | Effect size, CI, achieved power for the headline claim |
Latency decomposition is mandatory. A single end-to-end number tells you nothing actionable. The per-stage breakdown is what turns the system into evidence, and it is usually where the surprising result is.
Correctness Tests
- End-to-end invariants no component can check alone: every ingested article eventually becomes recommendable or is explicitly rejected with a reason; no recommendation references a non-existent or deleted item; no article is recommended before its publish time.
- Component contract tests at every interface, running in CI.
- Idempotent ingestion: the same article twice produces one item.
- Cross-component consistency: index contents match the item store after a quiescent period.
- Trace completeness: every request produces a full trace with no missing spans.
- Reproducibility: the same seed and configuration produce the same headline metric within its CI.
- Every upstream component's own test suite still passes, unmodified.
Failure Tests
| Injection | Required behaviour |
|---|---|
| Embedding service down | Ingestion buffers or degrades; defined, not accidental |
| Index unavailable | Recommendation falls back (popularity/recency) with a logged reason |
| Storage full | Clean degradation |
| Ingestion 10× burst | Backpressure; freshness degrades measurably; nothing crashes |
| A stream partition stalls | Watermark handling — the P07 idle-partition case, now end to end |
| Node failure (if distributed) | P05's guarantees hold through the stack |
| Clock skew | No correctness impact |
| Corrupt segment | Detected, isolated, recovered |
| Slow downstream consumer | Bounded memory throughout |
The interesting question is not whether the system survives — it is whether the research claim survives. Run the headline experiment under fault injection and report whether the effect persists. That is what makes it a systems result rather than a benchmark.
Expected Difficulties
- Integration will take longer than any component did, and the time goes to interfaces, not features. That is why milestone 3 has a hard deadline.
- You will want to rewrite components. You will look at P02 from two years ago and want to redo it. Do not. Fix only what the question needs, and note the rest as future work.
- Four languages is real friction. Prefer process boundaries with a simple protocol over FFI. The cost of a subprocess call is a latency-budget line item; the cost of a broken FFI binding is a lost week.
- The question may turn out to be uninteresting once you can measure it. If that happens by week 122, change it — with the change and its reason recorded. After week 124, finish the boring version and say plainly that the result was null. A documented null result is a completed project; an abandoned interesting one is not.
- Scope creep is fatal here because there is no project after this to absorb the slip. The question decides the scope. Write the component list in milestone 2 and treat additions as requiring a written justification.
- Writing 17 hours of paper is harder than it sounds if you start at week 129. Write the methods section during milestone 8, while you are doing the thing it describes.
Scope Boundaries
In scope: exactly the components the question requires, integrated, instrumented, fault-injected, and measured.
Out of scope: components the question does not need; production hardening beyond what the experiment requires; a UI beyond what the demonstration needs; multi-tenancy, auth, deployment automation; rewriting any upstream component; new algorithms unrelated to the question.
Deliverables
- The integrated system — one command to bring it up, one to run the headline experiment
- The paper, 6,000–10,000 words: abstract, introduction, related work, system design, methodology, results, ablations, threats to validity, limitations, future work, reproducibility appendix. Threats to validity and limitations are not optional sections
- A benchmark suite others can run against their own systems
- The demonstration video, 5–10 minutes
- A reproducibility package: data or its generator, configs, seeds, environment specification, expected outputs with tolerances
- An architecture diagram that is accurate, not aspirational
- A postmortem: what you would do differently across all fifteen projects
Exit Criteria
- The system runs end to end, one command
- The research question is answered — including if the answer is "no" or "no measurable effect"
- ≥2 baselines, one of which is a degenerate configuration of your own system
- Ablations attribute the effect to specific components
- ≥5 seeds per configuration with bootstrap CIs on the headline metric
- A negative control was run and showed no effect
- The headline experiment was repeated under fault injection
- End-to-end latency decomposed by stage
- The paper is written, including threats to validity and limitations
- A person who is not you has cloned the repository and reproduced the headline number, and you have their report
- Demonstration video recorded
- Postmortem written
The reproduction-by-a-stranger criterion is the hardest one and the most important. Everything else you can grade yourself on. That one you cannot.
Connections
Backward: eight or more projects, depending on the question.
Forward: Portfolio — publication, and what comes after. If the result holds up, Research Directions lists which venues take work of this shape.
References
Beyond the question-specific literature (see Final System):
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004.
- Zobel, J. Writing for Computer Science, 3rd ed. Springer, 2014.
- Shewchuk, J. R. Three Sins of Authors in Computer Science and Math. 1997.
- Wilson, G. et al. Best Practices for Scientific Computing. PLoS Biology 12(1), 2014.
- Collberg, C., Proebsting, T. A. Repeatability in Computer Systems Research. CACM 59(3), 2016. The study that found most systems papers are not reproducible — read it before writing your reproducibility appendix.
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth: A Pragmatic Guide to Assessing Empirical Evaluations. ACM TOPLAS 38(4), 2016. The best available checklist for a systems evaluation section.
- Hoefler, T., Belli, R. Scientific Benchmarking of Parallel Computing Systems. SC 2015. Twelve rules for reporting performance results; apply all twelve.
P15 hands-on — The integrated system, block by block
Five earlier projects, imported rather than reimplemented, wired into one service.
Source:
handson/h15_integrated.py--- run it withpython3 handson/h15_integrated.py
Full project spec: P15 — Integrated Final System
Nothing on this page is reimplemented. Every mechanism is imported from the hands-on file that built it --- the NSW index from P02, the Bloom-filtered LSM runs from P04, the windowing and watermarks from P07, the assignment and statistics from P10, the measured roofline from P14 --- because importing them is the only honest test of whether they compose.
The first attempt failed with a KeyError. I had assumed the ANN module
exported build_nsw and search_nsw; it exports greedy, graph and entry.
That failure is left in the page deliberately, because it is the normal cost of
integration and it is the thing a curriculum of separate exercises can otherwise
hide from you.
What the assembled system produces is a table that is a product decision: recall against latency against throughput, with a hardware ceiling beside each row saying how much of the machine is being left unused.
Contents
- Block 1 — Load the parts
- Block 2 — A service: ANN retrieval over an LSM store
- Block 3 — Measure the request path
- Block 4 — Capacity planning before deployment
- Block 5 — Ship it behind an experiment
- Block 6 — Watch it in production
- The assembly
- The design space
- Queueing: why utilisation is the hidden variable
- Tail at scale: the arithmetic that makes big systems slow
- Error budgets, and what an SLO actually buys
- Degradation, not failure
- What the integration actually cost
- The three loops
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Load the parts
Teaches: if they cannot be imported, they were never components
The problem. Fourteen files built fourteen mechanisms. The only honest test of whether they are components rather than exercises is to import them — and the first attempt failed.
@block(1, "Load the parts", "if they cannot be imported, they were never components")
def b1(s, show):
here = os.path.dirname(os.path.abspath(__file__))
parts = {}
for name, f in (("ann", "h02_ann.py"), ("lsm", "h04_lsm.py"),
("stream", "h07_streaming.py"), ("ab", "h10_abtest.py"),
("hw", "h14_hardware.py")):
t0 = time.perf_counter()
parts[name] = load(name, os.path.join(here, f))
if show:
print(f" {f:<22}-> {len(parts[name][1]):>2} exports "
f"({time.perf_counter()-t0:>5.2f}s)")
if show:
print(" Each file's blocks were re-run with output suppressed, so what we")
print(" hold now are the actual functions those projects built -- not copies.")
print(" This is the moment a curriculum of exercises becomes a system: the")
print(" interfaces either line up or they do not, and no amount of prose")
print(" about 'composability' substitutes for the import statement.")
return {"parts": parts}
Reading the implementation
load() imports a hands-on file and re-runs its blocks with stdout suppressed,
recovering the state dict each block returned. What comes back are the actual
functions those projects built, not copies.
The harness manipulation is worth reading: _harness._BLOCKS is module-global, so
the loader saves it, clears it, imports the target (whose @block decorators
repopulate it), runs them, and restores. That is a hack, and it is the honest kind
— a real component library would export a module-level API rather than requiring
its blocks to be executed. The awkwardness here is information: these files
were written as demonstrations, and turning a demonstration into a component costs
something.
The first version of this block failed with a KeyError. I assumed the ANN
module exported build_nsw and search_nsw; it exports greedy, graph and
entry. That failure is left on the page because it is the normal cost of
integration and precisely what a curriculum of separate exercises otherwise hides
from you.
What the numbers say
Output:
h02_ann.py -> 15 exports ( 5.93s)
h04_lsm.py -> 11 exports ( 0.01s)
h07_streaming.py -> 10 exports ( 0.02s)
h10_abtest.py -> 6 exports ( 0.00s)
h14_hardware.py -> 6 exports ( 0.36s)
Each file's blocks were re-run with output suppressed, so what we
hold now are the actual functions those projects built -- not copies.
This is the moment a curriculum of exercises becomes a system: the
interfaces either line up or they do not, and no amount of prose
about 'composability' substitutes for the import statement.
Beyond the toy
The general lesson: an interface you have never called is a guess. The industry version of this is the difference between a library with users and a library with one user, and it is why "internal API" and "public API" are different engineering artefacts with different costs.
Note also the load times. h02 takes seconds because it builds an NSW index; the others are milliseconds. A component whose import is expensive changes the architecture of everything that uses it — which is why real systems separate "construct the index" from "load the index" and persist the built artefact (P03).
Block 2 — A service: ANN retrieval over an LSM store
Teaches: two projects, one request path
The problem. Two projects, one request path. The vector index answers which documents; the storage engine answers what they contain. Neither knew the other existed.
@block(2, "A service: ANN retrieval over an LSM store", "two projects, one request path")
def b2(s, show):
ann = s["parts"]["ann"][1]
lsm = s["parts"]["lsm"][1]
n = ann["n"]
# h04's Run is a sorted immutable segment with a Bloom filter. Build one
# per 500 documents, exactly as its own make_db does, but keyed to OUR ids.
runs = [lsm["Run"]([(f"doc{i}".encode(), f"payload for document {i}".encode())
for i in range(base, min(base+500, n))], bpk=10)
for base in range(0, n, 500)]
def query(qv, ef=32, k=5):
ids, ndist = ann["greedy"](qv, ann["graph"], ann["entry"], ef)
out = []
for i in ids[:k]:
v, st = lsm["get"](runs, f"doc{i}".encode())
out.append((i, v, st))
return out, ndist
if show:
hits, ndist = query(ann["q"][0])
print(f" index: {n} vectors of dim {ann['d']}, NSW graph with M={ann['M']}")
print(f" store: {len(runs)} LSM runs of 500 docs, 10-bit Bloom filters")
print(f" one query touched {ndist} vectors, returned {len(hits)} docs, "
f"all found: {all(v is not None for _, v, _ in hits)}")
for i, v, st in hits[:3]:
print(f" doc{i:<6} {v.decode()[:32]:<34} "
f"{st['block_reads']} block read, {st['skipped']} runs skipped")
tot = sum(st["skipped"] for _, _, st in hits)
print(f" Bloom filters skipped {tot} of {len(runs)*len(hits)} possible run")
print(f" probes -- {tot/(len(runs)*len(hits)):.0%} of the store never touched.")
print(" The vector index answers WHICH documents; the LSM answers WHAT they")
print(" contain. Neither project knew the other existed. The first attempt")
print(" at this seam failed on a KeyError -- I assumed the ANN module")
print(" exported build_nsw/search_nsw and it exports greedy/graph/entry.")
print(" That is the normal cost of integration and the reason this block")
print(" exists: an interface you have never called is a guess.")
return {"query": query, "runs": runs}
Reading the implementation
The seam is four lines: search returns ids, ids become keys, keys go to
P04's Bloom-filtered get. The LSM runs are constructed with our
key space rather than the ones make_db generates, which is the small adaptation
integration always requires.
What the Bloom filters buy here is visible in the output: most runs are skipped entirely per lookup, so the store contributes almost nothing to the request's latency. That is P04's measurement showing up as a system property rather than a microbenchmark.
The real incompatibility is stated and not fixed: the index can return an id the store has already compacted away. Neither component prevents it, and neither is wrong — it is a seam defect, which is the characteristic bug class of integration. The fix is version-pinning a snapshot across both components, which requires a concept (a global read timestamp) that neither project has.
What the numbers say
Output:
index: 8000 vectors of dim 48, NSW graph with M=12
store: 16 LSM runs of 500 docs, 10-bit Bloom filters
one query touched 664 vectors, returned 5 docs, all found: True
doc7088 payload for document 7088 1 block read, 1 runs skipped
doc1768 payload for document 1768 2 block read, 11 runs skipped
doc7918 payload for document 7918 1 block read, 0 runs skipped
Bloom filters skipped 38 of 80 possible run
probes -- 48% of the store never touched.
The vector index answers WHICH documents; the LSM answers WHAT they
contain. Neither project knew the other existed. The first attempt
at this seam failed on a KeyError -- I assumed the ANN module
exported build_nsw/search_nsw and it exports greedy/graph/entry.
That is the normal cost of integration and the reason this block
exists: an interface you have never called is a guess.
Beyond the toy
Seam defects are the ones that survive component testing, because each component satisfies its own contract. The standard defences: a shared snapshot/epoch across components, idempotent operations so a stale reference is harmless rather than wrong, and contract tests that exercise the pair rather than each part. The last is the cheapest and the least often done.
Block 3 — Measure the request path
Teaches: a latency budget, decomposed
The problem. A latency budget you can decompose is a latency budget you can act on. This block measures each stage and — more importantly — the tail of the composition.
@block(3, "Measure the request path", "a latency budget, decomposed")
def b3(s, show):
ann = s["parts"]["ann"][1]; lsm = s["parts"]["lsm"][1]; runs = s["runs"]
qs = list(ann["q"])[:120]
if show:
def pct(fn, xs):
ts = []
for x in xs:
t0 = time.perf_counter(); fn(x); ts.append((time.perf_counter()-t0)*1e6)
ts.sort(); return ts[len(ts)//2], ts[int(.99*(len(ts)-1))]
ids = ann["greedy"](qs[0], ann["graph"], ann["entry"], 32)[0][:5]
stages = [("ANN search (ef=32)",
pct(lambda q: ann["greedy"](q, ann["graph"], ann["entry"], 32), qs)),
("LSM fetch x5",
pct(lambda _: [lsm["get"](runs, f"doc{i}".encode()) for i in ids], qs)),
("end to end", pct(lambda q: s["query"](q), qs))]
print(f" {'stage':<26}{'p50':>10}{'p99':>10}{'share of p50':>15}")
total = stages[-1][1][0]
for lbl, (p50, p99) in stages:
print(f" {lbl:<26}{p50:>8.0f}us{p99:>8.0f}us{p50/total:>14.0%}")
print(f" p99/p50 end to end: {stages[-1][1][1]/total:.2f}x")
print(" Search dominates and the store is nearly free -- which is only true")
print(" because the Bloom filters made most runs untouchable. Turn them off")
print(" and the fetch stage grows by the number of runs, which is the")
print(" experiment P04 already ran. Components carry their measurements with")
print(" them; that is what makes a budget like this cheap to build.")
print(" Note the tail: a request is slow if EITHER stage is slow, so the")
print(" composed p99/p50 is worse than either component's own. That is the")
print(" tail-at-scale arithmetic of proofs.md P14 appearing in a two-stage")
print(" pipeline on a single machine -- it does not need a cluster to bite.")
return {}
Reading the implementation
Measure ANN search alone, the LSM fetch alone, and the end-to-end path, reporting p50 and p99 for each.
Search dominates and the store is nearly free, which is only true because the Bloom filters made most runs untouchable. Turn them off and the fetch stage grows by the number of runs — an experiment P04 already ran. Components carry their measurements with them, which is what makes a budget like this cheap to construct.
The tail is the part worth staring at: the composed p99/p50 is worse than either stage's own. A request is slow if either stage is slow, so the probability of avoiding a slow stage is the product of two probabilities. That is Dean & Barroso's tail-at-scale arithmetic appearing in a two-stage pipeline on a single machine — it does not need a cluster to bite.
What the numbers say
Output:
stage p50 p99 share of p50
ANN search (ef=32) 521us 693us 91%
LSM fetch x5 72us 99us 13%
end to end 570us 778us 100%
p99/p50 end to end: 1.37x
Search dominates and the store is nearly free -- which is only true
because the Bloom filters made most runs untouchable. Turn them off
and the fetch stage grows by the number of runs, which is the
experiment P04 already ran. Components carry their measurements with
them; that is what makes a budget like this cheap to build.
Note the tail: a request is slow if EITHER stage is slow, so the
composed p99/p50 is worse than either component's own. That is the
tail-at-scale arithmetic of proofs.md P14 appearing in a two-stage
pipeline on a single machine -- it does not need a cluster to bite.
Beyond the toy
The general form: with fan-out \(n\) over components each having p99 latency \(t\), the probability that no component is slow is \(0.99^n\).
| \(n\) | P(at least one p99) |
|---|---|
| 1 | 1% |
| 10 | 9.6% |
| 100 | 63% |
| 1000 | 99.99% |
At fan-out 100, the median request contains a p99 event. The system's p50 is built from its components' tails, which is why tail latency is a systems property rather than a component one — and why the mitigations (hedged requests, tied requests, micro-partitioning, selective replication) are all about breaking the multiplication rather than making any component faster.
Block 4 — Capacity planning before deployment
Teaches: P14's roofline applied to P02's index
The problem. Capacity planning before deployment, using a model rather than a load test. Two measured machine constants and a FLOP count give a ceiling — and the gap between the ceiling and reality is itself the useful number.
@block(4, "Capacity planning before deployment", "P14's roofline applied to P02's index")
def b4(s, show):
hw = s["parts"]["hw"][1]; ann = s["parts"]["ann"][1]
if show:
d = ann["d"]
print(f" measured: {hw['bw']/1e9:.1f} GB/s, fp64 peak {hw['flops']/1e9:.0f} "
f"GFLOP/s, ridge {hw['ridge']:.1f} FLOP/byte")
print(f" {'strategy':<20}{'vectors read':>14}{'bytes':>10}{'intensity':>11}"
f"{'bound':>9}{'ceiling QPS':>13}")
rows = [("brute force", ann["n"])]
for ef in (8, 32, 64):
nd = ann["greedy"](ann["q"][0], ann["graph"], ann["entry"], ef)[1]
rows.append((f"NSW ef={ef}", nd))
for lbl, nv in rows:
byts = nv * d * 8; flops = 2 * nv * d
ai = flops / byts
qps = min(hw["flops"], ai * hw["bw"]) / flops
print(f" {lbl:<20}{nv:>14,}{byts/1e3:>8.1f}K{ai:>11.2f}"
f"{'MEMORY':>9}{qps:>13,.0f}")
print(f" Intensity is {2/16:.2f} FLOP/byte for every row and cannot be")
print(" otherwise: a dot product does two flops per eight-byte coordinate.")
print(" So the ONLY lever on throughput is touching fewer vectors -- which is")
print(" precisely what the index does, and precisely why quantisation (fewer")
print(" BYTES per vector) is the other half of every production ANN system.")
print(" These ceilings are far above the measured p50 in block 3, and the")
print(" gap is not hardware: it is Python walking a graph one node at a")
print(" time. The roofline prices data movement, so the difference between")
print(" it and reality is exactly the implementation's overhead -- which")
print(" makes it a budget for a rewrite, not a criticism of the design.")
return {}
Reading the implementation
The distance-computation count returned by P02's greedy makes this
exact rather than estimated: bytes = nd × d × 8, FLOPs = 2 × nd × d, so
intensity is \(2/16 = 0.125\) FLOP/byte for every strategy and cannot be
otherwise — a dot product does two flops per eight-byte coordinate.
Every row is memory-bound, which means the only lever on throughput is touching fewer vectors. That is precisely what the index does, and precisely why quantisation (fewer bytes per vector) is the other half of every production ANN system.
The efficiency column — measured QPS against the ceiling — is the honest part. It comes out under 1%, and that is not a criticism of the design: it is Python walking a graph one node at a time. The roofline prices data movement, so the gap between it and reality is exactly the implementation's overhead, which makes it a budget for a rewrite rather than a complaint.
What the numbers say
Output:
measured: 52.6 GB/s, fp64 peak 448 GFLOP/s, ridge 8.5 FLOP/byte
strategy vectors read bytes intensity bound ceiling QPS
brute force 8,000 3072.0K 0.25 MEMORY 17,131
NSW ef=8 368 141.3K 0.25 MEMORY 372,417
NSW ef=32 664 255.0K 0.25 MEMORY 206,400
NSW ef=64 973 373.6K 0.25 MEMORY 140,852
Intensity is 0.12 FLOP/byte for every row and cannot be
otherwise: a dot product does two flops per eight-byte coordinate.
So the ONLY lever on throughput is touching fewer vectors -- which is
precisely what the index does, and precisely why quantisation (fewer
BYTES per vector) is the other half of every production ANN system.
These ceilings are far above the measured p50 in block 3, and the
gap is not hardware: it is Python walking a graph one node at a
time. The roofline prices data movement, so the difference between
it and reality is exactly the implementation's overhead -- which
makes it a budget for a rewrite, not a criticism of the design.
Beyond the toy
Knowing there are ~3 orders of magnitude of headroom before optimising is the whole point of P14. It tells you a C or Rust reimplementation is worth considering and a micro-optimisation of the Python is not. The converse case matters just as much: a kernel already at 0.9 of its roofline cannot be improved by rewriting the inner loop, and the only remaining moves are algorithmic.
Block 5 — Ship it behind an experiment
Teaches: P10 decides whether the change was real
The problem. Offline recall is a hypothesis. This block is what converts an engineering improvement into a claim about users — and the checks run before anyone is allowed to read the metric.
@block(5, "Ship it behind an experiment", "P10 decides whether the change was real")
def b5(s, show):
ab = s["parts"]["ab"][1]
rng = np.random.default_rng(17)
if show:
print(" Proposed change: raise ef from 8 to 32 -- better recall, slower.")
n = 40_000
arm = np.array([ab["assign"](f"u{i}", "ef-32-rollout") for i in range(n)])
chi, p = ab["srm"]([int((arm == 0).sum()), int((arm == 1).sum())])
print(f" assignment {int((arm==0).sum()):,}/{int((arm==1).sum()):,} "
f"SRM chi2={chi:.2f} p={p:.3f} -> {'PASS' if p > 0.001 else 'FAIL'}")
need = ab["n_per_arm"](0.05, 0.03)
ok = (arm == 0).sum() >= need
print(f" to detect a 3% relative lift at 80% power: {need:,} per arm; "
f"we have {int((arm==0).sum()):,} -> {'POWERED' if ok else 'UNDERPOWERED'}")
conv = rng.binomial(1, np.where(arm == 1, 0.05*1.06, 0.05)).astype(float)
a, b = conv[arm == 0], conv[arm == 1]
t, pv = ab["welch"](a, b)
print(f" conversion A={a.mean():.4f} B={b.mean():.4f} "
f"lift={(b.mean()-a.mean())/a.mean():+.2%} p={pv:.4f} -> "
f"{'SHIP' if pv < 0.05 and b.mean() > a.mean() else 'DO NOT SHIP'}")
print(" Recall improved in an offline benchmark; that is not a reason to")
print(" ship. The A/B platform converts an engineering improvement into a")
print(" claim about users, and the SRM and power checks run BEFORE anyone is")
print(" allowed to read the conversion number.")
return {}
Reading the implementation
The order is the content: hash assignment, then SRM, then power, and only then the conversion number. Each check can veto the reading of the next.
- SRM first, because if the arms are not comparable populations nothing downstream means anything (P10).
- Power second, because an underpowered test that reaches significance overstates the effect (P10) — so knowing the power changes how you read the result, not just whether you run it.
- One primary metric, decided in advance.
What the numbers say
Output:
Proposed change: raise ef from 8 to 32 -- better recall, slower.
assignment 19,979/20,021 SRM chi2=0.04 p=0.978 -> PASS
to detect a 3% relative lift at 80% power: 331,398 per arm; we have 19,979 -> UNDERPOWERED
conversion A=0.0484 B=0.0526 lift=+8.77% p=0.0527 -> DO NOT SHIP
Recall improved in an offline benchmark; that is not a reason to
ship. The A/B platform converts an engineering improvement into a
claim about users, and the SRM and power checks run BEFORE anyone is
allowed to read the conversion number.
Beyond the toy
The thing worth internalising: recall going up in an offline benchmark is not a reason to ship. The offline metric is computed on synthetic queries with a synthetic notion of relevance, against logged data collected under the old policy — which systematically favours models that agree with the old policy (P08's offline/online gap, P09's feedback loop).
The platform's value is not the t-test. It is that this sequence is enforced by software rather than remembered by people under launch pressure.
Block 6 — Watch it in production
Teaches: P07 turns the request log into a live metric
The problem. A metric with a stated emit delay and a measured bias is a monitor. A metric without them is a number on a dashboard.
@block(6, "Watch it in production", "P07 turns the request log into a live metric")
def b6(s, show):
st = s["parts"]["stream"][1]
rng = np.random.default_rng(18)
if show:
events = []
for _ in range(6000):
et = rng.uniform(0, 300_000)
delay = (rng.exponential(1500) if rng.random() > .06
else rng.uniform(0, 60_000))
events.append((et, et + delay, "err" if rng.random() < .02 else "ok"))
events.sort(key=lambda e: e[1])
W = st["W"]
truth = {}
for et, _, k in events:
truth[(int(et // W), k)] = truth.get((int(et // W), k), 0) + 1
terr = sum(v for (_, k), v in truth.items() if k == "err")
ttot = sum(truth.values())
print(f" {len(events)} request events, {W//1000}s tumbling windows, "
f"6% arrive late")
print(f" true error rate = {terr/ttot:.4%}")
print(f" {'policy':<24}{'emit delay':>12}{'measured rate':>15}"
f"{'error':>10}{'events lost':>13}")
for lbl, lag, grace in (("live dashboard", 2_000, 0),
("alerting", 10_000, 30_000),
("weekly report", 10_000, 90_000)):
got, dropped, _ = st["run"](events, st["fixed"](lag), W, grace)
errs = sum(v for (_, k), v in got.items() if k == "err")
tot = max(sum(got.values()), 1)
print(f" {lbl:<24}{(lag+grace)/1000:>10.0f}s{errs/tot:>15.4%}"
f"{errs/tot - terr/ttot:>+10.4%}{ttot-tot:>13,}")
print(" The dashboard drops a couple of hundred events at a 2-second emit")
print(" delay and still lands within 0.04pp of the true rate -- because")
print(" lateness here is very nearly independent of whether a request")
print(" failed, so it loses from numerator and denominator alike. That is a")
print(" property of THIS workload, not a general licence: correlate the")
print(" delay with the metric (slow requests are the failing ones) and the")
print(" same pipeline becomes systematically optimistic. Measure the bias")
print(" before trusting a fast estimate, then set the threshold against it.")
return {}
Reading the implementation
Three watermark policies over the same request log, reporting the measured error rate against the true one and the events lost.
The result is benign — a 2-second dashboard lands within 0.04 pp of truth while dropping a couple of hundred events — and the reason is stated explicitly: lateness here is very nearly independent of whether a request failed, so the pipeline loses from numerator and denominator alike and the ratio survives.
That is a property of this workload, not a general licence. Correlate the delay with the metric — slow requests are the failing ones, which is the normal case in an incident — and the same pipeline becomes systematically optimistic. Biased in the direction that hides incidents, at exactly the moment you need it most.
What the numbers say
Output:
6000 request events, 60s tumbling windows, 6% arrive late
true error rate = 2.3167%
policy emit delay measured rate error events lost
live dashboard 2s 2.2774% -0.0392% 204
alerting 40s 2.2723% -0.0443% 15
weekly report 100s 2.3167% +0.0000% 0
The dashboard drops a couple of hundred events at a 2-second emit
delay and still lands within 0.04pp of the true rate -- because
lateness here is very nearly independent of whether a request
failed, so it loses from numerator and denominator alike. That is a
property of THIS workload, not a general licence: correlate the
delay with the metric (slow requests are the failing ones) and the
same pipeline becomes systematically optimistic. Measure the bias
before trusting a fast estimate, then set the threshold against it.
Beyond the toy
The discipline: measure the bias before trusting a fast estimate, then set the alert threshold against the biased estimator rather than against truth. A biased estimator with a known bias is usable; an estimator whose bias nobody measured is not, regardless of how small the bias happens to be.
This is also why the three policies should coexist rather than compete. The dashboard is for humans watching in real time, the alerting path trades latency for accuracy at a chosen point, and the reconciliation path is exact and slow. Shipping one of them and calling it "the metric" is what produces the incident where the dashboard and the invoice disagree.
Assembly note
The table in the assembly is a product decision, not a benchmark: each row of
ef is a different service, with recall, latency, throughput and a hardware
ceiling side by side.
And the closing structure is the actual deliverable of the final project — three loops closed around one system:
- Offline hypothesis — recall, latency, capacity on held-out data. Cheap, fast, systematically optimistic.
- Online experiment (P10) — the only instrument that measures users.
- Production monitor (P07) — windowed metrics with a stated emit delay and a measured bias.
A system with only the first is a benchmark. With the first two, it is a product change. With all three, it is an engineered system — and that distinction is the thing the whole track exists to teach.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nSix blocks, five imported projects, one system. The whole path:\n")
ann = s["parts"]["ann"][1]; hw = s["parts"]["hw"][1]
d = ann["d"]
qs = list(ann["q"]); truth = list(ann["truth"])
print(f" {'ef':>5}{'recall@10':>12}{'vectors read':>14}{'p50':>10}"
f"{'QPS':>9}{'ceiling QPS':>13}{'efficiency':>12}")
for ef in (8, 16, 32, 64):
recs, ts, nds = [], [], []
for q, tr in zip(qs, truth):
t0 = time.perf_counter()
got, nd = ann["greedy"](q, ann["graph"], ann["entry"], ef)
ts.append(time.perf_counter() - t0)
recs.append(ann["recall_at_k"](got, tr, 10)); nds.append(nd)
ts.sort(); p50 = ts[len(ts)//2]; nd = sum(nds)/len(nds)
flops = 2 * nd * d
ceil = min(hw["flops"], (flops/(nd*d*8)) * hw["bw"]) / flops
print(f" {ef:>5}{sum(recs)/len(recs):>12.4f}{nd:>14,.0f}{p50*1e6:>8.0f}us"
f"{1/p50:>9,.0f}{ceil:>13,.0f}{1/p50/ceil:>11.3%}")
print("\n Read that as a product decision, because that is what it is. Each row")
print(" is a different service: ef=8 is fast and wrong, ef=64 is accurate and")
print(" slow, and recall is bought at a steeply rising price in vectors read.")
print(" The efficiency column says every row leaves over 99% of the hardware")
print(" unused -- this is Python chasing pointers through a graph. That is not")
print(" a criticism of the index; it is a measured budget for a rewrite, and it")
print(" says a C implementation has roughly three orders of magnitude of")
print(" headroom before the memory system becomes the constraint.")
print("\n And then the honest part. Choosing a row from this table is an offline")
print(" decision made on synthetic queries with a synthetic notion of relevance.")
print(" Block 5 is what makes it real: hash assignment, SRM, power, and a")
print(" conversion metric that decides. Block 6 is what keeps it real: a")
print(" windowed error rate with a stated emit delay and a measured bias.")
print(" Offline recall is a hypothesis, the experiment is the test, the stream")
print(" is the monitor. A system is not the sum of its components; it is those")
print(" three loops closed around them.")
print("\n What this file proves and the other fourteen cannot: the parts IMPORT.")
print(" Every function used here was written for a different project with no")
print(" knowledge of this one. The integration cost was a few lines per seam")
print(" and one real failure -- a KeyError from assuming an API that did not")
print(" exist. Days of component work, an hour of integration, one genuine")
print(" interface bug. That ratio is the thing to expect and to budget for.")
print("\n Built from: P02 (NSW index, greedy search, recall@k), P04 (LSM runs,")
print(" Bloom-filtered get), P07 (windowing, watermarks, late data), P10 (hash")
print(" assignment, SRM, power, Welch), P14 (measured roofline). Missing, on the")
print(" project page: a serving layer with real concurrency (m3-m4), the")
print(" distributed control plane from P05 (m6), P06-style batch reindexing")
print(" (m8), and the capstone report that puts one end-to-end number against a")
print(" stated SLO.")
Output:
Six blocks, five imported projects, one system. The whole path:
ef recall@10 vectors read p50 QPS ceiling QPS efficiency
8 0.2938 234 190us 5,270 584,900 0.901%
16 0.4219 365 307us 3,259 375,961 0.867%
32 0.5969 592 501us 1,996 231,484 0.862%
64 0.8078 989 873us 1,145 138,539 0.827%
Read that as a product decision, because that is what it is. Each row
is a different service: ef=8 is fast and wrong, ef=64 is accurate and
slow, and recall is bought at a steeply rising price in vectors read.
The efficiency column says every row leaves over 99% of the hardware
unused -- this is Python chasing pointers through a graph. That is not
a criticism of the index; it is a measured budget for a rewrite, and it
says a C implementation has roughly three orders of magnitude of
headroom before the memory system becomes the constraint.
And then the honest part. Choosing a row from this table is an offline
decision made on synthetic queries with a synthetic notion of relevance.
Block 5 is what makes it real: hash assignment, SRM, power, and a
conversion metric that decides. Block 6 is what keeps it real: a
windowed error rate with a stated emit delay and a measured bias.
Offline recall is a hypothesis, the experiment is the test, the stream
is the monitor. A system is not the sum of its components; it is those
three loops closed around them.
What this file proves and the other fourteen cannot: the parts IMPORT.
Every function used here was written for a different project with no
knowledge of this one. The integration cost was a few lines per seam
and one real failure -- a KeyError from assuming an API that did not
exist. Days of component work, an hour of integration, one genuine
interface bug. That ratio is the thing to expect and to budget for.
Built from: P02 (NSW index, greedy search, recall@k), P04 (LSM runs,
Bloom-filtered get), P07 (windowing, watermarks, late data), P10 (hash
assignment, SRM, power, Welch), P14 (measured roofline). Missing, on the
project page: a serving layer with real concurrency (m3-m4), the
distributed control plane from P05 (m6), P06-style batch reindexing
(m8), and the capstone report that puts one end-to-end number against a
stated SLO.
The design space
An integrated system is a set of budgets, not a set of components. The design question is where each budget is spent and what happens when one is exceeded.
| Budget | Set by | Enforced by | Failure when exceeded |
|---|---|---|---|
| Latency (p99) | product requirement | timeouts, hedging, load shedding | user-visible slowness, then cascading retries |
| Correctness | domain (billing vs dashboard) | P07's watermark + grace | silently wrong numbers |
| Capacity | hardware ceiling (P14) | admission control, autoscaling | queueing collapse |
| Freshness | staleness tolerance | index rebuild cadence | stale results that look correct |
| Cost | budget | index size, replica count, precision | none — it just gets expensive |
The assembly's table is exactly this: recall against latency against throughput,
with a hardware ceiling beside each row. Choosing ef is choosing a point in a
five-dimensional budget space, and the value of building the whole system is that
the trade becomes visible instead of implicit.
Queueing: why utilisation is the hidden variable
The single most important fact about a serving system is that latency is not linear in load. For an M/M/1 queue at utilisation \(\rho\), the mean response time is
\[ W = \frac{S}{1-\rho} \]
so at 50% utilisation latency is 2× service time, at 90% it is 10×, at 99% it is 100×. The knee is not a gradual curve; it is a wall.
| Utilisation | Latency multiplier | Practical reading |
|---|---|---|
| 30% | 1.4× | wasteful but safe |
| 50% | 2× | typical target for latency-sensitive services |
| 70% | 3.3× | typical target for throughput services |
| 90% | 10× | only for batch |
| 95%+ | 20×+ | any perturbation cascades |
This is why services are provisioned at 40--60% and why "the CPU is only 60% busy" is not evidence of headroom. Combine with Little's Law (\(L = \lambda W\)) and you get the capacity model: to serve 10k QPS at 20 ms you need 200 requests in flight, which sets thread pools, connection counts and batch sizes.
Tail at scale: the arithmetic that makes big systems slow
If a request fans out to \(n\) services each with independent p99 latency \(t\), the probability that no component is slow is \(0.99^n\):
| Fan-out \(n\) | P(at least one p99) | Effective percentile of the slowest |
|---|---|---|
| 1 | 1% | p99 |
| 10 | 9.6% | ~p90 |
| 100 | 63% | ~p37 |
| 1000 | 99.99% | essentially always |
At fan-out 100, the median request contains a p99 event. The system's p50 is built from its components' tails. Block 3 measures the two-stage version of this on one machine: composed p99/p50 is worse than either stage's own, because a request is slow if either stage is slow.
The mitigations from Dean & Barroso are all about breaking that multiplication:
- Hedged requests — send to a second replica after p95 elapses, take the first response. Costs ~5% extra load, cuts the tail dramatically.
- Tied requests — send to two, each cancels the other on start.
- Micro-partitioning — many more shards than machines, so load balances and a hot shard can be migrated.
- Selective replication for hot partitions.
- Latency-induced probation — remove a slow replica from rotation.
Error budgets, and what an SLO actually buys
An SLO of 99.9% availability over 30 days is 43 minutes of error budget. That number is the permission to take risk: if the budget is unspent, ship faster; if it is exhausted, freeze. It converts an argument about caution into arithmetic.
The corollary that matters for this system: a dependency's SLO caps yours. Three sequential dependencies at 99.9% give 99.7%. Availability composes multiplicatively down a call chain and additively in redundancy, which is the whole design pressure toward fewer, wider services and graceful degradation (serve stale, serve popular, serve fewer results) rather than failure.
Degradation, not failure
The design skill an integrated system teaches is what to do when a budget is blown, and the answer is never "return a 500":
| Pressure | Graceful response |
|---|---|
| Index too slow | drop ef, serve lower recall |
| Store unavailable | serve IDs from cache with stale metadata |
| Overload | shed load at admission, prioritise by tier |
| Downstream timeout | serve popularity fallback (P08 block 3) |
| Stream lagging | widen the watermark, mark metrics as provisional |
Load shedding must happen at the edge and early: a request that is going to time out anyway consumes capacity all the way down. This is also why retries need budgets and jitter — naive retry storms are the classic mechanism by which a recoverable blip becomes an outage.
What the integration actually cost
The first run failed with a KeyError: the ANN module exports greedy, graph
and entry, not the build_nsw/search_nsw I assumed. That is left on the page
because it is the normal cost of integration and the thing a curriculum of
separate exercises otherwise hides. The realistic ratio, visible here: days of
component work, an hour of integration, one genuine interface bug.
Three seams in this system that are real and under-tested:
- The index can return an id the store has already compacted away. Neither component prevents it; the system must, by version-pinning a snapshot across both.
- The efficiency column says the implementation leaves >99% of the hardware unused. That is not a criticism, it is a measured budget for a rewrite — and knowing it before optimising is the point of P14.
- The stream monitor's bias is workload-dependent. Block 6's benign result (a 2 s dashboard within 0.04 pp of truth) holds only because lateness is independent of failure. Correlate them and the same pipeline becomes systematically optimistic — biased in the direction that hides incidents.
The three loops
The deliverable of the final project is not the service; it is three loops closed around it:
- Offline hypothesis — recall, latency, capacity measured on held-out data. Cheap, fast, and systematically optimistic (P08's offline/online gap).
- Online experiment — P10: hash assignment, SRM, power, one primary metric. The only instrument that measures users.
- Production monitor — P07: windowed metrics with a stated emit delay and a measured bias, so a regression is detected rather than inferred.
A system with only the first is a benchmark. With the first two, it is a product change. With all three, it is an engineered system — and that is the distinction the whole track exists to teach.
How this connects to the rest of the track
Every project, by construction: P02 the index, P04 the store, P07 the monitor, P10 the experiment, P14 the ceiling. The ones not imported here are the ones the project page lists as missing: P05 for the control plane, P06 for batch reindexing, P12 underneath all of it.
Failure modes at scale
- Cascading failure through retry amplification: one slow dependency, every caller retries 3×, load triples, everything becomes slow.
- Metastable failure — the system stays broken after the trigger is removed, because the retry queue is self-sustaining. Requires a deliberate reset (shed everything, drain, restore).
- Capacity measured at the wrong percentile: sizing on mean CPU hides that p99 latency exploded at 70% utilisation.
- Snapshot skew between components — index version 41 with store version 40.
- Monitoring that shares a failure domain with the system it monitors.
Primary sources
- Dean & Barroso, The Tail at Scale (CACM 2013).
- Beyer et al., Site Reliability Engineering (2016) — error budgets, and the chapter on cascading failures.
- Bronson, Aghayev, Charapko & Zhu, Metastable Failures in Distributed Systems (HotOS 2021).
- Little, A Proof for the Queuing Formula (1961); Gunther, Guerrilla Capacity Planning for the practical version.
- Barroso, Hölzle & Ranganathan, The Datacenter as a Computer (3rd ed.).
- Brooker, Timeouts, Retries and Backoff with Jitter (AWS Builders' Library).
Running it
python3 handson/h15_integrated.py # every block, then the assembly
python3 handson/h15_integrated.py --block 3 # just block 3 and its prerequisites
python3 handson/h15_integrated.py --quiet # the assembly only
What to do with this
Close the last loop. The service currently chooses ef offline; make block 5's
experiment choose it, and block 6's stream monitor detect when the choice stops
working. At that point the three loops --- offline hypothesis, online experiment,
production monitor --- are closed around the same system, which is the actual
deliverable of the final project and the thing that distinguishes a portfolio of
exercises from an engineered system.
Milestones, experiments, readings and exit criteria for this project: P15 — Integrated Final System.
Calibration — Before Week One
A 3-hour battery, taken cold, that sizes the first three projects to you rather than to my guess about you.
Without it, P01's eight weeks assume a starting point I invented. If attention is already second nature, weeks 1–4 are waste. If Rust ownership defeats you in P11-I, the whole Stage-2 schedule is wrong and you discover it in month five.
This is not a test you can fail. Every outcome maps to a schedule, and the "you already know this" outcomes save you more time than the "you need longer" outcomes cost.
Table of Contents
- Rules
- The Five Tasks
- C1 — Attention, From the Description
- C2 — Nearest Neighbour, Naive to Better
- C3 — Systems Arithmetic
- C4 — Ownership and Lifetime
- C5 — Measurement Judgement
- Scoring
- What Your Scores Change
- Re-Calibration
- References
Rules
| Time | 3 hours 10 minutes, timed per task. Stop when the timer stops, mid-line if necessary |
| Tools | Language reference and standard-library docs only. No search, no assistant, no papers, no prior code of yours |
| Cold | Do not read the project pages first. If you have already read P01, note that and discount C1 accordingly |
| Honesty | Nobody sees this. A contaminated calibration produces a plan for a person who does not exist — which is the exact failure the sibling track's locked PLAN.md exists to prevent |
| Record | Write answers in notebook/000-calibration.md as you go, including where you got stuck and what you tried |
Take it in one sitting. Split across days it measures your best day rather than your working level.
The Five Tasks
| # | Task | Minutes | Sizes | Measures |
|---|---|---|---|---|
| C1 | Attention, from the description | 45 | P01 | Can you turn a specification into correct tensor code? |
| C2 | Nearest neighbour, naive to better | 40 | P02 | Algorithmic instinct + do you reach for measurement? |
| C3 | Systems arithmetic | 25 | P04, P05, P14 | Back-of-envelope fluency — the skill the whole track rests on |
| C4 | Ownership and lifetime | 40 | P11-I, P04, P12 | Rust readiness, which is the journey's one real language cost |
| C5 | Measurement judgement | 30 | Everything | Can you spot a lying benchmark? |
| Scoring and mapping | 20 |
C1 — Attention, From the Description
45 minutes. No reference to any Transformer material.
Here is a specification with no code and no formula. Implement it.
You have \(T\) vectors \(x_1 \dots x_T\), each of dimension \(d\), as a matrix
Xof shape(T, d).Produce an output
Yof the same shape, where each output row \(y_i\) is a weighted average of transformed input rows \(1 \dots i\) — never rows after \(i\).The weights must depend on the content of the rows, not their positions: row \(i\) decides how much attention to pay to row \(j\) by comparing a vector derived from \(x_i\) against a vector derived from \(x_j\). The weights over \(j\) must be non-negative and sum to 1.
The number of learnable parameters must not depend on \(T\).
Deliverables, in this order:
- The mechanism in words, before any code. What are the learnable parameters and what are their shapes?
- A numpy implementation,
attention(X, Wq, Wk, Wv) -> Y. - A test that the weights sum to 1 for every row.
- A test that row \(i\) is unaffected by any change to rows \(> i\).
- Its computational cost in FLOPs, as a function of \(T\) and \(d\).
Self-score after time is up:
| 1 | Could not produce a mechanism that satisfies the constraints |
| 2 | Mechanism roughly right; code incomplete or the masking is wrong |
| 3 | Working implementation, both tests pass |
| 4 | + correct FLOP count, and you scaled the scores by something (even if you were unsure why) |
| 5 | + you can state why the scaling is needed in terms of the variance of a dot product, and you noticed the parameter count is independent of \(T\) by construction |
What separates 3 from 5 is not implementation skill; it is whether the arbitrary constants prompt a question. That is exactly the habit P01 exists to build, so a 3 here is a good reason to do P01 in full.
C2 — Nearest Neighbour, Naive to Better
40 minutes.
You have 100,000 vectors of dimension 128 in memory, and a stream of query vectors. For each query, return the 10 most similar by cosine similarity.
- Write the exact solution. (10 min)
- Estimate its per-query cost — arithmetic operations and bytes moved — before running it. Write the estimate down, then measure. (10 min)
- Without looking anything up, propose two ways to make it sub-linear, and for each state precisely what you give up. (15 min)
- For one of them, state the experiment that would tell you whether it works, and what result would make you abandon it. (5 min)
Self-score:
| 1 | Exact solution only; no cost estimate |
| 2 | Exact solution + a cost estimate that was off by >10× |
| 3 | Cost estimate within ~3×; one plausible sub-linear idea |
| 4 | + two ideas with the trade-off named for each (recall, memory, build time) |
| 5 | + a falsifiable experiment with a stated falsifier, and you noticed that normalising makes cosine, dot product and L2 give identical rankings |
Step 2 is the one that matters. The habit of estimating before measuring is what the whole notebook is built around, and it is independent of whether you have seen ANN search before.
C3 — Systems Arithmetic
25 minutes. Pen and paper. No calculator beyond arithmetic.
Eight questions. Order-of-magnitude answers are fine; state your assumptions, which matter more than the number.
- A service handles 10,000 requests/second at 50 ms mean latency. How many requests are in flight on average?
- Your process reads 4 KB from a file 100,000 times. The data is in the OS page cache. Roughly how long does the syscall overhead alone take?
- A 7-billion-parameter model in bf16 generates one token. How many bytes must move from memory, at minimum? At 2 TB/s, how long is that?
- A key-value store does one
fsyncper write. What write rate can it sustain, to within an order of magnitude? - An LSM tree with fanout 10 holds 64 GB with a 64 MB first level. Roughly how many levels, and how many times is a byte rewritten under leveled compaction?
- A request fans out to 50 services, each exceeding its p99 latency 1% of the time. What fraction of requests are slow?
- A 512×512 fp32 matrix multiply. How many FLOPs? At 20 GFLOP/s, how long?
- You want to detect a 1% relative change in a metric with standard deviation equal to its mean. Order of magnitude of users per arm?
Answers with the reasoning are in the numbers reference — but score yourself before looking:
Expected answers (open only after you have written yours)
- 500. Little's Law: \(L = \lambda W = 10^4 \times 0.05\).
- ~13 ms. 100,000 × ~128 ns. Note the data is nearly free from cache; the boundary crossing is the cost.
- 14 GB (7e9 × 2 bytes), ~7 ms. Every weight is read once per token.
- ~10⁴ writes/s. fsync is ~100 µs. This is the ceiling regardless of everything else.
- 3 levels beyond the base (\(\log_{10}(64\text{GB}/64\text{MB}) = 3\)); write amplification ≈ \(10 \times 3 + 1 \approx 31\).
- ~39%. \(1 - 0.99^{50}\).
- 268 MFLOP (\(2N^3\)); ~13 ms.
- ~10⁵ per arm. \(n \approx 16\sigma^2/\delta^2\) with \(\sigma/\mu = 1\) and \(\delta/\mu = 0.01\) gives \(16/10^{-4} = 1.6\times10^5\).
Self-score: 1 = 0–2 correct to an order of magnitude · 3 = 4–5 correct · 5 = 7–8 correct with assumptions stated.
This is the highest-signal task in the battery. Every project in the track opens with a prediction, and back-of-envelope fluency is what makes predictions worth writing down.
C4 — Ownership and Lifetime
40 minutes. Rust if you have it installed; otherwise answer in prose and C.
Implement a scope chain: an
Environmentwith a map from name to value and an optional parent. Supportget(searching up the chain) anddefine.Then: a
Closureholds a function body and a reference to the environment where it was created. Two closures created in the same scope must see each other's writes to that scope.
- Write the type definitions. (15 min)
- Implement
getanddefine. (15 min)- Answer in writing: what keeps the environment alive after the function that created it returns? What happens if two closures both mutate the same variable? (10 min)
Self-score:
| 1 | Could not express the shared-mutable-parent structure at all |
| 2 | Wrote it in a GC language and could not say what the Rust/C version needs |
| 3 | Correct structure; needed to look up the exact smart-pointer syntax |
| 4 | + can explain why Rc<RefCell<>> (or manual refcounting in C) is required — shared ownership plus interior mutability |
| 5 | + can state what a cycle would do to it, and that this is why tracing GC exists |
A 1 or 2 is the single most schedule-relevant result in the battery, because Rust is the journey's one real language cost and P11-I is where it is paid.
C5 — Measurement Judgement
30 minutes. Five benchmarks. For each: what is wrong, and what would you measure instead? Roughly 6 minutes each.
B1. To measure syscall cost:
t0 = now(); for (i=0;i<1e7;i++) getpid(); t1 = now();
printf("syscall: %.2f ns\n", (t1-t0)/1e7*1e9); // prints 1.23 ns
B2. To measure DRAM latency, a pointer chase where each pointer points 64 bytes ahead, wrapping at the end of a 512 MB buffer. Reports 1.3 ns per load.
B3. To compare two sorting functions:
t0=time.time(); sort_a(data); t1=time.time()
t2=time.time(); sort_b(data); t3=time.time()
print(f"b is {(t1-t0)/(t3-t2):.2f}x faster") # prints 1.04x faster
B4. A load generator sends a request, waits for the response, then sends the next. Under a 1-second server stall it reports p99 = 12 ms.
B5. A Bloom filter at 24 bits/key is tested with 200,000 absent keys. Zero false positives observed. Reported as "false-positive rate: 0%, better than the predicted 1e-5".
What each one is (open after answering)
- B1 —
getpid()is cached by libc; this is not a syscall. It is four times an empty loop iteration, which is impossible for a mode switch. Use a syscall that must trap and fails fast, e.g.close(-1). - B2 — a constant 64-byte stride is exactly sequential cache lines and the prefetcher hides the whole latency. Use a random single cycle (Sattolo).
- B3 — one run each, no warmup, no repetition, no uncertainty. A 4% difference from two samples is noise. Repeat, report p50/p95/p99, bootstrap the median, and be willing to say "no measurable difference".
- B4 — coordinated omission. The generator stopped sending during the stall, so it never recorded the latencies its own stall caused. Send on a schedule and measure from intended send time.
- B5 — no resolution. At 1e-5, 200k probes expect ~2 events; observing 0 is Poisson noise, not a result. You need ~100/p ≈ 10 million probes.
Self-score: 1 = spotted 0–1 · 3 = spotted 3 · 5 = spotted all five and named the correct replacement measurement for each.
Every one of these is a mistake made and documented during the construction of this track (§14). Spotting them cold is genuinely hard, and a low score here is the least worrying of the five — it is the most teachable.
Scoring
Record in notebook/000-calibration.md:
C1 attention _/5
C2 nearest neighbour _/5
C3 systems arithmetic _/5 <- highest signal
C4 ownership _/5 <- most schedule-relevant
C5 measurement _/5
___
total _/25
Then run:
cd tools && python3 calibrate.py C1 C2 C3 C4 C5 # e.g. python3 calibrate.py 3 4 2 1 3
It prints the adjusted schedule and the total delta in weeks.
What Your Scores Change
| Task | Score | Adjustment |
|---|---|---|
| C1 | 1–2 | P01 as written, 8 weeks. Add the extra reading in week 2 |
| 3 | P01 as written, 8 weeks | |
| 4 | P01 −1 week: compress milestones 3–4 (single head + masking) into one | |
| 5 | P01 −2 weeks: start at milestone 5; keep every experiment. Never skip the experiments — they are the project, the implementation is the substrate | |
| C2 | 1–2 | P02 as written, 7 weeks. Do math.md §concentration before week 1, not week 9 |
| 3–4 | P02 as written, 7 weeks | |
| 5 | P02 −1 week: fold milestones 3–4 together | |
| C3 | 1–2 | +1 week before Stage 1: work through numbers.md §§1–5 and 9–11, redoing every derivation on paper. This is the highest-return week in the whole plan for you |
| 3 | Re-take C3 at the M7 stage review | |
| 4–5 | No change. You already have the skill the track is trying to build | |
| C4 | 1–2 | P11-I +2 weeks (5→7). Do the Rust book chapters 4, 10, 15 before the project rather than during. Consider moving P11-I earlier still, to week 9, so Rust has longer to settle before P04 |
| 3 | P11-I as written, 5 weeks | |
| 4–5 | P11-I −1 week. Consider C for P12 instead of Rust, since Rust buys you less | |
| C5 | 1–2 | Read numbers.md §14 and the bench.py preamble in week 1, and re-take C5 at M7 |
| 3–4 | No change | |
| 5 | No change. Consider a higher bar on your own benchmark-quality scores |
Totals
| Total | Reading |
|---|---|
| 5–10 | The 34-month plan is right, and the extra weeks above are well spent. Do not compress anything |
| 11–17 | The plan as written fits you. Apply only the per-task adjustments |
| 18–22 | You are ahead. Take the per-task reductions, and raise your scorecard bar — a 3 for you should be someone else's 4 |
| 23–25 | Stage 1 is largely revision. Consider: P01 and P02 at MVI scope with the full experiment suite, then jump to Stage 2 and reinvest the ~6 saved weeks in P05 or P15 — the two projects most likely to overrun |
A high score does not mean skip the experiments. Every reduction above cuts implementation weeks. The experiments, the notebook entries and the reports are the part that builds the capability you asked for, and they are never the thing to cut.
Re-Calibration
Re-take C3 and C5 at each stage review — M7, M15, M22, M26, M31. Both are 25–30 minutes and both measure skills the track claims to build, so a flat score across two stages is evidence the program is not working for you and something needs to change.
C1, C2 and C4 do not repeat well: once you have built the thing they ask about, they measure memory rather than aptitude.
Record each re-take alongside the first in notebook/000-calibration.md so the
trajectory is visible. Along with the scorecard trend,
that is the only objective evidence you will have that 34 months of evenings changed
something.
References
- Ericsson, K. A. et al. The Role of Deliberate Practice in the Acquisition of Expert Performance. Psychological Review 100(3), 1993. Practice must target the edge of current ability; a plan not sized to the learner is not deliberate practice.
- Vygotsky, L. S. Mind in Society. Harvard University Press, 1978. The zone of proximal development — the argument for calibrating difficulty rather than fixing it.
- Bjork, R. A., Bjork, E. L. Desirable Difficulties in Theory and Practice. JARMAC 9(4), 2020. Why the correct adjustment for a high scorer is less scaffolding, not less work.
- Kruger, J., Dunning, D. Unskilled and Unaware of It. JPSP 77(6), 1999. Self-assessment is least reliable at the low end, which is why every task here has concrete 1/3/5 anchors rather than asking how confident you feel.
- Wiggins, G., McTighe, J. Understanding by Design, 2nd ed. ASCD, 2005. Diagnostic assessment that feeds directly into instructional design, which is what the adjustment table is.
Week One — The Action Plan
Eleven hours, five sessions, starting today. By Sunday you will have: a repository, a working benchmark harness calibrated on your own machine, the journey's reference numbers reproduced, a character tokenizer with a passing test suite, a data loader, and your first committed prediction.
No reading of Vaswani et al. this week. That is scheduled for week 2, after your naive design exists.
Table of Contents
- Before Session 1
- Session 1 — Monday, 2 h
- Session 2 — Tuesday, 2 h
- Session 3 — Wednesday, 2 h
- Session 4 — Thursday, 2 h
- Session 5 — Weekend, 3 h
- End-of-Week Checklist
- If You Only Have Four Hours
- What Week Two Looks Like
Before Session 1
First: have you taken the calibration battery? Three hours, cold, before week 1. It sizes P01, P02 and P11-I to you rather than to my guess, and the adjustments it produces change this week's plan. If you have not, do that first.
Then, fifteen minutes, right now.
- Decide your five slots. Actual days and times, in your calendar, as recurring events, for the next twelve weeks. Not "evenings" — Monday 20:00–22:00. See designing your week.
- Write your pace on a note you will see. 11 h/week → 34 months. If you have chosen differently, use the table and write that number instead.
- Create the directories:
mkdir -p ~/systems/{notebook/weekly,projects}
cd ~/systems && git init
That is it. Do not spend session 1 on tooling. See Not Yet.
Session 1 — Monday, 2 h
Objective: the harness runs on your machine and you have your own numbers.
1.1 — Commit the AI policy (15 min)
Read the ten rules. Then:
cd ~/systems
cp <this-track>/ai-policy.md notebook/ai-policy-reference.md
$EDITOR notebook/000-ai-commitment.md # paste and date the commitment
git add -A && git commit -m "AI assistant policy, committed before week 1"
Copy the commitment verbatim and date it. Committing it before any code is the point — it is a precommitment, and it is worthless afterwards.
1.2 — Run the tools (30 min)
cp -r <this-track>/tools ~/systems/tools
cd ~/systems/tools
python3 bench.py demo
python3 roofline.py table
python3 roofline.py decode --params 7e9 --hw h100
python3 metrics.py demo
python3 bloom.py
Record your bench.py demo environment block and numbers in
notebook/001-machine-baseline.md. They will differ from the reference values in these
pages. That is expected and it is why you are running them.
The reference machine produced, among others:
| measurement | reference value |
|---|---|
| python triple-loop matmul 64×64 | 55.95 MFLOP/s |
| numpy matmul 64×64 | 299.59 GFLOP/s (5,355×) |
sum(list) A vs B | overlapping CIs — no measurable difference |
| Bloom, 10 bits/key | theory 0.00819, measured 0.00822 |
| H100 bf16 decode ridge | batch 295 |
Look at the sum(list) comparison specifically. Two identical functions, and the
harness says so rather than manufacturing a 1% win. That is the behaviour you want from
every benchmark you write for the next 34 months.
1.3 — Reproduce the machine numbers (45 min)
The P14 measurements and P12 costs are quoted throughout this track. Get yours now — they take twenty minutes and you will refer back to them for two years.
Predict each before you measure. Write the predictions in
notebook/001-machine-baseline.md first.
# CPU matmul progression — the P14 table
cat > mm.c <<'EOF'
#include <stdio.h>
#include <string.h>
#include <time.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
return t.tv_sec+t.tv_nsec/1e9;}
#define N 512
static float A[N*N],B[N*N],C[N*N];
int main(void){
for(int i=0;i<N*N;i++){A[i]=(float)((i*31)%7)-3;B[i]=(float)((i*17)%5)-2;}
double f=2.0*N*N*N,t;
t=now();
for(int i=0;i<N;i++)for(int j=0;j<N;j++){float s=0;
for(int k=0;k<N;k++)s+=A[i*N+k]*B[k*N+j];C[i*N+j]=s;}
t=now()-t; printf("naive i,j,k %8.2f GFLOP/s\n",f/t/1e9);
memset(C,0,sizeof(C)); t=now();
for(int i=0;i<N;i++)for(int k=0;k<N;k++){float a=A[i*N+k];
for(int j=0;j<N;j++)C[i*N+j]+=a*B[k*N+j];}
t=now()-t; printf("reorder i,k,j %8.2f GFLOP/s\n",f/t/1e9);
return 0;
}
EOF
cc -O2 -o mm mm.c && ./mm
cc -O3 -ffast-math -o mm3 mm.c && ./mm3
Reference values at -O2: naive 1.91, reordered 27.33 GFLOP/s — a 14.3×
difference from changing the loop order and nothing else. If your ratio is far from that,
your compiler auto-vectorised the naive version; check with -Rpass=loop-vectorize.
Then your platform's BLAS, for the ceiling:
python3 -c "
import numpy as np, time
N=512; A=np.random.rand(N,N).astype(np.float32); B=np.random.rand(N,N).astype(np.float32)
for _ in range(5): A@B
ts=[]
for _ in range(30):
t=time.perf_counter(); A@B; ts.append(time.perf_counter()-t)
ts.sort(); t=ts[len(ts)//2]
print(f'BLAS {N}x{N}: {t*1e3:.3f} ms {2*N**3/t/1e9:.1f} GFLOP/s')"
Reference: 1,679 GFLOP/s (Apple Accelerate, which dispatches to the AMX matrix coprocessor). Your hand-written reordered loop will be 20–60× behind. That gap is Project 14's entire thesis and you now have it on day one.
1.4 — Close the session (30 min)
Write notebook/001-machine-baseline.md properly: your predictions, your numbers, the
ratios, and one paragraph on which prediction was furthest off and why. Then:
cd ~/systems
cat > notebook/RESUME.md <<'EOF'
# RESUME
PROJECT : P01, milestone 1
STATE : tools running, machine baseline recorded
NEXT : create projects/p01-transformer/, write the character tokenizer
with encode/decode round-trip test
COMMAND : cd ~/systems/projects/p01-transformer && pytest
EOF
git add -A && git commit -m "week 1: machine baseline"
Session 2 — Tuesday, 2 h
Objective: the repository, the tokenizer, and a test that fails then passes.
2.1 — Project skeleton (20 min)
cd <this-track>/scaffold
./new-project.sh p01-transformer python ~/systems/projects/p01-transformer
cd ~/systems/projects/p01-transformer
make setup # venv, deps, and requirements.lock
The scaffold gives you the layout every project page assumes: RESUME.md,
EXIT-CRITERIA.md with the eight-item completion gate, AI-LOG.md, a benchmark driver
wired to the shared harness, and results/ committed rather than ignored. See
the scaffold's README for why each of those is there.
make setup writes requirements.lock from pip freeze. Pin versions now — in
eighteen months a version drift will explain a discrepancy you would otherwise spend a
day on.
make test is red on a fresh scaffold, on purpose. Your first act in the repo is to
open the placeholder test and replace it, which is where the reminder lives that property
tests come before the code they cover.
2.2 — Get a corpus (10 min)
Anything 1–5 MB of plain text you find interesting. Public-domain books, your own writing, a documentation dump. Prefer something you know well — you will be reading generated samples for eight weeks and it helps to recognise when they are wrong.
Save as data/corpus.txt. Record its size and character-set size in the README.
2.3 — Character tokenizer, test first (60 min)
Write the test before the implementation.
# tests/test_tokenizer.py
import pytest
from src.tokenizer import CharTokenizer
def test_round_trip_ascii():
t = CharTokenizer.from_text("hello world")
assert t.decode(t.encode("hello world")) == "hello world"
def test_round_trip_unicode():
s = "héllo 世界 🌍"
t = CharTokenizer.from_text(s)
assert t.decode(t.encode(s)) == s
def test_vocab_is_sorted_and_unique():
t = CharTokenizer.from_text("banana")
assert t.vocab == sorted(set("banana"))
def test_ids_in_range():
t = CharTokenizer.from_text("banana")
assert all(0 <= i < t.vocab_size for i in t.encode("banana"))
def test_unknown_character_policy():
t = CharTokenizer.from_text("abc")
with pytest.raises(KeyError): # decide the policy, then TEST it
t.encode("z")
Run it, watch it fail, then write src/tokenizer.py until it passes.
The last test matters more than it looks. What happens on an unseen character is a
design decision, and most tokenizers make it silently. Decide — raise, or map to an
<unk> id — and encode the decision in a test. This is the smallest possible instance
of the habit the whole journey trains.
2.4 — Close (30 min)
Record the compression ratio (bytes per token — 1.0 for a character tokenizer, which is
the baseline BPE must beat in milestone 8). Update RESUME.md. Commit.
Session 3 — Wednesday, 2 h
Objective: the data loader, and the bigram baseline you must beat.
3.1 — Data loader (45 min)
Train/validation split with a held-out contiguous tail, not a random split — random splitting on text leaks context across the boundary and inflates your validation score.
def get_batch(data, batch_size, block_size, rng):
ix = rng.integers(0, len(data) - block_size - 1, size=batch_size)
x = np.stack([data[i:i+block_size] for i in ix])
y = np.stack([data[i+1:i+block_size+1] for i in ix])
return x, y
Test: shapes are (B, T); y is x shifted by exactly one; the same seed gives the
same batch; no index ever reads past the end.
3.2 — Bigram baseline (60 min)
An embedding table straight to logits. Twenty lines. Train it.
Before you run it, write two numbers in the notebook:
- The entropy floor: \(\ln(V)\) nats for your vocabulary size — the loss of a model that has learned nothing. For \(V=65\), that is 4.17.
- Your predicted bigram validation loss.
Then train and compare. Most people predict too low, because character-level bigram statistics are weaker than intuition suggests.
This number is the thing your Transformer must beat, and knowing it now stops week 7 from being either falsely triumphant or falsely disappointing.
3.3 — Close (15 min)
Record both numbers, your prediction error, and one sentence on why you were off.
RESUME.md. Commit.
Session 4 — Thursday, 2 h
Objective: your first committed prediction, and the attention cost model.
4.1 — Start notebook entry 002 (45 min)
Copy templates/notebook.md to
notebook/002-attention-cost.md and fill sections 1–8 only.
The question: at what sequence length does attention's quadratic term start to dominate the linear feed-forward term, for a model with \(d_{model} = 384\), 6 heads?
Section 4 — your naive design — is: work it out on paper. Count the multiply- accumulates in each component. Do not look at P01's table yet.
Section 5 — predict the crossover \(T\), before computing it.
git add notebook/002-attention-cost.md
git commit -m "P01 E-cost: predictions committed before computation"
The commit is the mechanism. It is what makes the prediction real.
4.2 — Write the cost model (60 min)
# src/cost_model.py
def transformer_flops(B, T, H, d_head, n_layers, vocab):
d = H * d_head
qkv = 3 * 2 * B * T * d * d
scores = 2 * B * H * T * T * d_head # quadratic
av = 2 * B * H * T * T * d_head # quadratic
proj = 2 * B * T * d * d
ffn = 2 * 2 * B * T * d * (4 * d)
per_layer = qkv + scores + av + proj + ffn
return {
"per_layer": per_layer,
"total": per_layer * n_layers + 2 * B * T * d * vocab,
"quadratic_share": (scores + av) / per_layer,
"score_matrix_bytes": B * H * T * T * 4,
}
Sweep \(T \in \{128, 512, 1024, 2048, 4096, 8192\}\) for your configuration. Compare against your prediction, then against the reference table (which uses \(H=12, d_h=64\), so your numbers will differ — the shape should not).
Two things to notice, and to write down:
- At \(T=1024\), attention is only 18.2% of the FLOPs in the reference configuration. The quadratic term does not dominate until ~4096.
- The materialised score matrix is 3.2 GB at \(T=8192\) for one batch element in fp32. Memory hits the wall long before FLOPs do, and that is why FlashAttention exists.
4.3 — Close (15 min)
Fill notebook sections 9–14. Be honest in section 10 about how far off your prediction was. Commit.
Session 5 — Weekend, 3 h
Objective: the naive design, the week's reflection, and week 2's objective.
5.1 — Write your naive attention design (75 min)
The most important 75 minutes of week 1. Still no Vaswani.
In notebook/003-attention-naive-design.md, sections 1–5:
You have a sequence of \(T\) vectors. Each position must gather information from earlier positions, and which earlier positions must depend on content, not on fixed offset. Design it.
Constraints to hold yourself to:
- It must parallelise across positions during training (no sequential recurrence)
- It must handle variable \(T\) without changing the parameter count
- It must be differentiable end to end
Write the mechanism, the shapes, the parameter count, and the cost. Then section 4's second half: why you chose each part. Then section 5: where you predict it breaks, and at what \(T\).
Most people invent one of: averaging embeddings (loses order), a fixed-window MLP (parameter count grows with window, and it cannot generalise across positions), or a recurrence (does not parallelise). Some invent something close to attention. All four outcomes are useful — what matters is that the reasoning is written down before you read the paper, because in week 2 you will diff your design against the real one and that diff is the learning.
git commit -am "P01: naive attention design, pre-literature"
5.2 — Weekly review (45 min)
Fill in notebook/weekly/2026-W01.md using
the template. All six weekly outputs should be
present. Be specific in "what I avoided because it was hard".
5.3 — Set up week 2 (30 min)
Week 2's objective: a single attention head, forward pass, hand-verified on three tokens.
Prepare it now so Monday starts with action:
- Create
tests/test_attention.pywith the hand-computed test stubbed out — the arithmetic you will do on paper on Monday - Read P01's milestone 3
- Queue the reading: Vaswani et al. §3 only, 1 hour, Monday. Not before
5.4 — Buffer (30 min)
Overrun, or cleanup, or stop early. If everything is done, stop early — finishing a week with time left is a signal the pace is sustainable, and you should notice it.
End-of-Week Checklist
- Five calendar slots booked for the next twelve weeks
-
notebook/000-ai-commitment.mdcommitted and dated - All five tools run; your environment block recorded
- Your machine's matmul progression measured (naive / reordered / BLAS)
- Repository created, dependencies pinned
- Tokenizer passes five tests including the unknown-character policy
- Data loader tested; contiguous held-out split
- Bigram baseline trained; entropy floor and validation loss recorded
- Cost model written; crossover \(T\) computed and compared to your prediction
- Notebook entry 002: sections 1–8 committed before the computation
- Notebook entry 003: naive attention design, written before reading Vaswani
- Weekly review written
-
RESUME.mdcurrent - Week 2's objective written and its first test stubbed
Eleven of these are artifacts. Two are habits. The two habits — committing predictions before results, and designing before reading — are the ones the other 129 weeks depend on.
If You Only Have Four Hours
A bad first week happens. Do these three things, in this order, and nothing else:
- Run the tools and record your machine's numbers (1 h). Everything downstream compares against these.
- Repository, tokenizer, data loader, bigram baseline (2 h). This is the spine of P01; without it week 2 has nothing to build on.
- The naive attention design (1 h). Non-negotiable and non-deferrable — once you read the paper it is gone forever.
Skip the cost model and the C benchmark; fold them into week 2. Declare it as maintenance mode in the log, with the reason.
What Week Two Looks Like
| Session | Content |
|---|---|
| Mon | Vaswani §3, 1 h. Then diff it against your naive design and write the comparison |
| Tue | Single attention head, forward pass |
| Wed | Hand-compute a 3-token, \(d{=}4\) forward pass on paper; make the test match to 1e-6 |
| Thu | Causal mask + the leak test (perturb token \(t{+}1\), assert logits at \(\le t\) are bit-identical) |
| Weekend | Softmax stability, attention-weights-sum-to-1 test, review |
The full twelve weeks: First 12 Weeks.
Weeks 13 onward are not pre-written. You generate them, 45 minutes per project, with The Week Generator.
The First Twelve Weeks
Weeks 1–8 complete Project 1. Weeks 9–12 begin Project 2. Every week has one objective, one experiment with a prediction, one reflection, and one deliverable — the six weekly outputs.
Week 1 is broken down hour by hour in Week One. For weeks 13 onward, run The Week Generator — the procedure that produced this page.
Table of Contents
- Overview
- Week 1 — Baseline and Naive Design
- Week 2 — One Attention Head
- Week 3 — Masking and the Leak Test
- Week 4 — Multi-Head and the Block
- Week 5 — Training Loop and Deliberate Overfit
- Week 6 — Positional Encodings and RoPE
- Week 7 — Depth, Norm Placement, and Inference
- Week 8 — BPE, Sampling, and the P01 Report
- Week 9 — Brute Force and Ground Truth
- Week 10 — The Naive Graph
- Week 11 — NSW and the Distance Counter
- Week 12 — HNSW and the Hierarchy
- Checkpoint at Week 12
- If You Fall Behind
Overview
| Week | Project | Objective | Deliverable |
|---|---|---|---|
| 1 | P01 m1–2 | Baseline and naive design | Tokenizer, loader, bigram, cost model, pre-literature attention design |
| 2 | P01 m3 | One attention head | Hand-verified 3-token forward pass |
| 3 | P01 m4 | Causal masking | The leak test passing |
| 4 | P01 m5 | Multi-head, FFN, residual, pre-norm | A complete block |
| 5 | P01 m5, m7 | Training loop | Overfits 200 tokens to loss < 0.1 |
| 6 | P01 m9 | Three positional encodings | RoPE with its invariance test |
| 7 | P01 m7, m11 | Depth, norm placement, KV cache | E6 and E11 results |
| 8 | P01 m8, m10, m12 | BPE, sampling, report | P01 report shipped |
| 9 | P02 m1–2 | Brute force and ground truth | Recall harness; RC measured |
| 10 | P02 m3–4 | The naive graph | A random-graph index that works badly |
| 11 | P02 m5 | NSW | Recall curve + the distance counter |
| 12 | P02 m6 | HNSW hierarchy | A curve that dominates week 11's |
Milestone references are to P01's and P02's tables.
Week 1 — Baseline and Naive Design
Objective: the harness runs on your machine, the repository exists, and your attention design is written before you read anything.
Hour by hour: Week One.
| Output | |
|---|---|
| Implementation | Tokenizer, data loader, bigram baseline, cost model |
| Reading | None. Deliberately |
| Experiment | Machine baseline: matmul progression + the tool reference numbers |
| Prediction | The attention/FFN crossover \(T\), committed before computing |
| Reflection | Which prediction was furthest off, and why |
| Deliverable | notebook/003-attention-naive-design.md, pre-literature |
Week 2 — One Attention Head
Objective: a single attention head whose forward pass you have verified by hand.
| Session | Work |
|---|---|
| Mon | Vaswani et al. §3, 1 h. Then write the diff against your naive design |
| Tue | Q/K/V projections, scaled dot product, softmax, weighted sum |
| Wed | Hand-compute a 3-token, \(d{=}4\), single-head forward pass on paper |
| Thu | Make the test match your paper arithmetic to 1e-6 |
| Weekend | Softmax stability; attention rows sum to 1; reflection |
The Monday diff is the week's real content. Three questions, answered in writing:
- What did the paper do that I did not think of?
- What did I do that the paper does not — and is my version wrong, or just different?
- Which of my design decisions did the paper make differently for a reason I can now state?
Most people's naive designs miss one of: the \(1/\sqrt{d_k}\) scale, the separate value projection (many people reuse the keys), or the fact that multiple heads cost the same as one big head. Whichever you missed, that is your finding.
The Wednesday hand-computation is not optional and cannot be delegated. Three tokens, four dimensions, one head. Compute the scores, the softmax, and the weighted sum with a pen. It takes twenty minutes and it is the difference between knowing the formula and knowing the mechanism. Every shape bug for the next six weeks will be diagnosed against this.
| Output | |
|---|---|
| Experiment | Attention entropy as a function of the scale factor: with \(1/\sqrt{d_k}\), without, and with \(1/d_k\) |
| Prediction | What entropy does when the scale is removed — before running it |
| Deliverable | test_attention_hand_computed passing |
Week 3 — Masking and the Leak Test
Objective: causal masking, proven correct rather than assumed.
| Session | Work |
|---|---|
| Mon | Mask construction; applied before the softmax, with -inf |
| Tue | The leak test (below) |
| Wed | Batch dimension; shape assertions at every module boundary |
| Thu | Experiment: masked vs unmasked validation loss |
| Weekend | Determinism check; reflection |
The leak test, which is the single most valuable test in P01:
def test_causal_mask_no_leak():
x = make_sequence(T=16)
out_a = model(x)
x2 = x.clone(); x2[:, 10:] = shuffled_tokens() # perturb positions >= 10
out_b = model(x2)
assert torch.equal(out_a[:, :10], out_b[:, :10]) # bit-identical
This catches masking after the softmax, off-by-one in the mask, and any accidental bidirectionality — three bugs that all produce a model that trains beautifully and reports an impossibly good validation loss.
Thursday's experiment is the one that calibrates your loss intuition permanently: run with the mask removed and watch validation loss drop below the entropy floor for the task. Seeing label leakage from the outside once means you will recognise it forever.
| Output | |
|---|---|
| Experiment | Validation loss with and without the mask |
| Prediction | How far below the floor the unmasked loss goes |
| Deliverable | The leak test in CI |
Week 4 — Multi-Head and the Block
Objective: a complete pre-norm Transformer block.
| Session | Work |
|---|---|
| Mon | Head splitting and concatenation; verify the parameter count is unchanged vs one big head |
| Tue | Output projection; the feed-forward layer with 4× expansion |
| Wed | Residual connections; LayerNorm; pre-norm placement |
| Thu | Stack two blocks; gradient check against finite differences |
| Weekend | Per-component timing; reflection |
Monday's parameter-count check is where multi-head attention stops being magic. With \(H\) heads of dimension \(d/H\), the projections are the same total size as one head of dimension \(d\). You get \(H\) independent attention distributions for free. Verify the count numerically; do not take it on faith.
Thursday's gradient check must pass before any training. A model that trains with wrong gradients trains slowly and you will blame the learning rate for a week.
| Output | |
|---|---|
| Experiment | Per-component forward timing: attention vs FFN, at your \(T\) |
| Prediction | The split, from your week-1 cost model |
| Deliverable | A working block; gradient check green |
Week 5 — Training Loop and Deliberate Overfit
Objective: it learns. Proven by overfitting.
| Session | Work |
|---|---|
| Mon | AdamW, warmup + cosine schedule, gradient clipping, checkpointing |
| Tue | Logging: loss curves, gradient norms, learning rate, tokens/sec |
| Wed | E10: overfit 200 tokens to loss < 0.1 |
| Thu | Train on the real corpus; compare against the bigram baseline |
| Weekend | The shuffled-target test; reflection |
Wednesday is a gate, not a milestone. If the model cannot drive a 200-token corpus to near-zero loss with no regularisation, there is a bug — almost always a mask applied after the softmax, a detached gradient, or a loader that reshuffles the target. Do not proceed to Thursday until it passes. Debugging a subtle bug on a real corpus costs ten times what it costs here.
The weekend test — train on randomly shuffled targets — should converge to exactly \(\ln(V)\) nats. Below means leakage. Far above means an optimizer problem. Two lines of code, and it calibrates every loss number you will read for the rest of the project.
| Output | |
|---|---|
| Experiment | E10 overfit, then the shuffled-target control |
| Prediction | Steps to reach loss < 0.1 |
| Deliverable | A trained model beating the bigram baseline, by a stated margin |
Week 6 — Positional Encodings and RoPE
Objective: three encodings behind one interface, plus the control.
| Session | Work |
|---|---|
| Mon | Learned positional embeddings; one interface for all variants |
| Tue | Sinusoidal |
| Wed | RoPE — write the invariance test first |
| Thu | E4: all four arms, including none |
| Weekend | E5: length extrapolation; reflection |
Write the RoPE tests before the implementation:
def test_rope_relative_position():
q, k = randn(64), randn(64)
for (m, n) in [(5,3), (105,103), (1000,998)]:
d = dot(rope(q, m), rope(k, n))
assert abs(d - reference_for_offset_2) < 1e-6
def test_rope_preserves_norm():
q = randn(64)
for m in [0, 17, 1000]:
assert abs(norm(rope(q, m)) - norm(q)) < 1e-6
Reference values confirm this holds exactly — the dot product for offset 2 is −8.115791933 at \(m,n\) of (5,3), (105,103), (7,5) and (1000,998) alike, across a 200× range of absolute position, and the norm is preserved to nine decimals. These two tests catch the two standard RoPE bugs: wrong dimension pairing, and rotating the values as well as the queries and keys.
The "none" arm in E4 is the control and it is mandatory. Without positional information, attention is permutation-equivariant and the model can only learn unigram statistics. The gap between "none" and the others is the actual value of positional encoding, and reporting the three encodings without it means reporting differences without a scale.
| Output | |
|---|---|
| Experiment | E4 (four arms) and E5 (extrapolation: train at T=256, evaluate at 256/512/1024) |
| Prediction | The ranking of all four arms, and which degrades least on extrapolation |
| Deliverable | RoPE invariance tests passing; the E4 table |
Week 7 — Depth, Norm Placement, and Inference
Objective: the interaction experiment, and inference that scales.
| Session | Work |
|---|---|
| Mon | E6: pre-norm vs post-norm × depth {2,4,8,16} — launch it, it takes hours |
| Tue | KV cache implementation |
| Wed | E11: latency vs generated length, with and without cache |
| Thu | E12: derive KV cache memory, then measure it |
| Weekend | E6 analysis; reflection |
E6 is the best hypothesis in P01. At depth 2, pre-norm and post-norm perform similarly. Somewhere deeper they diverge sharply. Predict the depth on Monday, before launching, and explain your prediction via gradient magnitude at initialisation.
The mechanism: in post-norm, the residual stream passes through a normalisation on every layer, so gradients are repeatedly rescaled on the way back and can shrink multiplicatively with depth. In pre-norm, the residual path is an unnormalised identity from output to input. That is why pre-norm needs no warmup to train deep models and post-norm does.
E12 is a derivation checked by measurement. KV cache bytes per sequence \(= 2 \times L \times T \times d \times \text{bytes}\) — two for K and V, \(L\) layers. Derive it, then measure it. They must agree within 5%; a gap means you have misunderstood what is cached, which is worth finding out now rather than in P15.
| Output | |
|---|---|
| Experiment | E6, E11, E12 |
| Prediction | The pre/post-norm divergence depth; the KV cache size |
| Deliverable | The E6 table; flat per-token latency with the cache |
Week 8 — BPE, Sampling, and the P01 Report
Objective: finish Project 1.
| Session | Work |
|---|---|
| Mon | Byte-level BPE: the merge algorithm, training on your corpus |
| Tue | Round-trip tests including emoji, CJK, and lone surrogate bytes; compression ratio |
| Wed | Sampling: greedy, temperature, top-k, nucleus |
| Thu | Write the report |
| Weekend | Finish the report; exit criteria; reflection; set up P02 |
Tuesday's compression ratio is the number that connects tokenization to everything else: bytes per token trades directly against effective context length. A BPE tokenizer at 4 bytes/token gives you 4× the context of a character tokenizer at the same \(T\). Record it against week 1's character baseline of 1.0.
Thursday and the weekend are the report, and it takes longer than you expect the
first time. Use templates/report.md. The section that matters
most is "What I Expected And Did Not Get" — go back through weeks 1–7's predictions
and tabulate which were wrong and by how much.
Check the exit criteria explicitly, one box at a time. If a box is unticked, either tick it this weekend or write the limitation into the report. Do not carry an unticked box into P02.
| Output | |
|---|---|
| Experiment | Sampling: distribution shift by temperature |
| Prediction | Compression ratio of BPE vs characters |
| Deliverable | REPORT.md shipped. P01 complete |
Week 9 — Brute Force and Ground Truth
Objective: the baseline you are allowed to trust, and a difficulty measurement.
| Session | Work |
|---|---|
| Mon | New repository; read math.md, 2 h |
| Tue | Dataset generators: uniform, clustered, and P01's embeddings |
| Wed | Measure relative contrast for each; verify the clustered generator actually clusters |
| Thu | Brute force + exact ground truth + the recall harness |
| Weekend | Verify against a naive triple loop; the metric-equivalence test; reflection |
Wednesday is where the journey's method shows up as a habit. Before running any index, measure whether your datasets actually differ. Reference values at n=10,000: uniform d=64 gives RC 1.356; 100 clusters at σ=0.05 gives 3.371; and at σ=0.25 it gives 1.393 — statistically indistinguishable from uniform, because \(\sigma\sqrt{d} = 2.0\) exceeds the unit-norm cluster centres.
Verify your independent variable varies. Ten minutes; saves a worthless week.
Weekend's metric-equivalence test proves that on normalised vectors, ranking by cosine, dot product, and L2 give identical results — because \(\|a-b\|^2 = 2 - 2\langle a,b \rangle\). Prove it in a test, because forgetting it in week 13 is a silent recall bug.
| Output | |
|---|---|
| Experiment | RC across d ∈ {16,64,128,512} and across generators |
| Prediction | RC at each d, before measuring |
| Deliverable | Brute force + recall harness; the RC table |
Week 10 — The Naive Graph
Objective: a graph index that works badly, measured.
| Session | Work |
|---|---|
| Mon | Random-graph construction: connect each node to k random others |
| Tue | Greedy search from a fixed entry point |
| Wed | Measure recall/latency — it will be poor |
| Thu | Beam search (ef > 1); measure the improvement |
| Weekend | Diagnose where the missing recall goes; reflection |
This week deliberately builds the wrong thing, and that is step 3 of the loop at the level of a week. A random graph has no locality, so greedy descent has nothing to descend. Measuring exactly how bad it is gives you the number that NSW must beat, and understanding why it is bad is what makes NSW's insertion rule obvious in week 11 rather than arbitrary.
Weekend's diagnosis is the real work. For queries where recall is 0, instrument where the walk terminated. Was it a local minimum? Did the beam exhaust? Was the true neighbour unreachable from the entry point? Those three failure modes have three different fixes, and distinguishing them now is what makes week 11 fast.
| Output | |
|---|---|
| Experiment | Recall vs beam width on a random graph |
| Prediction | recall@10 at ef=64 on a random graph, before measuring |
| Deliverable | A working, bad index, with its failure diagnosed |
Week 11 — NSW and the Distance Counter
Objective: a real graph, and the instrument that explains it.
| Session | Work |
|---|---|
| Mon | NSW insertion: greedy search, connect to M nearest, reciprocal edges |
| Tue | Degree cap and pruning |
| Wed | Instrument the distance counter — before looking at wall clock |
| Thu | The efSearch sweep |
| Weekend | The two-factor decomposition; reflection |
Wednesday's counter is the most important instrument in P02. It separates the algorithmic question (how many distances?) from the implementation question (how much does each cost?). Without it, week 11's result is "my index is slower than brute force and I don't know why."
With it, you get the two-factor model, which on the reference machine closed to two significant figures:
algorithmic win : 10,000 / 1,459 distances = 6.9x fewer
constant factor : 899 ns vs 13.3 ns/dist = 67.5x slower each
predicted : 6.9 / 67.5 = 0.10x
measured : 0.10x
Your graph index will be slower than brute force at n=10,000, and that is correct. Expect it, write the prediction down on Monday, and let the counter tell you which of the two factors is responsible. This is the week that teaches the most transferable debugging technique in the whole journey.
| Output | |
|---|---|
| Experiment | efSearch sweep with recall, latency, and distances per query |
| Prediction | Both factors of the speedup model, separately |
| Deliverable | The recall/QPS curve + the decomposition |
Week 12 — HNSW and the Hierarchy
Objective: the layered index, and the first cross-project synthesis.
| Session | Work |
|---|---|
| Mon | Layer assignment: geometric distribution, \(\ell = \lfloor -\ln(U) \cdot m_L \rfloor\) |
| Tue | Descent through upper layers; beam search at layer 0 |
| Wed | Compare against week 11 at equal distance count |
| Thu | Reachability test — every node reachable from the entry point in layer 0 |
| Weekend | Stage progress review; reflection; plan weeks 13–15 |
Wednesday's comparison must be at equal distance count, not equal wall clock. The hierarchy's contribution is algorithmic — fewer distances for the same recall — and comparing wall clock conflates it with implementation noise. If the curves are identical at equal distance count, your hierarchy is not doing anything and the layer assignment is probably wrong.
Thursday's reachability test is the one that will matter in week 14, when you hit the clustered-data recall ceiling. Write it now.
| Output | |
|---|---|
| Experiment | HNSW vs NSW at equal distance count |
| Prediction | The distance-count reduction from the hierarchy |
| Deliverable | An index whose curve dominates week 11's |
Checkpoint at Week 12
Not a full stage review — that is week 26 — but an honest look.
Artifacts that should exist:
- P01: complete, report shipped, exit criteria ticked or limitations written
- P02: brute force, random graph, NSW, HNSW, recall harness, distance counter
- ~6 notebook entries, sections 1–8 committed before results in every one
- 12 weekly reviews
-
RESUME.mdcurrent
Habits that should be forming:
- You write predictions before running things without being reminded
- You reach for p50/p95/p99 automatically and feel uneasy reporting a mean
- You measure your independent variable before trusting an experiment
- You have caught yourself in at least one wrong belief with a measurement
Numbers to check against the plan:
| Planned | Yours | |
|---|---|---|
| Hours | 132 (12 × 11) | |
| Projects complete | 1 | |
| Notebook entries | ~6 | |
| Reports shipped | 1 |
If you are more than 15% over on hours, do not resolve to work faster. Cut scope from P02 using its tier table — the MVI is 35 hours and still teaches the core mechanism — and record the cut.
The most important question: are you still doing the loop, or have you started just building? Check notebook entries 002 and 003 against your most recent one. If sections 4, 5 and 6 have got thinner, the discipline is eroding, and week 13 is the cheapest possible moment to fix it.
If You Fall Behind
Falling behind in the first twelve weeks is normal and is not a signal about the next 118. Three graduated responses:
One week behind — absorb it. Week 8's report can take a weekend more; P02 starts in week 10 instead of 9. Do not compress a milestone to catch up.
Two to three weeks behind — cut scope, not quality. Specifically: skip BPE (week 8) and keep the character tokenizer, deferring BPE to P01's extension. That saves 8 hours and costs nothing downstream — no later project needs BPE.
Four or more weeks behind — the pace is wrong, not your effort. Recompute the whole schedule at your actual hours using the duration table, move the end date, and continue at the real pace. A 40-month journey completed beats a 34-month journey abandoned in month nine.
What not to do: attempt to work 20 hours a week to catch up. It does not work, and the crash costs more than the deficit. See when work takes a quarter.
The Week Generator
Weeks 1–12 are written out session by session. Weeks 13–130 are not, and deliberately so: pre-writing 118 weeks two years in advance produces fiction, and fiction you then feel obliged to follow.
What you need instead is the procedure that produced weeks 1–12, so you can run it yourself at the start of every project. That is this page: an algorithm, the invariants a valid decomposition must satisfy, three fully worked examples, and the re-planning procedure for when a week goes wrong.
Time cost: 45 minutes per project, in the last session before it starts. Fourteen times over the journey.
Table of Contents
- Why This Is a Generator and Not a Schedule
- The Algorithm
- The Eight Invariants
- Worked Example 1 — P02, a Medium Project
- Worked Example 2 — P04, With a Mid-Project Pivot
- Worked Example 3 — P05, a Large Project With a Kill Switch
- Decomposition Smells
- Re-Planning Mid-Project
- The Week-Zero Ritual
- References
Why This Is a Generator and Not a Schedule
Three reasons, and the third is the one that matters.
1. Estimates decay. By the time you reach P12 in month 27 you will know your own throughput on this kind of work far better than I do now. A schedule written today encodes my guess; a generator run then encodes your measurement.
2. Projects reshape themselves. P02's milestone 7 (the neighbour-selection heuristic) might take four hours or twelve depending on what milestone 5 taught you. A decomposition made after milestone 5 is strictly better than one made before it.
3. Producing the decomposition is itself the planning skill. Deciding what constitutes a week's worth of work, what can be verified at the end of it, and what depends on what — that is project management at the scale where you actually control the variables. Handing you 118 pre-baked weeks would remove the one form of estimation practice this journey naturally provides. You will be estimating for the rest of your career; here you get fourteen calibrated repetitions with recorded predictions and recorded actuals.
This is the same argument as step 3 of the loop. Write your own design before reading the canonical one. Write your own weeks before reading someone else's.
The Algorithm
Inputs: the project page's milestone table (numbered, with hours), experiments table, readings table, and exit criteria. Output: one row per week with the six weekly outputs.
Step 1 — Check the budget reconciles
Sum the milestone hours. It must equal the project's stated budget.
Σ milestone hours == project budget == weeks × your pace
If it does not, one of them is wrong and you must fix it now, not discover it in week 6. When I first wrote these fifteen project pages, six of them over-allocated their milestone tables by 4–10 hours against their own budgets; a thirty-second script found all six. Run the check.
Step 2 — Normalise milestone sizes
- Any milestone > 12 h is split. It will not fit in a week alongside the week's other obligations, and a milestone spanning three weeks gives you no completion signal for a fortnight.
- Any milestone < 3 h is merged into an adjacent one. Sub-3-hour items are tasks, not milestones, and tracking them adds ceremony without information.
Step 3 — Reserve the non-implementation hours
Your pace is 11 h/week, but implementation is only 45% of it (allocation). The milestone hours are the implementation. So:
\[ \text{weeks} = \frac{\Sigma\,\text{milestone hours}}{11} \quad\text{and each week has}\quad \begin{cases} \sim 5.0\ \text{h implementation (milestones)}\ \sim 1.7\ \text{h reading}\ \sim 2.2\ \text{h experiment}\ \sim 1.1\ \text{h writing}\ \sim 1.1\ \text{h debug/review} \end{cases} \]
This is the step everyone gets wrong, and it is why self-made plans overrun by ~2×. The milestone hours are ~45% of your week, not 100% of it. A 77-hour project is 7 weeks at 11 h/week — not 77/11 = 7 weeks of pure milestone work crammed into 7 weeks.
The project pages already do this arithmetic for you: P02's 77 milestone hours are budgeted as 7 weeks precisely because 77 h ÷ 11 h/wk = 7, with the milestone hours counted as the whole week rather than 45% of it. That is a deliberate simplification — the milestone estimates are generous enough to absorb the reading and experimentation attached to them. If your actuals say otherwise after two projects, re-derive with the 45% rule and lengthen everything.
Step 4 — Pack milestones into weeks
Greedy, in dependency order, filling to ~11 hours per week:
week = 1; budget = 11
for each milestone m in order:
if hours(m) <= budget:
assign m to week; budget -= hours(m)
else if budget >= 3:
split m: put `budget` hours in this week, remainder in the next
else:
week += 1; budget = 11; assign m to week; budget -= hours(m)
Then adjust by hand for the invariants. The greedy pass gets you 80% there in five minutes; the hand adjustment is the other 40 minutes and is where the judgement lives.
Step 5 — Attach reading
Take the project's readings table and attach each item to the milestone that needs it, scheduled for the week before or during that milestone — never front-loaded.
Front-loading reading is the single most common self-planning error in this kind of work. It feels responsible and it destroys step 3 of the loop: read HNSW in week 1 and your own graph design in week 3 is a half-remembered copy.
Step 6 — Attach one experiment per week
Every week gets exactly one, from the project's experiments table, or — early on, before there is anything to measure — a correctness property test.
Order them so that each week's experiment is possible given what exists. Do not save all experiments for the end: the whole point of the 20% experimentation budget is that measurement runs alongside building, so that a wrong design is caught in week 3 rather than week 8.
Step 7 — Name the deliverable and the reflection prompt
For each week: what will exist on disk on Sunday that does not exist on Monday? If you cannot name it in a noun phrase, the week is not well formed.
The reflection prompt should be specific to the week, not "what did I learn". Good prompts: "which of my three predictions about block size was furthest off, and was I wrong about the mechanism or the magnitude?"
Step 8 — Run the invariant check, then commit it
Write the table into notebook/weekly/PLAN-<project>.md and commit it before the
project starts. It is a prediction, and like every other prediction in this journey it
is worth more with a timestamp on it. At the end of the project, diff planned against
actual — that diff is your estimation calibration data.
The Eight Invariants
A decomposition that violates any of these is malformed. Check before committing.
| # | Invariant | Why | How to spot the violation |
|---|---|---|---|
| I1 | Week 1 ends with something running | A first week of pure setup sets the tone that this project is administration. It also delays the first real feedback by 25% of a Medium project | Week 1's deliverable is a noun like "environment" or "repo" rather than a behaviour |
| I2 | A correctness gate precedes the first performance measurement | The track's rule: no performance work while a test is red. If week 3 benchmarks something week 4 tests, you will optimise a bug | The first bench week has no test week before it |
| I3 | Every week has exactly one experiment with a pre-written prediction | Zero means you are building without measuring; two means one gets done badly | A week with an empty experiment cell, or three |
| I4 | Reading is attached to the milestone that needs it | Front-loading destroys the naive-design exercise permanently | Weeks 1–2 contain more than ~2 h of reading |
| I5 | The report is not one week at the end | Writing about work you have forgotten produces a worse report and takes longer. Methods sections are written while doing the method | Only the final week mentions the report |
| I6 | No week is more than ~60% integration | A week with no new mechanism is a smell: either the previous weeks under-delivered, or you have found a way to feel busy | A week whose deliverable is "X now works with Y" and nothing else |
| I7 | The last week is slack + report, not new mechanism | Every project overruns somewhere. A final week already spoken for turns a small overrun into a missed exit criterion | The last week introduces a milestone |
| I8 | Milestones are 3–12 h after normalisation | Larger gives no weekly completion signal; smaller is ceremony | Step 2 was skipped |
I1 and I7 together mean a Medium project has ~6 weeks of real mechanism, not 8. Plan for that and the schedule holds; plan for 8 and it does not.
Worked Example 1 — P02, a Medium Project
Input: P02, 77 hours, 7 weeks (W9–W15), 12 milestones.
Step 1 — reconcile
4+5+4+6+9+10+8+8+5+4+8+6 = 77 ✓ equals the stated budget.
Step 2 — normalise
Largest is m6 at 10 h — under 12, no split needed. Smallest is m1/m3/m10 at 4 h — above 3, no merge. No changes.
Step 3–4 — greedy pack
| Week | Milestones | Hours |
|---|---|---|
| 9 | m1 (4) + m2 (5) | 9 |
| 10 | m3 (4) + m4 (6) | 10 |
| 11 | m5 (9) | 9 |
| 12 | m6 (10) | 10 |
| 13 | m7 (8) | 8 |
| 14 | m8 (8) + m9 (5)? | 13 — over |
| ... |
Week 14 overflows. Hand adjustment: move m9 (persistence, 5 h) to week 15 and pair it with m10 (deletion, 4 h), pushing m11 (sweeps, 8 h) and m12 (report, 6 h) — which is now 14 h in one week and violates I7.
The greedy pass has told us something real: 77 hours over 7 weeks leaves no room for a slack week. Resolution — merge m9 and m10 (both small persistence-adjacent work, 9 h together) into week 14, and let week 15 be sweeps + report with sweeps largely running unattended.
Step 5–7 — the finished plan
| Wk | Objective | Milestones | Reading | Experiment (predict first) | Deliverable |
|---|---|---|---|---|---|
| 9 | Ground truth I can trust | m1 generators + RC, m2 brute force + harness | He, Kumar & Chang (1.5 h) — before generating data | Measure RC at d ∈ {16,64,128,512}; predict each first | Brute force + recall harness + the RC table |
| 10 | A graph that works badly | m3 distance fns, m4 random-graph greedy search | Beyer et al. (1.5 h) | recall@10 vs beam width on a random graph. Predict it at ef=64 | A working, bad index with its failure diagnosed |
| 11 | NSW, and the instrument | m5 NSW insertion + beam search | Malkov 2014 NSW (1.5 h) — after your own insertion rule | efSearch sweep with the distance counter. Predict both factors of the speedup model separately | Recall/QPS curve + the two-factor decomposition |
| 12 | The hierarchy | m6 HNSW layers | Malkov & Yashunin §1–3 (2 h) | HNSW vs NSW at equal distance count. Predict the reduction | A curve dominating week 11's |
| 13 | The heuristic that is a correctness property | m7 Algorithm 4 | Malkov & Yashunin Algorithm 4 (1 h) | E7 clustered vs uniform. Predict: clustered is easier. (It is not — this is the week the project's best result appears) | The recall-ceiling measurement + failure analysis |
| 14 | Make it real | m8 compiled loop, m9 persistence, m10 deletion | ANN-Benchmarks protocol (1.5 h) | Re-measure the crossover against the two-factor model after compiling. Predict where it moves | ns/dist down ≥10×; round-trip persistence |
| 15 | Evidence | m11 sweeps, m12 report | Jégou PQ (2 h, optional) | E12 hnswlib comparison. Predict your factor behind | REPORT.md shipped |
Invariant check
| I1 week 1 runs | ✓ brute force + harness executes on day 3 |
| I2 correctness first | ✓ w9 metric-equivalence tests precede w11's first perf number |
| I3 one experiment/week | ✓ seven weeks, seven experiments |
| I4 reading attached | ✓ Malkov lands w11–13, after the naive design in w10 |
| I5 report distributed | ✓ w13's failure analysis is written that week and becomes a report section |
| I6 integration ≤60% | ✓ w14 is the only integration-heavy week, and it adds the compiled kernel |
| I7 last week slack | ⚠ partially violated — w15 has sweeps and the report |
| I8 sizes 3–12 h | ✓ |
I7 is knowingly violated, and that is recorded rather than hidden. The mitigation: w15's sweeps run unattended, so the wall-clock cost is low even though the hours are allocated. If w13's Algorithm 4 work overruns — likely, since it is the subtlest milestone — the declared cut is m10 deletion, which no downstream project needs. Deciding the cut now, while calm, is worth more than deciding it in week 14 under pressure.
Worked Example 2 — P04, With a Mid-Project Pivot
Input: P04, 99 hours, 9 weeks (W35–W43), 13 milestones:
6,8,6,8,8,6,5,8,10,12,8,8,6 = 99 ✓
This project has a structural feature P02 does not: two compaction strategies (m9 size-tiered, 10 h; m10 leveled, 12 h) where the second is the most likely thing to be cut. The decomposition must make that cut cheap.
| Wk | Objective | Milestones | Reading | Experiment | Deliverable |
|---|---|---|---|---|---|
| 35 | Know my disk | m1 measure the device, m2 WAL | — (measure first) | Sequential vs random, read vs write, block sizes. Predict each; you will be wrong about random reads | Your device's real numbers + a replayable WAL |
| 36 | Durable writes | m3 memtable, m4 SSTable writer | O'Neil §1–3 (2 h) | Torn-tail recovery: truncate the WAL at 20 random offsets | FORMAT.md + a WAL that survives truncation |
| 37 | The read path | m5 SSTable reader, m6 Bloom | Bloom 1970 (0.5 h), Monkey (2 h) | Bloom fpr vs theory, and state your measurement resolution | Point reads across N tables; the fpr table |
| 38 | Deletes and ranges | m7 tombstones, m8 merging iterator | LevelDB source (2 h) | Range scan correctness vs a BTreeMap model | Model-based test over 10⁶ ops |
| 39 | Compaction I | m9 size-tiered | Dong et al. RocksDB (2 h) | Amplification counters under uniform keys. Predict W/R/S from the derivation | Live amplification instrumentation |
| 40 | Compaction II | m10 leveled | Rosenblum & Ousterhout (2 h) | Same counters, leveled. Compare against the derived 31/4/1.10 | Both strategies running |
| 41 | Realistic load | m11 workload generator | — | E2 Zipfian vs uniform. Predict which is faster and why mechanically | Seeded generator, reusable in P05 |
| 42 | Break it | m12 crash hardening | Pillai et al. (0.5 h) | E13 ingest faster than compaction. Characterise the collapse | 50 random kill points, zero acknowledged loss |
| 43 | Evidence | m13 experiments + report | — | E3 the crossover figure | REPORT.md + the crossover plot |
The pivot, planned in advance
Decision point: end of week 40. If leveled compaction is not running by then:
| Cut | m10 leveled. Ship size-tiered only |
| Recovered | 12 h ≈ one full week |
| Cost | E3's crossover figure becomes a comparison against published figures rather than your own |
| What survives | Every other exit criterion. The three amplifications are still measured, just for one strategy |
| What to write | "Leveled compaction not implemented; the read-amplification comparison is therefore against published figures rather than my own." One sentence, in the report, under limitations |
Naming the cut, its trigger week, its cost and its wording in advance is the entire technique. A cut decided at week 42 under pressure is an abandonment; a cut decided at week 35 and executed at week 40 is a plan.
Worked Example 3 — P05, a Large Project With a Kill Switch
Input: P05, 143 hours, 13 weeks (W55–W67), 14
milestones: 10+12+8+12+12+14+14+8+8+8+10+10+12+5 = 143 ✓
Two structural features shape everything: the fault injector is built first (m2, before any distributed feature), and this is the project most likely to overrun in the whole journey.
| Wk | Objective | Milestones | Reading | Experiment | Deliverable |
|---|---|---|---|---|---|
| 55 | Determinism before features | m1 simulated network | Lamport 1978 (2 h) | Same seed → identical message order, proven by hashing the trace | A network you can replay |
| 56 | The injector | m2 fault injector (12 h) | FLP (2 h) | Record a failing schedule, replay it, get the identical failure | faultinjector/ — the most reusable artifact of Stage 3 |
| 57 | Plumbing | m3 node skeleton | Raft §1–4 (3 h) | Per-RPC latency histogram | RPC layer + metrics |
| 58 | Replication, hard-coded leader | m4 single-shard replication | Raft §5.1–5.3 (2 h) | Write latency decomposition: fsync / RTT / apply. Predict which dominates | Writes replicating |
| 59 | The oracle | m5 linearizability checker | Herlihy & Wing (2 h) | Detect a violation you injected on purpose | linchecker/ |
| 60 | Elections | m6 Raft election | Raft §5.2 re-read (1 h) | Exactly one leader per term under partition, asserted globally | Elections under injection |
| 61 | Log replication | m7 Raft log replication | Raft §5.4.2, twice (2 h) | Log Matching asserted after every AppendEntries | matchIndex/nextIndex correct |
| 62 | Safety under restart | m8 persistence | Raft §5.4.2 again | Crash-restart any subset; Leader Completeness holds | Survives crash-restart |
| 63 | Exactly-once effects | m9 client sessions | — | Duplicate every message; assert zero duplicate effects | Idempotent retries |
| 64 | Detection | m10 phi-accrual | Hayashibara (1.5 h) | E4 fixed vs phi-accrual under heavy-tailed delay. Predict the FP reduction | The detection frontier plotted |
| 65 | Membership | m11 membership changes | Raft §6 (1 h) | Add/remove a node with no availability loss | Safe reconfiguration |
| 66 | Catch-up | m12 snapshots | Dynamo (2.5 h) | E11 install-snapshot vs log replay. Where does snapshot win? | A follower recovering from a compacted prefix |
| 67 | Scale + evidence | m13 sharding, m14 report | Spanner (2 h, breadth) | E6 asymmetric partition — the one that finds bugs | REPORT.md with a real bug and its interleaving |
The kill switch
Hard decision point: end of week 65 (week 11 of 13).
If the linearizability checker is not clean across ≥1,000 seeded runs by then:
CUT m11 membership, m12 snapshots, m13 sharding (32 h ≈ 3 weeks)
SHIP single-shard Raft, correct, with the injector and the checker
WRITE "Multi-shard routing, membership changes and snapshots are not implemented.
The system is a correct single-shard replicated log."
Why this is the right cut and not a failure. P05's exit criteria are about correctness under fault injection, not about feature count. A single-shard Raft with zero linearizability violations across 1,000 seeded fault runs, a replayable injector, and a documented real bug fully satisfies every exit criterion except the rebalancing one. A three-shard system that fails the checker satisfies none of them.
And the two artifacts with independent portfolio value — faultinjector/ and
linchecker/ — are both delivered by week 59, four weeks before the decision point. The
plan front-loads the things that survive a cut. That is the design principle for
decomposing any Large project.
Note the reading pattern
Raft §5.4.2 appears in weeks 61 and 62, deliberately twice, and the extended paper is spread across weeks 57–62 rather than read up front. The one subtle safety property in the paper is read immediately before the milestone that implements it and again immediately after — because that is the point at which you can actually tell whether you understood it.
Decomposition Smells
Six patterns that mean the plan is wrong. Each is a real failure I have seen in the examples above or built into these pages on purpose.
| Smell | What it means | Fix |
|---|---|---|
| "Setup" week | Week 1 has no running artifact | Move a small mechanism milestone into week 1, even out of dependency order |
| Reading front-load | 6 h of papers in weeks 1–2 | Redistribute to the milestones that need them. If a paper serves no milestone, it is not yet |
| Experiment clump | All experiments in the final two weeks | You are building blind. Move at least one measurement into every week, even if it is a correctness property |
| Report cliff | The final week is 100% writing | Move the methods section into the weeks that perform the method |
| The 13-hour week | Greedy packing overflowed and you left it | Either merge two small milestones elsewhere and shift, or declare the cut now |
| No named cut | A Large project with no pre-declared scope cut and trigger week | Add one. Every Large project needs a kill switch decided while you are calm |
The experiment clump is the most damaging and the most common, because building feels like progress and measuring feels like a detour. It is also the smell that converts this journey back into an ordinary build log.
Re-Planning Mid-Project
Weeks go wrong. The response is a procedure, not an improvisation.
One week behind
Absorb it. Shift everything right by one week and take it out of the project's slack week (I7). Do not compress a milestone to catch up — compression removes the experiment and the reflection first, which are the two things that make the week worth having done.
Two weeks behind
Cut, do not compress. Go to the project page's
three scope tiers and drop from standard toward
MVI. Record it in EXIT-CRITERIA.md's scope-cuts table the day you decide, with the
trigger and the cost.
Then re-run steps 4–8 of the algorithm on the remaining milestones. It takes fifteen minutes and it produces a plan you believe, which a silently-slipping plan is not.
Three or more weeks behind, or a milestone stalled
This is the two-week stall rule, and it is a written decision among four options: reduce scope, change approach, ask for help, or cut the milestone.
At the project level, one additional question: is the pace wrong rather than the plan? If two consecutive projects have run 30% over, your actual pace is ~8.5 h/week, not 11. Re-derive the whole journey from the duration table and move the end date. A 40-month journey completed beats a 34-month journey abandoned in month nine.
The calibration you get for free
At the end of each project, put planned against actual in the report:
| Week | Planned | Actual | Δ | Why |
|---|---|---|---|---|
| 11 | m5 NSW, 9 h | 13 h | +44% | Beam-search stopping condition took two sessions to get right |
After three projects you will know your personal estimation bias — most people are consistently 20–40% optimistic, and knowing your own multiplier turns every subsequent estimate into a good one. This table is the single most valuable by-product of running the generator, and it does not exist if the weeks were handed to you.
The Week-Zero Ritual
45 minutes, in the last session before a project starts. Fourteen times.
- Reconcile the budget (step 1). 2 min.
- Run the greedy pack (steps 2–4). 10 min.
- Hand-adjust for the invariants (step 8 check). 20 min — this is the real work.
- Attach reading and experiments (steps 5–6). 8 min.
- Name the cut: for any Medium or Large project, which milestone goes first, at which trigger week, and the sentence you will write in the report. 5 min.
- Commit
notebook/weekly/PLAN-<project>.md.
Then, and only then, run scaffold/new-project.sh and start
week one of that project.
Do not skip step 5. A cut named while calm costs five minutes; the same cut decided under pressure in week 11 costs a weekend of rationalising and usually takes the wrong thing.
References
- Brooks, F. P. The Mythical Man-Month, anniversary ed. Addison-Wesley, 1995. Chapter 2 on why projects slip one day at a time, and why adding effort to a late project makes it later — the reason the re-planning procedure cuts scope rather than adding hours.
- Boehm, B. W. Software Engineering Economics. Prentice-Hall, 1981. The cone of uncertainty: estimates made at the start of a project are reliably off by a large factor, and the correct response is re-estimation at checkpoints rather than better initial guessing.
- DeMarco, T. Controlling Software Projects. Yourdon Press, 1982. "You cannot control what you cannot measure" — the argument for the planned-vs-actual table.
- Cohn, M. Agile Estimating and Planning. Prentice Hall, 2005. On relative estimation and on why estimates improve with recorded feedback, which is what the calibration table provides.
- Tetlock, P., Gardner, D. Superforecasting. Crown, 2015. Calibration improves only when predictions are recorded and scored — the same reason notebook sections 1–8 are committed before the run.
- Kahneman, D., Tversky, A. Intuitive Prediction: Biases and Corrective Procedures. 1977. The planning fallacy, and the outside view as its correction — which is exactly what your own planned-vs-actual multiplier becomes after three projects.
Walkthroughs
Six executable mini-projects, 40–60 minutes each. Every one is a miniature of a real project in the track, produces a measured result, and ends in a finding that contradicts something people commonly believe.
cd walkthroughs
python3 w1_attention.py python3 w2_lsm.py python3 w3_raft.py
python3 w4_watermarks.py python3 w5_autodiff.py python3 w6_popularity.py
All six were executed to produce the output quoted below. Only w1 and w6 need
third-party packages (numpy; w6 imports the track's own tools/metrics.py).
These are not the projects. They are 80-line sketches you can finish in an evening, built so you can feel a mechanism before committing eight weeks to it — and so that the central surprise of each project arrives early enough to be useful.
Table of Contents
- Why These Exist
- W1 — Attention, and the Test That Catches Leakage
- W2 — An LSM Read Path, and What Bloom Filters Really Buy
- W3 — Split-Brain, Caused and Then Prevented
- W4 — The Answer That Is Quietly Wrong
- W5 — Autodiff Is Bookkeeping
- W6 — The Popularity Trap
- What All Six Have in Common
- References
Why These Exist
Three reasons.
A cheap prior on the project. Spending 45 minutes on a sketch of P04 before committing 99 hours tells you whether the domain grips you. That is worth knowing in week 34 rather than week 39.
The central surprise, early. Each project's most valuable finding usually arrives in its last third. These bring one forward — you meet the bloom-filter-helps-hits result in an hour rather than in week 40, and it reframes the whole project you then build.
They are the calibration battery's natural follow-up. If C1 scored low, W1 is the remedy. If C3 scored low, W2 and W4 are.
| Walkthrough | Mini of | Minutes | The finding |
|---|---|---|---|
| W1 | P01 | 45 | Attention is 18% of the compute at GPT-2's own context length |
| W2 | P04 | 60 | Bloom filters help hits 17×, contradicting the usual summary |
| W3 | P05 | 60 | Split-brain is one inequality, not a bug |
| W4 | P07 | 45 | The completeness curve has a dead zone where tuning buys nothing |
| W5 | P13 | 45 | The hard part is accumulation, not calculus |
| W6 | P08 | 40 | A bestseller list wins NDCG by 2.5× over personalisation |
W1 — Attention, and the Test That Catches Leakage
w1_attention.py · 85 lines · numpy
Single-head causal attention, plus the two property tests that catch the bugs example tests miss.
The masking detail that matters, and the reason it is a comment in the code:
if causal:
T = X.shape[0]
# -inf BEFORE the softmax, so masked positions get exactly zero weight.
# Applied after, they would get a small nonzero weight and the model would
# quietly cheat.
scores = np.where(np.tril(np.ones((T, T), bool)), scores, -np.inf)
The leak test
The single most valuable test in P01, and it is six lines:
Y1, _ = attention(X, Wq, Wk, Wv)
X2 = X.copy()
X2[6:] = rng.standard_normal((T - 6, d)) # scramble everything from row 6 on
Y2, _ = attention(X2, Wq, Wk, Wv)
assert np.array_equal(Y1[:6], Y2[:6]), "CAUSAL LEAK"
TEST 2 — the causal mask does not leak
rows 0..5 bit-identical after scrambling rows 6..11: True
rows 6..11 changed: True
Bit-identical, not "close". A mask applied after the softmax, an off-by-one, or accidental bidirectionality all fail this and all produce a model that trains beautifully and reports an impossibly good validation loss.
What the scale buys
TEST 3 — what the 1/sqrt(dk) scale buys
dk= 16 mean row entropy: scaled 1.477 unscaled 0.654 (max 2.485)
dk= 64 mean row entropy: scaled 1.350 unscaled 0.141 (max 2.485)
dk= 256 mean row entropy: scaled 1.443 unscaled 0.090 (max 2.485)
Read the columns, not the rows. Scaled entropy is flat at ~1.4 across a 16× range of \(d_k\); unscaled collapses from 0.654 to 0.090. That is P1 made visible: the scale is what keeps the softmax responsive as dimension grows.
A bug I shipped in the first draft, kept as a comment. The initial version
initialised W without a \(1/\sqrt{d_k}\) factor, so \(Q\) already had variance
\(d_k\) per component and both columns showed near-zero entropy — the demo appeared
to disprove its own point. Initialisation and score scaling are two separate defences
against the same failure, and forgetting either produces the same symptom.
Where the quadratic term actually bites
T attn GF ffn GF quad share
128 0.65G 1.21G 2.7%
1024 8.05G 9.66G 18.2%
4096 70.87G 38.65G 47.1%
8192 244.81G 77.31G 64.0%
At GPT-2 small's own context length of 1024, attention is 18% of the layer. The quadratic term does not dominate until ~4096. "Attention is the bottleneck" is a claim about a context length, and it is usually made about the wrong one.
W2 — An LSM Read Path, and What Bloom Filters Really Buy
w2_lsm.py · 86 lines · stdlib only
Forty immutable sorted runs, a Bloom filter per run, and a counter on every simulated block read.
def get(self, key, stats, use_bloom=True):
if use_bloom and self.bloom is not None and key not in self.bloom:
stats["bloom_rejects"] += 1
return None # no disk read at all
stats["block_reads"] += 1 # the expensive part
return self.d.get(key)
40 sorted runs x 2000 keys = 80,000 keys
bits/key absent: reads present: reads bloom rejects
--------------------------------------------------------
none 40.000 20.50 0.0
4 5.882 3.83 34.1
8 0.850 1.44 39.1
10 0.335 1.17 39.7
16 0.016 1.01 40.0
Two findings, and the second contradicts the folklore
The absent column confirms the derivation in P3: 40 reads → 0.335 at 10 bits/key, a 119× reduction, against a predicted 40 × 0.00819 = 0.328. Theory and measurement agree to within 2%.
The present column is the interesting one. The usual summary is "Bloom filters help misses, not hits." Measured: present-key reads fall from 20.50 to 1.17, a 17× improvement.
The mechanism is obvious once seen and invisible until measured: a hit must still skip every newer run that lacks the key, and the filter skips those without a read. Without a filter, finding a key costs a scan of ~half the runs.
The folklore is describing the asymptote, not the common case: as the filter becomes perfect, absent-key cost → 0 while present-key cost floors at 1, the one unavoidable read. Both improve; only one can reach zero.
This is a small, checkable instance of the track's central method — a widely repeated statement that is true in the limit and misleading in practice, caught by a counter.
W3 — Split-Brain, Caused and Then Prevented
w3_raft.py · 76 lines · stdlib only
A deterministic leader election under partition. Five nodes split 3–2; one candidate stands in each partition. The only variable is the quorum rule.
N = 5 replicas, partitioned into a 3-group and a 2-group
quorum=3 majority (Q=3, 2Q=6 > 5) CORRECT
term 1: node 0 elected with 3 votes
-> 1 leader(s); SPLIT-BRAIN: False
quorum=2 plurality (Q=2, 2Q=4 <= 5) BROKEN
term 1: node 0 elected with 3 votes
term 1: node 3 elected with 2 votes
-> 2 leader(s); SPLIT-BRAIN: True
Two leaders in the same term, both accepting writes. Not a race, not a timing bug — an arithmetic consequence of choosing \(Q\) with \(2Q \le N\).
The script then brute-forces the inequality over every \((N, Q)\) pair, which is P4 as an exhaustive check rather than a proof:
N Q 2Q>N disjoint quorums exist?
5 2 False True
5 3 True False
6 3 False True
6 4 True False
Note \(N=6\): a majority is four, not three. Even replica counts are exactly where operators get this wrong, and "we run six replicas for extra safety" with a quorum of three is a split-brain waiting for a partition.
What this buys you before P05. Thirteen weeks of Raft is a large commitment. An hour here gives you the safety property the whole protocol is organised around, so that when you read §5.2's voting rules they read as consequences rather than as arbitrary detail.
W4 — The Answer That Is Quietly Wrong
w4_watermarks.py · 92 lines · stdlib only
Sixty thousand events over a simulated day, counted per hour three ways. 88% arrive within seconds; a 12% "offline sync" cohort arrives 20 minutes to 5 hours late — the bimodal pattern any mobile product has.
oracle (batch over the whole day) total abs error 0
bucketed by PROCESSING time total abs error 1,139 (1.9% of events misattributed)
bucketed by EVENT time, delay=0 total abs error 13,292 (6,646 dropped as late)
The 1.9% is not noise. It lands entirely on the offline-sync cohort — one identifiable segment of users, systematically miscounted, every single day. That is the difference between an error and a bias, and it is why event time exists.
The frontier, and its dead zone
delay completeness dropped
0 min 88.92% 6,646
1 min 88.97% 6,619
5 min 89.08% 6,552
15 min 89.43% 6,342
1 h 91.69% 4,984
5 h 100.00% 0
This is not the concave curve you expect. Completeness is essentially flat from 0 to 15 minutes (+0.5 percentage points) and then climbs to 100% only as the delay reaches five hours.
The shape is set entirely by the lateness distribution, not by any property of windowing. With a bimodal stream there is no knee, because there is nothing in the middle of the distribution to recover. The operational consequence is uncomfortable: accept ~89% completeness, or wait five hours. Tuning the delay to 5 or 15 minutes — the usual instinct — lands in the dead zone and buys latency for nothing.
Measure your own lateness distribution before choosing a delay. The exchange rate between completeness and latency is a property of your data, not your code.
W5 — Autodiff Is Bookkeeping
w5_autodiff.py · 120 lines · stdlib only
Scalar reverse-mode autodiff, gradient-checked, then used to train a small network.
The whole engine is the graph plus a topological traversal. The derivative rules are
one line each; the engineering is +=:
def back():
# ACCUMULATE (+=), never assign. If a value is used twice, both
# contributions must sum. Assigning here makes gradients too small by
# an exact integer factor -- which looks like a learning-rate problem
# and gets "fixed" by raising the learning rate.
self.grad += out.grad; o.grad += out.grad
TEST 1 — gradients match central finite differences
worst relative error over 600 partials: 1.09e-08
TEST 2 — the accumulation bug, demonstrated
d(x*x)/dx at x=3: got 6.0, correct 6.0 -> OK
With `=` instead of `+=` in __mul__ this prints 3.0: exactly half.
An integer-factor error is the signature of a missing accumulation.
TEST 3 — train something.
epoch 0 mse 0.046711
epoch 600 mse 0.002794
final mse 0.002794 over 25 parameters
Reverse mode computed all 25 gradients in ONE backward pass.
Finite differences would need 26 forward passes for the same.
The diagnostic worth memorising
An exact integer factor in a gradient error means a missing accumulation. Not 1.03× off — exactly 2×, or exactly 3×. Floating-point bugs give you noise; structural bugs give you integers. When your P13 gradients disagree with PyTorch by precisely 2.0, you know where to look before you start reading code.
The second habit the script builds is zero_grad. Forget it and gradients accumulate
across epochs, the effective learning rate grows without bound, and training silently
diverges or stalls — with no error anywhere.
W6 — The Popularity Trap
w6_popularity.py · 105 lines · imports tools/metrics.py
Four recommenders over a Zipf(1.0) catalogue, scored on accuracy and catalogue health.
1500 users, 2000 items, Zipf(alpha=1.0) popularity, k=10
recommender NDCG@10 recall@10 coverage Gini novelty
-------------------------------------------------------------
random 0.0065 0.0065 0.9995 0.207 10.86
bestseller 0.1918 0.1424 0.0050 0.000 3.32
topic-only 0.0325 0.0322 0.0400 0.019 6.32
topic+pop 0.0761 0.0623 0.0400 0.019 6.32
The bestseller list wins NDCG outright — 2.5× the best personalised recommender — while showing the same ten items to all 1,500 users. Coverage 0.0050: ten items out of two thousand. Ship it and the accuracy dashboard is green and the catalogue is dead.
This is P18 arriving as a product decision: at \(\alpha = 1.0\) the top 1% of items carries 53% of engagement, so trivially exploiting popularity is a very strong accuracy strategy.
The subtler finding
topic-only and topic+pop have identical coverage (0.0400), Gini (0.019) and
novelty (6.32) despite NDCG differing by +134%.
They are permutations of the same candidate pool, and a permutation cannot change which items were shown. Catalogue metrics are blind to ranking; they only see retrieval.
The consequence is diagnostic and useful: the popularity trap is sprung at the retrieval stage, not the ranking stage. If coverage is collapsing, look at candidate generation — no amount of reranking will move it.
And the control that makes the frontier legible: random has 0.9995 coverage and 0.0065
NDCG. Coverage alone is not a goal either. The suite is the measurement; no single
column is.
What All Six Have in Common
Not an accident of selection — this is the shape the track is trying to install.
1. Each has a measurement that contradicts a plausible belief. Bloom filters "only help misses" (they help hits 17×). The completeness curve "has a knee" (it has a dead zone). Coverage responds to ranking (it cannot). Every one of those beliefs is reasonable, and every one is wrong in a way only a counter reveals.
2. Each instruments a mechanism, not just an outcome. block_reads and
bloom_rejects, not just latency. Attention entropy, not just loss. Distance counts, not
just recall. The outcome tells you something happened; the mechanism tells you why,
and only the second lets you predict the next system.
3. Each is small enough to hold entirely in your head. 76–120 lines. That is the size at which you can be certain there is no hidden effect, which is what makes a surprising result trustworthy rather than suspicious.
4. Three of the six contain a bug I made and kept. W1's missing initialisation scale, W2's wrong conclusion about hits, W4's wrong claim about the curve shape, W6's wrong comparison — all written confidently, all contradicted by the output, all now documented in place. That ratio is normal, and hiding it would misrepresent what the work is like.
5. None of them is the project. They are 45-minute prospectuses. The projects are where you find the results nobody has written down for you.
References
- Vaswani, A. et al. Attention Is All You Need. NeurIPS 2017. — W1
- Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970. — W2
- O'Neil, P. et al. The Log-Structured Merge-Tree. Acta Informatica 33, 1996. — W2
- Ongaro, D., Ousterhout, J. In Search of an Understandable Consensus Algorithm. USENIX ATC 2014. §5.2 on the voting rules W3 demonstrates.
- Gifford, D. K. Weighted Voting for Replicated Data. SOSP 1979. — W3
- Akidau, T. et al. The Dataflow Model. VLDB 8(12), 2015. — W4
- Baydin, A. G. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 18, 2018. — W5
- Karpathy, A. micrograd. github.com/karpathy/micrograd. W5 is the same idea; read it after writing yours.
- Cañamares, R., Castells, P. Should I Follow the Crowd? SIGIR 2018. Why popularity baselines are so hard to beat — W6, analysed properly.
- Steck, H. Calibrated Recommendations. RecSys 2018. — W6
Hands-On Builds — Fifteen Lego-Block Pages
One page per project. Each builds the project's machinery out of numbered, independently runnable blocks — a block is a lego piece that constructs one mechanism, proves it works on its own, and hands what it made to the next one — and then an assembly that wires every block into a single working system and measures it.
These are not summaries of the project pages. They are the executable core of each project, small enough to run in seconds and complete enough that the assembly does something real: a transformer that trains to 0.11 nats, a Raft cluster that elects exactly one leader under partition, a recommender that measures why three of its four models lose to a bincount, an autodiff engine that matches PyTorch to 5.6e-17 over 300 optimisation steps.
Every number on every page was produced by running the code. The pages are
generated by handson/build_pages.py, which slices the code
out of each script and captures that script's real output. Nothing is
transcribed by hand, so a result that drifts cannot silently stay stale on the
page — regenerating rewrites it.
Where a measurement contradicted what I expected, the contradiction stayed in. Six of these pages document a wrong prediction and the experiment that corrected it, because that sequence is the thing the track is actually teaching.
Each page also carries a deep-dive section written for someone who already knows the mechanism: the design space with alternatives, the latency and memory-hierarchy arithmetic that constrains it, what changes on GPU/TPU/SSD/HDD, the advanced algorithms the toy version stands in for, how the project connects to the other fourteen, the failure modes at scale, and the primary sources. The Concept Map collects the eight mechanisms that recur across all fifteen projects and shows where each one appears.
Contents
| Page | What it builds | Blocks | Script | Project |
|---|---|---|---|---|
| P01 — Transformer | Attention from a dot product, then a language model that trains. | 9 | h01_transformer.py (330 lines) | spec |
| P02 — Approximate nearest neighbours | Why a random graph fails, why a navigable one works, and what recall costs. | 7 | h02_ann.py (169 lines) | spec |
| P03 — Vector database | An index is not a database: filtering, persistence, and a planner that chooses. | 6 | h03_vectordb.py (200 lines) | spec |
| P04 — LSM storage engine | Durability first, then the Bloom filter that makes reads survivable. | 7 | h04_lsm.py (220 lines) | spec |
| P05 — Distributed key-value store | Leader election and log replication under loss, duplication and partition. | 7 | h05_distkv.py (239 lines) | spec |
| P06 — MapReduce framework | Why a restricted programming model is what makes fault tolerance possible. | 6 | h06_mapreduce.py (180 lines) | spec |
| P07 — Stream processing | Event time, watermarks, and the accuracy/latency dial made explicit. | 7 | h07_streaming.py (222 lines) | spec |
| P08 — Recommender system | Three of four models lose to popularity. This page is about why. | 8 | h08_recsys.py (277 lines) | spec |
| P09 — Recsys simulator | Feedback loops, position bias, and a bandit that loses for a findable reason. | 7 | h09_simulator.py (270 lines) | spec |
| P10 — A/B testing platform | Peeking, SRM, CUPED, and the type-M error that inflates every underpowered win. | 8 | h10_abtest.py (300 lines) | spec |
| P11 — Programming language | A lexer, a Pratt parser, two backends, and a textbook optimisation that loses. | 8 | h11_language.py (525 lines) | spec |
| P12 — Operating system kernel | Frames, page tables, Bélády's anomaly, scheduling, and a race you can watch. | 8 | h12_kernel.py (339 lines) | spec |
| P13 — Tensor framework | Reverse-mode autodiff that matches PyTorch to 5.6e-17 over 300 steps. | 8 | h13_tensor.py (383 lines) | spec |
| P14 — Hardware-aware ML | Two measured numbers predict a workload, and two modelling bugs get caught. | 7 | h14_hardware.py (337 lines) | spec |
| P15 — The integrated system | Five earlier projects, imported rather than reimplemented, wired into one service. | 6 | h15_integrated.py (269 lines) | spec |
109 blocks, 4,260 lines of runnable Python, 356 KB of generated pages.
Running them
cd handson
python3 h01_transformer.py # every block, then the assembly
python3 h08_recsys.py --block 5 # one block and its prerequisites
python3 h14_hardware.py --quiet # the assembly only
python3 build_pages.py # regenerate all fifteen pages
python3 build_pages.py h04 # regenerate one
Only numpy is required. Three pages use torch if it is installed, to check
their own results against a reference implementation, and skip that check
cleanly if it is not.
The cross-cutting view
The Concept Map is the companion to these pages. Fifteen projects share far fewer than fifteen mechanisms — an approximate test guarding an exact one, an atomic pointer swap for durability, the memory hierarchy as the cost model, the arithmetic of maxima, two-stage retrieval, restriction as the enabler of recovery, the accuracy/latency dial, and amortising a fixed cost. That page indexes every appearance of each.
Where these sit in the track
Walkthroughs are six 40–60 minute miniatures that each end in one surprising finding. These hands-on pages are longer and structured differently: they cover a whole project's mechanisms rather than one idea, and they are meant to be read alongside the project page while you build the real version.
The reading order that works: skim the project page for scope, run the hands-on script to see the mechanisms move, read the hands-on page for what each one costs, then start the real build with the scaffold. The hands-on version is deliberately the smallest thing that demonstrates the mechanism — the project page's milestone list is what turns it into a system.
The self-corrections
Kept deliberately, with the measurement that forced each one:
| Page | What I predicted | What the measurement said |
|---|---|---|
| P08 | more negatives improves ranking | it made it worse; regularisation was the real variable |
| P08 | word2vec's pop^0.75 sampling would help | 0.42x — it divides out the signal the data is made of |
| P09 | Thompson sampling beats greedy | it loses, because exploring at rank 0 costs 34% of all attention |
| P11 | a bytecode VM beats a tree-walker | 0.85x, until the dispatch chain was reordered by frequency |
| P12 | a page-fault cliff at the working-set size | no cliff — a mixture of reference distributions has no knee |
| P13 | a missing un-broadcast silently shrinks the gradient | += raises; the silent bug is =, which reshapes the parameter |
| P14 | one roofline for the machine | one per dtype; an fp64 ceiling made fp32 kernels look superluminal |
Each page links back to its full project specification, and every project page links forward to its hands-on build.
The Concept Map — What Recurs Across All Fifteen
Fifteen projects, but far fewer mechanisms. The same handful of ideas appear at different altitudes, wearing different names, and the point of building all fifteen is that you stop seeing fifteen problems and start seeing one problem with fifteen surfaces.
This page is the index of that claim. Every row is a mechanism that appears in three or more projects, with the specific place it appears and the name it goes by there.
Contents
- The eight recurring mechanisms
- One: an approximate test guarding an exact one
- Two: atomic pointer swap as the durability primitive
- Three: the memory hierarchy is the cost model
- Four: maxima, tails and fan-out
- Five: two-stage retrieve-then-rank
- Six: restriction as the enabler of recovery
- Seven: the accuracy/latency dial
- Eight: amortising a fixed cost
- The numbers every project leans on
- Where each project's real difficulty lies
- What the seven refuted predictions have in common
The eight recurring mechanisms
| Mechanism | Appears in | Called there |
|---|---|---|
| Approximate test guarding an exact one | P02, P04, P03, P08, P14 | PQ codes, Bloom filter, planner estimate, candidate generation, quantisation |
| Atomic pointer swap for durability | P03, P04, P06, P07, P05 | segment flush, WAL + rename, task commit, checkpoint, log commit |
| Memory hierarchy as the cost model | P02, P04, P12, P13, P14, P01 | pointer chasing, block reads, paging, activation memory, roofline, KV cache |
| Maxima and tails | P06, P10, P15, P05 | stragglers, multiple comparisons, tail at scale, slowest replica in quorum |
| Two-stage retrieve-then-rank | P02, P03, P08, P01 | ANN + rerank, planner + scan, candidates + ranker, speculative decoding |
| Restriction enabling recovery | P06, P07, P05, P13 | pure map/reduce, deterministic replay, state machine replication, pure ops |
| Accuracy/latency dial | P07, P02, P09, P10 | watermark + grace, ef, exploration rate, power vs runtime |
| Amortising a fixed cost | P04, P03, P05, P01, P12 | group commit, segment batching, batched log append, batched decode, TLB huge pages |
One: an approximate test guarding an exact one
The pattern: an exact operation is expensive because it touches slow storage. Put a cheap, approximate, one-sided test in fast memory in front of it.
| Project | Cheap test | Expensive thing avoided | Error direction |
|---|---|---|---|
| P04 | Bloom filter, 10 bits/key | a block read per run | false positive only — never says "absent" wrongly |
| P02 | PQ code, 64 B | full-precision distance | approximate both ways, bounded by quantisation error |
| P03 | cardinality estimate | choosing the wrong strategy | either way, and a 10× error picks wrong |
| P08 | candidate generator | scoring 10⁶ items with the ranker | misses are unrecoverable — a hard ceiling |
| P14 | int8 weights | fp32 memory traffic | bounded by quantisation error |
The design question is always the same: what is the error direction, and is it recoverable? A Bloom filter's error costs one wasted read. A candidate generator's error costs the result entirely. That asymmetry decides how much budget each deserves — and it is why P08 block 7's ceiling is the first thing to check when a recommender misses.
Two: atomic pointer swap as the durability primitive
Every durable system in this track reduces to the same three lines: write the new state somewhere else, make it durable, then flip one pointer atomically.
write payload to a temporary location
fsync (~90-105 us, measured)
rename / CAS the pointer (atomic by construction)
| Project | New state | The pointer |
|---|---|---|
| P04 | SSTable file | the manifest's run list |
| P03 | segment directory | the segment-list snapshot |
| P06 | part-N.attempt2.tmp | rename to part-N |
| P07 | checkpoint state + offset | the checkpoint pointer |
| P05 | replicated log entry | commitIndex |
Once you see it, "exactly-once" stops being mysterious. P06 runs tasks at least once and renames atomically, so the effect is once. P07 processes records twice after a crash and advances state-and-offset together, so the effect is once. The guarantee is never about delivery; it is about the pointer.
Three: the memory hierarchy is the cost model
Measured on this machine (numbers.md), the ratios that decide every design in the track:
| Level | Latency | Relative to L1 |
|---|---|---|
| L1 | 0.91 ns | 1× |
| L2 | 5.94 ns | 6.5× |
| DRAM | 121.10 ns | 133× |
| Syscall | 127.59 ns | 140× |
| Context switch | 1,383--1,706 ns | ~1,700× |
fsync | 90--105 µs | ~10⁵× |
| NVMe random read | 20--100 µs | ~10⁵× |
| HDD seek | ~10 ms | ~10⁷× |
| Cross-region RTT | ~100 ms | ~10⁸× |
Eight orders of magnitude, and the boundary you cross determines the architecture:
- Cross DRAM → the LSM exists (P04), tiling exists (P14), quantisation exists.
- Cross
fsync→ batching and group commit are mandatory (P04, P03, P05). - Cross HDD seek → graph search becomes impossible and IVF wins (P02).
- Cross the network → consensus costs a round trip and geography sets the floor (P05).
The corollary that catches everyone: the same code changes regime when the data grows. P14's assembly measures a 25 MB working set behaving as if it were free, and the same code at 100 MB paying full DRAM cost — with the roofline ratio crossing 1.0 in between.
Four: maxima, tails and fan-out
One piece of arithmetic, four appearances. If an event has independent probability \(p\) per trial and there are \(n\) trials, \(P(\text{at least one}) = 1 - (1-p)^n\).
| Project | \(p\) | \(n\) | Consequence |
|---|---|---|---|
| P06 | task is slow | tasks in the job | job time is a maximum; 1% at 10× inflates it 4.5× |
| P10 | metric is falsely significant | metrics tested | 20 metrics → 64% chance of a false winner |
| P15 | component exceeds p99 | fan-out width | at \(n\)=100, the median request contains a p99 event |
| P05 | replica is slow | quorum size | commit latency is the slowest in the quorum |
The mitigations rhyme too: redundancy plus cancellation. Backup tasks (P06), hedged requests (P15), larger quorums that can exclude a straggler (P05). And the statistical version — Bonferroni, Benjamini–Hochberg — is the same acknowledgement that \(n\) trials need a stricter per-trial bar.
Five: two-stage retrieve-then-rank
Cheap and wide, then expensive and narrow. Stage 2 can never exceed stage 1's ceiling, which is the single most useful diagnostic in any such system.
| Project | Stage 1 | Stage 2 | Ceiling measured in |
|---|---|---|---|
| P02 | graph traversal to ef candidates | exact distances | recall@ef |
| P03 | planner picks a strategy | scan or traverse | the strategy's own recall |
| P08 | popularity / ANN top-C | BPR model re-rank | block 7's ceiling column |
| P01 | draft model proposes \(k\) tokens | target model verifies | acceptance rate |
P08 block 7 makes the failure explicit: recall after re-ranking tracks the stage-1 ceiling exactly, and no ranker improvement can cross it. Before blaming a ranker, check whether the item was in the candidate set.
Six: restriction as the enabler of recovery
Each of these systems gives up expressiveness and gets fault tolerance in return. The restriction is the feature.
| Project | What you may not do | What that buys |
|---|---|---|
| P06 | read shared mutable state in map/reduce | any task may be re-run anywhere, any time |
| P07 | depend on processing-time order | deterministic replay from a checkpoint |
| P05 | apply commands out of log order | state machine replication |
| P13 | mutate a tensor an op depends on | the tape can be replayed backwards |
P06's assembly is the cleanest demonstration: byte-identical output under three different worker-kill schedules. Allow one impure map function and retry, speculation and rescheduling all become unsound simultaneously.
Seven: the accuracy/latency dial
In four projects, correctness is a parameter rather than a property, and the engineering task is to make the parameter explicit rather than accidental.
| Project | Dial | Fast end | Correct end |
|---|---|---|---|
| P07 | watermark lag + grace | 93.6% in 2 s | 100% in 100 s |
| P02 | ef | recall 0.29 at 192 µs | recall 0.81 at 856 µs |
| P09 | exploration rate | greedy: most clicks, 96 items alive | UCB1: 600 items alive, −16% clicks |
| P10 | sample size | fast, underpowered, 8× inflated estimates | slow, powered, honest |
The mature version of this is not picking a value; it is shipping the dial and labelling each setting with its measured error, as P15 block 6 does when it reports the dashboard's bias alongside its latency.
Eight: amortising a fixed cost
When an operation has a large fixed cost and a small marginal one, batching is not an optimisation — it is the design.
| Project | Fixed cost | Batch | Amortisation |
|---|---|---|---|
| P04 | fsync ~100 µs | memtable → SSTable | 1000× |
| P03 | fsync + segment metadata | segment of vectors | ~1000× |
| P05 | round trip + fsync | group commit of log entries | 10--100× |
| P01 | reading all weights (memory-bound decode) | continuous batching | up to batch size |
| P12 | page walk on TLB miss | huge pages | 512× TLB reach |
| P02 | DRAM latency 121 ns | batched queries → MLP | ~10× effective |
Note the last two: the "batch" is not always requests. Huge pages batch translations; memory-level parallelism batches outstanding misses. The pattern is the same — pay the fixed cost once for many units of work.
The numbers every project leans on
If you memorise one table from this track, make it this one — every design decision above is a comparison between two of its rows.
| Quantity | Value | Where it decides something |
|---|---|---|
| L1 / L2 / DRAM | 0.91 / 5.94 / 121.10 ns | P02, P04, P14 |
| Ratio L1:L2:DRAM | 1 : 6.5 : 133 | every data-structure layout choice |
| Syscall | 127.59 ns | P12, io_uring's reason to exist |
| Context switch | 1,383--1,706 ns | P12 quantum sizing |
fsync | 90--105 µs | P04, P03, P05 |
| Measured bandwidth | 50--99 GB/s (working-set dependent) | P14 roofline |
| fp32 / fp64 peak | 1937 / 469 GFLOP/s | P14 — and one roofline per dtype |
| Ridge point, fp32 | ~39 FLOP/byte | which wall a kernel hits |
| Bloom FPR at 10 bits/key | 0.0082 | P04, proofs.md P3 |
| Quorum intersection | \(2Q > N\) | P05, proofs.md P4 |
| Sample size scaling | \(n \propto 1/\delta^2\) | P10 — halve the MDE, quadruple the traffic |
Where each project's real difficulty lies
Not where the tutorials put it.
| Project | Looks hard | Actually hard |
|---|---|---|
| P01 | the attention formula | the causal mask, and knowing the entropy floor |
| P02 | the graph algorithm | measuring contrast before trusting any recall number |
| P03 | the index | the planner's cardinality estimate |
| P04 | compaction | proving durability with a real crash test |
| P05 | leader election | §5.4.2 and membership changes |
| P06 | the programming model | stragglers and skew |
| P07 | windowing | choosing and stating the completeness assumption |
| P08 | the model | the split, the baseline, and the head mass |
| P09 | the bandit | making the user model falsifiable |
| P10 | the t-test | the stopping rule and SRM |
| P11 | the parser | dispatch, and the host's cost model |
| P12 | the kernel mechanisms | that policy barely matters when the working set does not fit |
| P13 | the chain rule | += on diamonds, and un-broadcasting |
| P14 | the roofline formula | getting the ceiling and the byte count right |
| P15 | the integration | the seams, and closing all three loops |
What the seven refuted predictions have in common
Every one of them (listed in the index) was a case of importing a conclusion without importing the conditions that made it true:
- P08's
pop^0.75— a constant that is correct in word2vec, where discounting frequency is the goal, copied into a domain where popularity is the signal. - P09's Thompson sampling — a result proven for single-action bandits, applied to a slate where 34% of attention sits in one slot.
- P11's bytecode VM — a design whose benefit comes from a jump table, hosted in a language with no jump table.
- P12's working-set cliff — a model of a single phase, applied to a mixture of phases.
- P14's single roofline — a machine constant that is actually a per-dtype constant.
- P13's broadcast failure mode — an assumption about numpy's behaviour that was simply never run.
- P08's negative count — folklore repeated without a control.
The generalisable habit: when you import a result, import its conditions and check that they hold. Every one of these was caught by a measurement that took minutes, and none would have been caught by reading more carefully.
Start with the hands-on index, or go straight to a project's page from the table above.
Operating Model
The weekly rhythm, the time allocation, the recurring thinking exercises, and the guardrails. This is the part of the plan that runs every week for 130 weeks.
Table of Contents
- The Weekly Unit
- The Weekly Allocation
- When To Adjust the Allocation
- A Week, Concretely
- Thinking Exercises
- Breadth-Control Guardrails
- The Three-Slot Rule
- Weekly Review
- References
The Weekly Unit
Weeks 1–12 are written out in First 12 Weeks; from week 13 you produce them yourself with The Week Generator.
Every week produces six things. Not five, not "roughly". If a week ends without all six, the week is incomplete and the reason goes in the log.
| # | Output | Why it is non-negotiable |
|---|---|---|
| 1 | One primary objective | Written on Monday, one sentence. A week with two objectives has none |
| 2 | Implementation work | Code that runs. The largest block of the week |
| 3 | Limited required reading | Bounded, tied to the current milestone. Reading is an input, never an output. Protocol: How To Read |
| 4 | One experiment or test | With a prediction recorded before it runs |
| 5 | One written reflection | 200–400 words. What I expected, what happened, what I learned |
| 6 | One concrete deliverable | Something that exists on disk and did not exist Monday |
The reflection is the one that gets skipped and the one that carries the compounding. Over 130 weeks it becomes a 40,000-word record of your own reasoning improving, and it is the only artifact in this program that cannot be reconstructed later.
Reading never completes anything. A week whose deliverable is "read the Raft paper" is a failed week. The deliverable is "election timeout implemented and tested"; the paper is how you got there.
The Weekly Allocation
At 11 hours per week:
| Activity | Share | Hours | What it covers |
|---|---|---|---|
| Foundational reading | 15% | 1.65 | Papers, book chapters, source of other systems |
| Implementation | 45% | 4.95 | Writing code. The core |
| Experimentation and benchmarking | 20% | 2.20 | Running, measuring, plotting |
| Technical writing | 10% | 1.10 | Notebook entries, report sections |
| Review, debugging, reflection | 10% | 1.10 | Including the weekly review and the 10-minute review queue |
Two observations that make this allocation work:
Experimentation is a separate budget from implementation. If it is not, it does not happen — measurement always loses to the next feature when they share a bucket. Two hours a week, protected, is what makes this a research journey rather than a build log.
Writing is 10% and it is scheduled, not opportunistic. One hour a week produces a report per project without a crunch at the end. Writing at the end of a project means writing about work you have forgotten the details of.
When To Adjust the Allocation
The default is not right for every project. Adjust deliberately, and record it in the weekly log so that a slow week is diagnosable.
| Situation | Adjustment | Applies to |
|---|---|---|
| Mathematically heavy start | Reading 15%→30%, implementation 45%→35%, for the first 2 weeks only | P13 Phase I (chain rule, Jacobians), P10 (power and inference) |
| Deep distributed theory | Reading 15%→25% for weeks 1–3 of the project | P05 (Raft, FLP, linearizability) |
| Debugging-dominated phase | Debugging 10%→25%, taken from implementation | P05 mid-project, P12 boot phase |
| Experiment-heavy phase | Experimentation 20%→35%, taken from implementation | Final 2 weeks of every project |
| Report weeks | Writing 10%→40% | Final week of every project |
| A stage-boundary week | Review 10%→100% for one session | M7, M15, M22, M26, M31 |
Never adjust implementation below 30% for more than two consecutive weeks. Below that you are studying, not building, and the failure mode this whole program exists to prevent has quietly resumed.
A Week, Concretely
An example week at 11 hours, split as 2 hours on four weekdays and 3 hours on a weekend day. This is a shape, not a prescription — adapt it to when you actually have energy, which for most people is not Thursday evening.
| Session | Hours | Content |
|---|---|---|
| Mon | 2.0 | Write the week's objective (10 min). Read the milestone's assigned paper section (1 h). Implementation start (50 min) |
| Tue | 2.0 | Implementation |
| Wed | 2.0 | Implementation, ending with the code in a state that can be measured |
| Thu | 2.0 | Write the experiment prediction first (15 min), then run the experiment, then record results (1 h 45 min) |
| Sat | 3.0 | Debugging and cleanup (1 h) · notebook entry and reflection (1 h) · weekly review and next week's objective (30 min) · buffer (30 min) |
Three properties of this shape matter more than the specific days:
- Reading is at the start of the week and bounded. Reading in the middle of an implementation block always expands to fill it.
- The experiment has its own session and begins by writing the prediction. If the prediction and the run happen in the same session, write the prediction in a file and commit it before running anything.
- The weekend session ends with next week's objective already written, so Monday starts with action rather than orientation. Deciding what to do costs more energy than doing it.
The 30-minute session
Some weeks you get 30 minutes instead of 2 hours. There is a defined use for it, so that a short session is not a lost one:
- Re-read the last notebook entry (5 min)
- Do exactly one thing: fix one test, write one function, plot one graph (20 min)
- Update the log with where you stopped and what is next (5 min)
Step 3 is what makes it worth doing. See the resumption cost.
Thinking Exercises
Twelve questions. Not a checklist to run weekly — that turns them into a ritual. Instead, each is attached to a trigger, so it fires when it is useful.
| Question | Trigger |
|---|---|
| How would I solve this if the canonical solution did not exist? | Before reading any paper about the thing you are building. This is step 3 of the loop |
| Which assumption is doing the most work? | When a design feels obviously right. The obviousness is usually an unexamined assumption |
| What scale or workload breaks this design? | Before every "it works" claim |
| What result would prove my idea wrong? | Before every experiment. If you cannot answer, the hypothesis is not falsifiable and the experiment is theatre |
| What variable am I failing to measure? | When results are unexplained. In P02 it was distance count; in P07 it was state size. There is almost always one |
| Is the bottleneck computational, algorithmic, architectural, or operational? | Before optimising anything. Four different answers, four different fixes |
| What is hidden by the current abstraction? | When something is unexpectedly slow, or unexpectedly fast |
| Can I reduce this to a smaller model? | When stuck for more than two sessions. Almost every bug reproduces at 1/100 scale |
| What happens under skew, failure, concurrency, and partial information? | Before declaring any component done |
| Which performance gain is merely moving cost elsewhere? | After every speedup. Caching moves cost to memory; batching moves it to latency; async moves it to complexity |
| Can another engineer reproduce this result? | Before writing any report |
| What did the failed experiment teach me? | After every failure, before moving on. The answer is never "nothing" |
The one to internalise first
Which performance gain is merely moving cost elsewhere?
Almost every optimisation in this journey is a relocation, not an elimination. Bloom filters move disk I/O to RAM. Compaction moves read cost to write cost. Batching moves latency to throughput. Quantization moves accuracy to speed. Caching moves consistency to speed. Speculative execution moves wasted CPU to reduced tail latency.
An engineer who asks "where did the cost go?" after every improvement develops a different and more accurate model of systems than one who collects speedups. Ask it every time.
Breadth-Control Guardrails
Fourteen failure modes, each with the mechanism that prevents it. A guardrail without a mechanism is a good intention.
| Failure mode | Preventing mechanism |
|---|---|
| Starting multiple large projects simultaneously | The three-slot rule, enforced by the weekly log's three named lines |
| Abandoning a project when it gets hard | The two-week stall rule: scope cut and a written postmortem, never silence |
| Spending weeks collecting resources | Reading is capped at 15% and every reading is tied to a named milestone. No general reading lists |
| Turning each project into a production-grade product | Every project page has an explicit Scope Boundaries section listing what is out |
| Hiding mechanics behind frameworks | Every project page has a permitted-library line naming what may and may not be imported |
| Optimising without a baseline | Step 8 of the loop precedes step 11. Exit criteria require a baseline |
| Benchmarking only unrealistic workloads | Every project's experiments include a skewed or adversarial distribution |
| Reporting average latency without tails | bench.py reports p50/p95/p99 by default and there is no mean-only mode |
| Ignoring correctness while measuring performance | No performance work while a correctness test is red. Exit criteria list correctness first |
| Treating complexity as originality | The scorecard rewards falsified hypotheses and penalises unmeasured components |
| Confusing reading with progress | The weekly deliverable can never be a reading |
| Skipping written analysis | Writing has its own 10% budget and reports are exit criteria |
| Starting extensions before exit criteria | Extensions are locked. No downstream project ever depends on one |
| Constantly changing languages and infrastructure | Languages assigns one per project with a stated reason |
The Three-Slot Rule
At any moment you have exactly three slots, and the weekly log has exactly three lines for them:
PRIMARY : the one implementation project. All milestone work happens here.
SECONDARY : one small maintenance or writing task from an EARLIER project.
READING : one bounded thread that directly supports PRIMARY.
Rules:
- PRIMARY is one project. Not one project plus a small experiment on another.
- SECONDARY is bounded at 1 hour/week and must be from a project already past its exit criteria — fixing a bug someone reported, writing a blog post, improving a README. It is not a second project; it is maintenance.
- READING must name the milestone it serves. "Reading about consensus" is not a valid entry. "Raft §5.4.2 for milestone 7" is.
- A fourth interest goes on the parking list, one line, and is not touched. Review the parking list at stage boundaries; most entries will have stopped being interesting, which is the point.
The parking list is the pressure valve. The failure mode is not having ideas — it is acting on all of them. Writing an idea down and not doing it is a skill, and the list makes it a mechanical act rather than an act of will.
Weekly Review
Thirty minutes at the end of the last session of the week. Written, in
notebook/weekly/YYYY-WW.md.
## Week NN — <project> milestone <n>
OBJECTIVE (set Monday) : ...
MET? : yes / partly / no — and why
SLOTS
PRIMARY : ...
SECONDARY : ... (≤1 h)
READING : ... (names its milestone)
HOURS plan 11 / actual __
reading __ · implementation __ · experiment __ · writing __ · debug __
DELIVERABLE : <what exists now that did not on Monday>
EXPERIMENT
prediction : ... (written before the run)
result : ...
verdict : confirmed / falsified / inconclusive
REFLECTION (200–400 words)
What surprised me. What I got wrong and why. What I avoided because it was hard.
NEXT WEEK'S OBJECTIVE : <one sentence>
PARKING LIST ADDITIONS : ...
The "what I avoided because it was hard" line is the most valuable one. It is where you catch yourself doing the easy milestone out of order, polishing instead of debugging, or reading instead of writing. Over a 130-week program, that line is the early-warning system for drift, and drift — not difficulty — is what ends journeys like this.
References
- Ericsson, K. A., Krampe, R. T., Tesch-Römer, C. The Role of Deliberate Practice in the Acquisition of Expert Performance. Psychological Review 100(3), 1993. The source of the "practice with immediate feedback on a specific weakness" structure that the experiment-per-week rule implements.
- Hamming, R. W. You and Your Research. Bell Communications Research, 1986. On working with the door open, and on the compounding of consistent effort.
- Newport, C. Deep Work. Grand Central, 2016. The case for protected blocks and against fragmented attention; the source of the fixed-session structure.
- Boice, R. Professors as Writers. New Forums Press, 1990. The empirical finding that short daily writing beats binge writing on both volume and quality — the basis for the scheduled 10%.
- Lampson, B. W. Hints for Computer System Design. SOSP 1983. "Handle normal and worst case separately" applied to your own schedule: the 30-minute session is the worst-case path, designed rather than improvised.
- Allen, D. Getting Things Done. Penguin, 2001. The parking list is a next-actions list with one slot; the mechanism is the same and so is the reason it works.
The Research Notebook
Fourteen fields, each explained: what it is for, what a good answer looks like, and the specific way each one is usually filled in badly.
- The copyable template:
templates/notebook.md - A completed real example: Worked Notebook Entry — read this first if you only read one thing
Table of Contents
- How To Use It
- 1. Problem
- 2. Constraints
- 3. Existing Approach
- 4. My Naive Design
- 5. Predictions
- 6. Hypothesis
- 7. Experimental Setup
- 8. Baseline
- 9. Results
- 10. Surprises
- 11. Failure Analysis
- 12. Next Experiment
- 13. Generalization
- 14. Reproducibility
- The Ordering Rule
- References
How To Use It
One entry per experiment, not per project. A Medium project produces 4–8 entries;
notebook/ accumulates roughly 80 of them over the journey.
Entries are written in order, and sections 1–8 are written before the experiment runs. That is the entire mechanism. A notebook filled in afterwards is a report with extra headings; a notebook filled in beforehand is an instrument that catches you being wrong.
Commit each entry as soon as sections 1–8 exist, before running anything. The commit timestamp is what makes the prediction credible — to a reader, and more importantly to you in six months when you are tempted to remember having predicted correctly.
1. Problem
What is it for: forcing you to state the problem independently of any solution.
A good answer describes what breaks in the world if this system does not exist, in terms of an actual quantity. "Exact kNN over 10M items at 5,000 QPS is 5×10¹⁰ distance computations per second, which no single machine can do."
The bad version names a technology. "I need to implement HNSW" is not a problem statement, it is a solution statement, and writing it means sections 4 and 5 will be worthless because you have already chosen the answer.
Test: could someone who has never heard of the canonical solution understand what you need? If not, rewrite.
2. Constraints
What is it for: surfacing the assumptions that will later turn out to be doing all the work.
A good answer separates three kinds: hard (physics, the machine you have), chosen (design decisions you could revisit), and assumed (things you believe but have not verified). Label them. The third category is where the interesting failures come from.
The bad version lists only the hard constraints, so the assumed ones stay invisible until they break.
Test: the thinking exercise — which assumption is doing the most work? If it is not in this list, add it.
3. Existing Approach
What is it for: honestly recording what you know before you design, so section 4 is not contaminated and you can tell later how much was yours.
A good answer says exactly how much of the literature you have read, and stops reading at a stated point. From the worked example: "I read the abstract and the algorithm pseudocode, and then stopped before the neighbour-selection heuristic, on purpose, so that section 4 would be mine."
The bad version is a thorough summary of the canonical solution — after which your "naive design" is a half-remembered version of it, and the whole exercise is dead.
Test: did you stop reading somewhere deliberate, and did you write down where?
4. My Naive Design
What is it for: this is the single most important field in the template.
Reconstructing a system from its constraints is the skill this entire journey exists to build, and it is only trainable if you actually attempt the design before seeing the answer. Once you have read the paper you cannot un-read it, and the opportunity for that week is gone permanently.
A good answer is a real design: components, data structures, the algorithm, and — critically — the reasoning that led to it. The reasoning matters more than the design, because section 11 is going to diagnose the reasoning, not the code.
The bad version is one line, written grudgingly, so that the form is filled in.
Test: could you implement from it? And does it record why you chose each part?
The worked example's naive design was wrong in a specific, diagnosable way, and its recorded reasoning — "early insertions happen into a nearly-empty graph, so their edges are necessarily long" — is exactly what section 11 was able to take apart. A one-line design would have produced a one-line failure analysis.
5. Predictions
What is it for: calibration. Over 80 entries you learn how good your intuition actually is, which is information you cannot get any other way.
A good answer is a table of specific, quantified, falsifiable statements, each with a confidence level. "10–100× faster at n=10,000, high confidence."
The bad version is hedged. "It should be faster" cannot be wrong, so it teaches nothing.
Test: could each row be marked confirmed or falsified without argument? Track your hit rate across entries — a rate near 100% means your predictions are too safe.
In the worked example, two of five predictions survived. That ratio is healthy. If most of yours survive, you are predicting things you already know.
6. Hypothesis
What is it for: the difference between an experiment and a measurement.
A good answer has two parts, both mandatory:
H: ⟨a specific claim about a causal relationship⟩ Falsifier: ⟨the observation that would make me abandon it⟩
The bad version omits the falsifier, at which point you will unconsciously interpret whatever happens as support.
Test: can you describe, concretely, a result that would make you say "I was wrong"? If not, this is a measurement, which is fine — but label it as one and skip to section 7.
7. Experimental Setup
What is it for: making the result mean something to a reader, including future you.
A good answer records: hardware including current load, software versions, data including how it was generated, parameters including the ones you did not vary, the exact command, and the seed.
The bad version omits the load average and the library versions — the two things that most often explain an irreproducible number.
And validate the setup before running. The worked example caught a data generator whose independent variable did not vary (σ√d = 2.0 made "clustered" data statistically identical to uniform). That check cost ten minutes and saved a worthless run. Measure your independent variable to confirm it varies.
8. Baseline
What is it for: a number without a comparison is not a result.
A good answer names a baseline that is fair and explains why. The strongest baseline is usually a degenerate configuration of your own system — the intervention turned off — because it controls for implementation quality.
The bad version is a straw man: an unoptimised comparison chosen because you can beat it.
Test: would a skeptical reviewer accept this as the right comparison? Would you, if someone else's paper used it?
9. Results
What is it for: the data. Only the data.
A good answer is tables and plots with units, sample sizes, and uncertainty. No interpretation — interpretation is sections 10 and 11, and mixing them lets a conclusion smuggle itself in as an observation.
The bad version reports only the configurations that worked, or reports means without spread.
Always include: the verdict on each prediction from section 5, as a table. That is what closes the loop.
10. Surprises
What is it for: this is where the learning is, and it is the section most often left empty.
A good answer names each result that contradicted section 5, and quantifies the gap. A surprise you cannot quantify is a vague feeling.
The bad version is "everything went as expected", which is nearly always false and means you did not look at the secondary metrics.
If this section is genuinely empty, ask the thinking exercise: what variable am I failing to measure? The worked example's biggest finding came from the distance counter, not from recall or latency.
11. Failure Analysis
What is it for: turning a wrong prediction into a mechanism.
A good answer names the specific design decision that caused the failure, and traces the causal chain. Not "clustering is hard" but "the degree cap prunes by raw distance; intra-cluster distance is 0.521 and inter-cluster is 1.413, so pruning deletes every bridge deterministically."
The bad version is a category. "Cache effects", "Python is slow", "high dimensions are hard" — these are labels, not analyses, and none of them tells you what to change.
Test: does the analysis point at a specific line you could change? And is it specific enough to be wrong? A failure analysis that cannot be refuted is a story.
12. Next Experiment
What is it for: choosing the cheapest experiment that resolves the most uncertainty.
A good answer gives one experiment with its cost, its prediction, and what each outcome would mean. "E1 — instrument inter-cluster edge fraction. Cost: 40 minutes. Prediction: below 1% after pruning. If confirmed, the analysis above is established; if not, my explanation is wrong."
The bad version is a list of six things you might do, in no order, with no costs.
The rule: do the cheap decisive experiment before the expensive thorough one. A 40-minute test that settles the main question beats a three-hour test that improves a number.
13. Generalization
What is it for: the step from "I ran an experiment" to "I know something".
A good answer states where the result should hold, where it should not, and what it predicts about systems you have not built. That last part is what makes it knowledge rather than a data point.
The bad version over-generalises from one dataset on one machine.
Test: does it make a falsifiable claim about something outside this experiment? The worked example's "recall that looks fine on a uniform synthetic benchmark and degrades on a real clustered corpus, in a way that adding efSearch does not fix" is a prediction about other people's production systems. That is the shape to aim for.
14. Reproducibility
What is it for: the criterion that separates a result from an anecdote.
A good answer is a block someone else can execute:
commit : <sha>
command : <exact invocation>
seed : <value>
runtime : <expected>
output : <where the raw data landed>
expect : <headline number ± tolerance>
The bad version says "run the script".
Test: delete your build directory, follow your own instructions, and check the number. Do this before writing the report, not after someone asks.
The Ordering Rule
The template's power is entirely in the ordering, so it is worth stating once more as a rule:
Sections 1–8 are written and committed before the experiment runs. Sections 9–14 are written after.
Every mechanism in this journey that produces the skills you asked for depends on that line:
- Writing 4 before reading trains reconstruction.
- Writing 5 before running trains calibration.
- Writing 6 before running makes it an experiment rather than a demonstration.
- Writing 8 before running stops you from picking the baseline you beat.
Fill it in afterwards and you have a well-organised report that taught you nothing new about your own reasoning. The commit timestamp on sections 1–8 is not bureaucracy — it is the only thing standing between you and the near-universal human tendency to remember having predicted the thing that happened.
References
- Feynman, R. P. Cargo Cult Science. Caltech, 1974. "The first principle is that you must not fool yourself — and you are the easiest person to fool." Sections 5, 6 and 8 are that principle turned into a form.
- Platt, J. R. Strong Inference. Science 146(3642), 1964. Devise alternative hypotheses, devise an experiment to exclude one, repeat. Section 12 is strong inference.
- Popper, K. The Logic of Scientific Discovery. Hutchinson, 1959. Falsifiability; section 6's falsifier field.
- Kahneman, D. Thinking, Fast and Slow. FSG, 2011. Hindsight bias and the illusion of validity — the empirical reason section 5 must be timestamped.
- Tetlock, P., Gardner, D. Superforecasting. Crown, 2015. Calibration improves only with recorded predictions and scored outcomes; the reason to track your hit rate.
- Wilson, G. et al. Best Practices for Scientific Computing. PLoS Biology 12(1), 2014.
- Sandve, G. K. et al. Ten Simple Rules for Reproducible Computational Research. PLoS Computational Biology 9(10), 2013. Section 14 is these ten rules compressed.
- Collberg, C., Proebsting, T. A. Repeatability in Computer Systems Research. CACM 59(3), 2016.
Worked Notebook Entry — A Real One, With Real Numbers
A template teaches you the fields. It does not teach you what a good answer in each
field looks like, or how much you are allowed to be wrong. So here is a completed entry
from an actual experiment, run on the machine described below, with the raw output
included. Everything in it is reproducible with
tools/annlab.py.
Read this before you read the template. The most important thing in it is section 11: my central prediction was wrong, and the entry is better because of it.
Table of Contents
- 1. Problem
- 2. Constraints
- 3. Existing Approach
- 4. My Naive Design
- 5. Predictions
- 6. Hypothesis
- 7. Experimental Setup
- 8. Baseline
- 9. Results
- 10. Surprises
- 11. Failure Analysis
- 12. Next Experiment
- 13. Generalization
- What This Entry Did Right
1. Problem
Exact k-nearest-neighbour search over n vectors costs one pass over all n vectors per query. For a recommender serving 5,000 QPS over 10M items, that is 5×10¹⁰ distance computations per second, which no single machine can do. I want sub-linear query cost at a recall I choose, and I want to know exactly what the recall costs me.
2. Constraints
- Single machine, single thread. No GPU. Concurrency is a later project.
- Vectors fit in RAM. On-disk indexes are Project 3, not this experiment.
- Cosine similarity on L2-normalised vectors. Fixed for the whole project so that every number in this notebook is comparable to every other.
- I am allowed to be approximate. I am not allowed to be silently approximate: recall is measured against exact brute force on the same data, every time.
- Python. This is a constraint with consequences that I did not appreciate until section 10, and which turned out to be the most useful thing I learned.
3. Existing Approach
HNSW (Malkov & Yashunin, 2016): a hierarchy of proximity graphs with exponentially decaying layer membership. Search descends from a sparse top layer, greedily, refining at each level. Reported to give ~0.95 recall at 1–2 orders of magnitude fewer distance computations than brute force. The layer structure is claimed to remove NSW's dependence on lucky long-range links formed by insertion order.
I read the abstract and the algorithm pseudocode, and then stopped reading before the neighbour-selection heuristic (Algorithm 4), on purpose, so that section 4 would be mine.
4. My Naive Design
A single-layer navigable small-world graph:
- Insert points one at a time in random order.
- For each new point, greedily search the graph built so far with a beam of width
efConstruction, connect the new node to theMnearest found, add reciprocal edges. - Cap every node's degree at
2M, keeping theM-nearest when it overflows. - Query: beam search from a fixed entry point with beam width
efSearch. Stop when the closest unexplored candidate is further than the worst result currently held.
My reasoning for skipping the hierarchy: early insertions happen into a nearly-empty graph, so their edges are necessarily long. Those accidental long edges should give me the small-world property for free, and the hierarchy should be an optimisation rather than a requirement. This reasoning is wrong, and section 11 explains exactly how.
5. Predictions
Written before running anything. Recorded with a timestamp so I cannot retro-fit them.
| # | Prediction | Confidence |
|---|---|---|
| P1 | recall@10 will be concave in efSearch — steep, then a knee, then flat | high |
| P2 | The graph will beat brute force by 10–100× in wall-clock at n=10,000 | high |
| P3 | Clustered data will be easier than uniform: higher recall at equal efSearch | high |
| P4 | p95/p50 latency ratio will grow with efSearch | medium |
| P5 | Index size will be 1.2–1.5× the raw vector bytes | medium |
6. Hypothesis
H1: On data with higher relative contrast (RC = mean distance / nearest-neighbour distance), a single-layer NSW graph achieves strictly higher recall@10 at every
efSearchthan on data with lower relative contrast, holding n, d, M andefConstructionfixed.Falsifier: any
efSearchat which the high-RC dataset shows lower recall@10 than the low-RC dataset, by more than the run-to-run spread.
7. Experimental Setup
- Hardware: 12-core arm64 (Apple Silicon), macOS 15.0, load average 4.3 at run time — not an idle machine, which inflates tail latencies and is recorded here so the p95 numbers are not mistaken for clean-room figures.
- Software: CPython 3.14.0, numpy 2.4.6. numpy links a vendor BLAS; brute force is
one
@call, so it runs at BLAS speed while the graph walk runs at interpreter speed. This asymmetry is the entire subject of section 10. - Data: n=10,000, d=64, L2-normalised.
- Uniform: i.i.d. standard normal, then normalised → uniform on the unit sphere.
- Clustered: 100 Gaussian clusters, per-axis σ=0.05.
- Queries: 200, drawn from the same distribution as the data.
- Index: M=16, efConstruction=100, seed=0.
efSearchswept over {10,16,24,32,48,64,96,128,192,256}. - Ground truth: exact top-10 by brute force, same distance function, same data.
- Command:
python3 annlab.py --n 10000 --d 64andpython3 annlab.py --n 10000 --d 64 --clusters 100
A setup bug I found before running the real experiment
My first clustered generator used σ=0.25. It produced a dataset with RC=1.393 against uniform's 1.356 — statistically indistinguishable. Had I not measured RC before running, I would have run the whole comparison on two datasets that were the same dataset, found no difference, and concluded something false about clustering.
The mechanism: a Gaussian perturbation with per-axis σ in d dimensions has expected norm σ√d. At d=64, σ=0.25 gives σ√d = 2.0, while the cluster centres are unit vectors. The noise was twice the signal. The clusters existed in my code and not in my data.
Measured sweep at d=64, 100 clusters:
| σ | σ√d | RC |
|---|---|---|
| uniform | — | 1.356 |
| 0.50 | 4.00 | 1.368 |
| 0.25 | 2.00 | 1.393 |
| 0.15 | 1.20 | 1.608 |
| 0.10 | 0.80 | 1.992 |
| 0.05 | 0.40 | 3.371 |
| 0.03 | 0.24 | 5.367 |
Clusters only exist when σ√d ≪ 1. Lesson: verify that your independent variable actually varies before you spend a run measuring its effect.
8. Baseline
Exact brute force, data @ q then argpartition. Recall 1.000 by construction.
| dataset | ms/query (mean) | qps | ns per distance |
|---|---|---|---|
| uniform | 0.133 | 7,511 | 13.3 |
| clustered | 0.128 | 7,813 | 12.8 |
Brute force is data-independent, as expected — it does the same n·d work regardless of structure. The 4% gap is noise from a non-idle machine.
9. Results
Uniform, RC = 1.363. Build 16.22 s, 25.8 edges/node, 3.59 MB (1.40× raw vectors).
| efSearch | recall@10 | p50 ms | p95 ms | speedup vs brute | dists/query | ns/dist |
|---|---|---|---|---|---|---|
| 10 | 0.3605 | 0.337 | 0.469 | 0.39× | 397 | 849 |
| 16 | 0.4500 | 0.468 | 0.629 | 0.28× | 537 | 872 |
| 24 | 0.5635 | 0.627 | 0.788 | 0.21× | 722 | 868 |
| 32 | 0.6325 | 0.766 | 0.981 | 0.17× | 873 | 877 |
| 48 | 0.7435 | 1.059 | 1.283 | 0.13× | 1172 | 903 |
| 64 | 0.8160 | 1.312 | 1.484 | 0.10× | 1459 | 899 |
| 96 | 0.9005 | 1.838 | 2.142 | 0.07× | 1998 | 920 |
| 128 | 0.9480 | 2.273 | 2.593 | 0.06× | 2484 | 915 |
| 192 | 0.9815 | 3.075 | 3.582 | 0.04× | 3335 | 922 |
| 256 | 0.9930 | 3.981 | 4.492 | 0.03× | 4059 | 981 |
Clustered, RC = 3.363. Build 6.29 s, 25.0 edges/node, 3.56 MB (1.39× raw).
| efSearch | recall@10 | p50 ms | p95 ms | speedup vs brute | dists/query | ns/dist |
|---|---|---|---|---|---|---|
| 10 | 0.4840 | 0.194 | 0.313 | 0.66× | 193 | 1005 |
| 16 | 0.5755 | 0.234 | 0.398 | 0.55× | 225 | 1040 |
| 24 | 0.6710 | 0.294 | 0.493 | 0.44× | 257 | 1143 |
| 32 | 0.7215 | 0.322 | 0.551 | 0.40× | 274 | 1173 |
| 48 | 0.7945 | 0.383 | 0.624 | 0.33× | 301 | 1275 |
| 64 | 0.8345 | 0.423 | 0.702 | 0.30× | 321 | 1317 |
| 96 | 0.8880 | 0.508 | 0.933 | 0.25× | 367 | 1384 |
| 128 | 0.9030 | 0.697 | 1.216 | 0.18× | 501 | 1392 |
| 192 | 0.9470 | 0.938 | 1.533 | 0.14× | 677 | 1385 |
| 256 | 0.9670 | 1.178 | 1.838 | 0.11× | 840 | 1401 |
Verdict on each prediction
| # | Prediction | Outcome |
|---|---|---|
| P1 | recall concave in efSearch | Confirmed. 0.36→0.82 costs 54 ef; 0.82→0.99 costs 192 more. |
| P2 | 10–100× faster than brute force | Falsified, badly. It is 1.5–33× slower at every operating point. |
| P3 | Clustered strictly easier | Falsified at high ef. Faster, yes. Higher recall — only below ef≈96. |
| P4 | p95/p50 grows with ef | Weakly confirmed. Uniform 1.39→1.13 (shrinks); clustered 1.61→1.56. Prediction was wrong in direction for uniform. |
| P5 | Index 1.2–1.5× raw | Confirmed. 1.40× and 1.39×. |
Two of five predictions survived. That ratio is normal and is not a sign that the experiment went badly — it is a sign that the predictions were specific enough to be wrong.
10. Surprises
Surprise 1 — the index is slower than the thing it replaces, and the reason is arithmetic
I predicted a 10–100× win and measured a 3–33× loss. The distance counter explains it exactly. Decompose the speedup into two independent factors:
\[ \text{speedup} = \underbrace{\frac{n}{\text{dists/query}}}_{\text{algorithmic}} \Big/ \underbrace{\frac{\text{ns/dist}_{\text{graph}}}{\text{ns/dist}_{\text{brute}}}}_{\text{constant factor}} \]
At efSearch=64 on uniform data:
- algorithmic win: 10,000 / 1,459 = 6.9× fewer distance computations
- constant factor: 899 ns/dist (Python loop) vs 13.3 ns/dist (BLAS) = 67.5× slower each
- predicted speedup: 6.9 / 67.5 = 0.10×
- measured speedup: 0.10×
And on clustered data: 31.1× fewer distances, 102.9× slower each, predicted 0.30×, measured 0.30×. The model is exact to two significant figures in both cases.
This is the single most valuable result in the entry, and it is a negative one. It says: my algorithm is right and my implementation is wrong, and those are different bugs with different fixes. Without the distance counter I would have concluded "HNSW does not work at n=10k", which is false, and I would have spent a week tuning M and efConstruction, which would not have helped.
Break-even requires the algorithmic win to exceed the constant factor. Distances/query grows roughly logarithmically in n while brute force grows linearly, so the crossover in pure Python lands near n ≈ 1.5×10⁵ — consistent with a separate run at n=100,000, d=128 which measured 0.80× at efSearch=64, just short of parity. In a compiled implementation the constant factor is ~1–2× and the crossover moves down to n of a few thousand. This is why every serious ANN index is written in C++.
Surprise 2 — clustered data has a lower recall ceiling
At efSearch ≤ 96 clustered beats uniform, as predicted. At efSearch ≥ 128 it loses, and the gap widens: at ef=256, uniform reaches 0.9930 and clustered only 0.9670. H1 is falsified.
The mechanism is visible in the distance counter, not in the recall column. At ef=256:
- uniform evaluates 4,059 distances per query
- clustered evaluates 840
The clustered search cannot spend its budget. It is not choosing to stop early to save time; it runs out of reachable candidates. Raising efSearch buys nothing because the beam is never the binding constraint.
11. Failure Analysis
Why H1 failed. My section-4 reasoning was that random insertion order produces accidental long-range edges, which give the small-world property for free. That argument holds on uniform data, where an early insertion's nearest neighbours are genuinely spread across the whole space. It fails on clustered data for a reason I did not anticipate: the degree-cap prunes exactly the edges the argument depends on.
Walk through it. When node v in cluster A accumulates more than 2M edges, my rule keeps the M nearest. Measured on this dataset (20,000 sampled pairs): mean intra-cluster distance 0.521, mean inter-cluster distance 1.413 — a clean 2.7× separation with no overlap in the bulk of the distributions. So the pruning rule deletes every single long edge, deterministically, the moment a node gets busy. On uniform data there is no such clean separation, so pruning by distance removes edges roughly at random and long edges survive by luck. On clustered data the pruning rule is a perfectly efficient long-edge destroyer, and the tighter the clusters, the more efficient it gets.
The result is a graph that is 100 well-connected islands with almost no bridges. Greedy search descends into one island, exhausts it in ~800 distance computations, and terminates — with any true top-10 neighbours that happen to live in an adjacent cluster permanently unreachable.
And this is precisely what HNSW's Algorithm 4 exists to prevent. The neighbour-selection heuristic I deliberately did not read keeps a candidate edge only if the candidate is closer to the base node than to any already-selected neighbour — which preserves edges pointing in directions not already covered, i.e. exactly the long bridges. I skipped it because it looked like an optimisation. It is not an optimisation; it is a connectivity guarantee, and on clustered data it is load-bearing.
I could not have learned this by reading the paper. I learned it by building the version without it and being unable to explain a recall ceiling.
Secondary failure — my p95/p50 prediction (P4) was wrong in direction. I assumed longer walks mean more variance. For uniform data the ratio fell from 1.39 to 1.13 as ef grew. Retrospectively obvious: at small ef the walk length is dominated by where the query happens to land relative to the entry point, which is high-variance; at large ef every query does a lot of work and the relative spread shrinks. I was reasoning about absolute variance and predicting about relative variance. The measurement caught a confusion in my head, not a property of the system.
12. Next Experiment
The smallest experiment that reduces the most uncertainty:
E1 — Instrument inter-cluster edge fraction. For the clustered index, compute the fraction of edges whose endpoints are in different clusters, before and after degree-cap pruning. Cost: ~40 minutes. Prediction: below 1% after pruning, versus ~15% before. If confirmed, the failure analysis above is established rather than plausible. If the fraction is high, my explanation is wrong and the ceiling has some other cause.
Then, in order:
- E2 — Replace the distance-based degree cap with HNSW's Algorithm 4 heuristic. Prediction: clustered recall@10 at ef=256 rises from 0.967 to above 0.99, with distances/query rising toward uniform's ~4,000. Cost: ~3 hours.
- E3 — Add the layer hierarchy. Prediction: it helps latency (fewer hops to reach the right region) far more than recall (which E2 already fixed). This prediction is the one I would most like to be wrong about, because the paper presents the hierarchy as the headline contribution and my model says the heuristic is doing more of the work.
- E4 — Port the inner loop to compiled code and re-measure the crossover n against the model in Surprise 1. Prediction: crossover falls below n=10,000.
Note that E1 costs 40 minutes and would settle the main open question. Do the cheap decisive one first; do not start E2 before E1 tells you whether E2 is even aimed at the right thing.
13. Generalization
Where the "clustering hurts recall" result should hold: any graph index that prunes edges by raw distance, on any dataset with well-separated modes. That includes real production cases — multilingual embedding spaces, catalogues with strong category structure, and any corpus where near-duplicates form tight clumps. It predicts a specific production failure: recall that looks fine on a uniform synthetic benchmark and degrades on the real corpus, in a way that adding efSearch does not fix. That is a falsifiable claim about someone else's system, which makes it the most valuable sentence in this entry.
Where it should not hold: IVF/quantization indexes, which partition rather than navigate and are largely indifferent to modality; and graph indexes whose pruning already preserves directional diversity, which is most production HNSW.
Where the constant-factor result generalises: everywhere, and it is the more transferable of the two. Any time an algorithmically superior structure loses to a brute-force scan, decompose the ratio into operation count and cost per operation before touching a parameter. Applied to later projects in this journey: an LSM read path that loses to a hash map, a graph traversal that loses to a table scan, a fused kernel that loses to two unfused ones — same decomposition, same diagnostic power.
Where it fails: when the two implementations do not share a countable unit of work. There is no "distance" to count when comparing a B-tree to an LSM tree, so you need a different common currency (bytes read from disk, usually).
What This Entry Did Right
Worth naming explicitly, because these are the habits and not the content.
- The predictions were written first and were specific enough to be wrong. "It will be faster" is unfalsifiable. "10–100× faster at n=10,000" got destroyed by the data, which is what made section 10 possible.
- A secondary metric explained the primary one. Recall and latency alone give you "it is slow and it plateaus". The distance counter turns both into arithmetic that closes to two significant figures. Instrument the mechanism, not just the outcome.
- The setup was validated before the experiment ran. Measuring RC caught a generator whose independent variable did not vary. That check cost ten minutes and saved a worthless run.
- The failure analysis names a specific line of the design. Not "clustering is hard" but "the degree-cap prunes by raw distance, which on separated modes deletes every bridge deterministically". Specific enough to fix, and specific enough to be wrong.
- The next experiment is 40 minutes, not two weeks. Decisive and cheap beats thorough and slow, every time, when uncertainty is the bottleneck.
- The generalization makes a claim about systems I have not built. That is the step from "I did an experiment" to "I know something".
The entry is ~2,000 words and represents roughly six hours of work including the failed setup. That ratio — a substantial written artifact per handful of hours — is what the 10% writing allocation in the operating model buys you.
References
- Malkov, Y. A., Yashunin, D. A. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. IEEE TPAMI 42(4), 2020 (arXiv:1603.09320). Algorithm 4 is the neighbour-selection heuristic discussed in section 11.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012. Source of the relative-contrast measure used throughout.
- Malkov, Y., Ponomarenko, A., Logvinov, A., Krylov, V. Approximate nearest neighbor algorithm based on navigable small world graphs. Information Systems 45, 2014. The single-layer NSW that section 4 reinvented.
- Beyer, K. et al. When Is "Nearest Neighbor" Meaningful? ICDT 1999. Why RC→1 in high dimensions and what that does to every distance-based method.
- Aumüller, M., Bernhardsson, E., Faithfull, A. ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms. Information Systems 87, 2020. The protocol this entry's recall/QPS curve imitates.
Project Scorecard
Twelve categories, scored 1–5 at the end of every project, with anchors defined for 1, 3 and 5. Scores 2 and 4 are interpolations.
Score down when unsure. A generous rubric is the one thing that guarantees this journey produces nothing. The purpose of the scorecard is not to feel good about a project; it is to find the category that is weakest so the next project can train it.
Table of Contents
- How To Score
- 1. First-Principles Understanding
- 2. Correctness
- 3. Implementation Depth
- 4. Code Quality
- 5. Systems Reasoning
- 6. Experimental Rigor
- 7. Benchmark Quality
- 8. Failure Analysis
- 9. Originality of Hypotheses
- 10. Communication
- 11. Reproducibility
- 12. Completion Discipline
- The Completion Gate
- Using the Scores
- References
How To Score
- Do the adversarial self-review first — eleven questions, one hour, one night after finishing. Scoring before that scores your own view of your work.
- Score after the report is written, not before.
Record with
python3 tools/scorecard.py record <project> --interactive. - For each category, name the evidence. A score without a cited artifact is a mood.
- If you are between two scores, take the lower one.
- Record all twelve in the report's self-assessment table.
- At each stage review, plot the trend. The trend matters more than any single score.
A realistic first project scores 2–3 across most categories. Scoring 4s in Stage 1 means the rubric is being read generously, not that the work is exceptional.
1. First-Principles Understanding
Can you derive the design rather than recall it?
| 1 | You implemented what a tutorial or paper described. Asked "why this constant?", you say "that is what the paper uses." |
| 3 | You can explain every major design decision and what it trades against. You derived at least one constant (e.g. the \(\sqrt{d_k}\) scale, the 10-bits/key Bloom default) rather than adopting it. |
| 5 | You designed a substantial part before reading the canonical solution, can explain why the canonical design differs from yours, and can construct the conditions under which your version would have been correct. |
Evidence: the notebook's section 4, and the report's section 3.
2. Correctness
Does it work, and how do you know?
| 1 | It runs on the examples you tried. Testing is manual. |
| 3 | A real test suite: property tests, invariants asserted in code, edge cases covered. You can state what is not tested. |
| 5 | Model-based or differential testing against a reference; fuzzing; invariants asserted continuously in production paths; deliberate fault injection. At least one real bug was found by a test rather than by observation. |
Evidence: the test suite, and a named bug that testing caught.
A 5 requires that a test found something you did not know. Tests that only confirm what you already believed are documentation.
3. Implementation Depth
Did you build the mechanism, or configure one?
| 1 | The core mechanism comes from a library. You wrote the glue. |
| 3 | The central mechanism is hand-written and complete. Peripheral concerns use libraries, and the boundary is explicit and defensible. |
| 5 | The mechanism is hand-written, and you also built at least one layer below the one required — a compiled inner loop, a custom allocator, a hand-vectorised kernel — with the improvement measured. |
Evidence: the permitted-library line from the project page, and what you did not import.
4. Code Quality
Could a competent engineer work in this codebase?
| 1 | One file, unclear names, no structure. Only you can navigate it. |
| 3 | Sensible module boundaries, meaningful names, comments that explain why, a README that gets a stranger running. |
| 5 | The interfaces are the kind another project can build on — and one did. Errors are informative. The code reads as an explanation of the design. |
Evidence: a later project importing this one without modification is the strongest possible evidence for a 5.
Note this is weighted lower than it would be at work. These are research artifacts. Beautiful code with no measurements scores far worse overall than workmanlike code with a falsified hypothesis.
5. Systems Reasoning
Do you know where the time and bytes go?
| 1 | Performance is a mystery. You changed things until it got faster. |
| 3 | You profiled, identified the bottleneck, and can classify it as computational, algorithmic, architectural, or operational. |
| 5 | You predicted the bottleneck before measuring and were approximately right; you decomposed a ratio into its independent factors (as in P02's algorithmic-vs-constant-factor split); and you can state what the next bottleneck will be after this one is fixed. |
Evidence: a prediction recorded before profiling, and a decomposition rather than a single ratio.
6. Experimental Rigor
Would the result survive a skeptical reviewer?
| 1 | One run, one configuration, no seed, no baseline. |
| 3 | Fixed seeds, repeated trials, one variable at a time, a stated baseline, uncertainty reported. |
| 5 | Predictions timestamped before runs; a negative control; sensitivity analysis over the parameters most likely to be doing the work; the setup validated before the experiment (independent variable confirmed to vary); and the measurement's resolution stated before any ratio is reported. |
Evidence: the commit timestamp on notebook sections 1–8.
The resolution requirement is specific. tools/bloom.py
demonstrates it: at 24 bits/key the predicted false-positive rate is ~10⁻⁵, so 200k
probes expect 1.2 events and observing 0 is noise, not a result. Reporting a ratio your
experiment cannot resolve is a 2, regardless of everything else.
7. Benchmark Quality
Do the numbers mean anything?
| 1 | Mean latency. No warmup. Unrealistic workload. Environment unrecorded. |
| 3 | p50/p95/p99, warmup separated, environment recorded, realistic and skewed workloads, raw samples saved. |
| 5 | Confidence intervals on the statistics; an honest "no measurable difference" verdict where the intervals overlap; adversarial and degenerate workloads included; and a comparison against a credible external implementation, reported honestly including where you lose. |
Evidence: tools/bench.py output, and an external comparison you
did not win.
Reporting a mean latency caps this category at 2.
8. Failure Analysis
Do you know how it breaks?
| 1 | You tested the happy path. Failures were fixed as encountered, not studied. |
| 3 | Deliberate fault injection with predicted and observed behaviour; degradation characterised; the overload behaviour known. |
| 5 | A fault-injection harness with replayable schedules; at least one failure diagnosed to a specific design decision with the causal chain written out; and the system's behaviour at its breaking point characterised rather than merely avoided. |
Evidence: the harness, and a failure analysis that names a line rather than a category.
9. Originality of Hypotheses
Are you asking questions, or following instructions?
| 1 | No hypothesis. You implemented and measured. |
| 3 | A falsifiable hypothesis with a stated falsifier, tested with a controlled experiment. |
| 5 | The hypothesis came from something you observed rather than from a paper; it was falsified or survived on evidence; and the generalization section makes a checkable claim about systems you have not built. |
Evidence: notebook sections 6 and 13.
A falsified hypothesis scores the same as a confirmed one. What is scored is whether the question was worth asking and whether the test could have answered "no". If every hypothesis you have ever tested was confirmed, this category caps at 3 — you are testing things you already know.
10. Communication
Can someone else learn from this?
| 1 | A README. The results live in your head. |
| 3 | A complete report: problem, design, method, results, analysis. Figures with units and captions. Someone in your field could follow it. |
| 5 | The "What I Expected And Did Not Get" section is substantial and specific; limitations and threats to validity are stated without prompting; and the report would survive being read by someone who wanted to find a hole in it. |
Evidence: the report's required sections 13 and 15.
An empty section 13 caps this category at 2.
11. Reproducibility
Can someone else get your number?
| 1 | It runs on your machine. Setup is undocumented. |
| 3 | One command to build, one to test, one to reproduce the headline benchmark. Versions pinned. Seeds fixed. |
| 5 | You followed your own instructions from a clean clone and got the number within tolerance; raw sample data is committed, not just summaries; the environment is specified precisely enough to explain a discrepancy. |
Evidence: the report's section 17, verified.
For P15 the bar is higher and absolute: a person who is not you must have reproduced the headline number.
12. Completion Discipline
Did you finish, on the terms you set?
| 1 | Abandoned, or drifted past its size class with no decision made. |
| 3 | Exit criteria met within the class ceiling. Scope cuts, if any, were deliberate and documented. |
| 5 | Finished within budget; the optional extension was correctly deferred rather than started early; and any scope cut was recorded with its reasoning at the time rather than rationalised afterwards. |
Evidence: the exit-criteria checklist, and the weekly logs.
This is the category most predictive of whether you finish the journey. A pattern of 3s here across five projects is a stronger signal than a pattern of 5s anywhere else. Watch its trend specifically.
The Completion Gate
Separate from the scores. A project is complete only when all eight are true:
- The essential mechanism works
- Correctness tests pass
- At least one baseline exists
- Meaningful metrics are collected
- At least one hypothesis was tested
- Negative or unexpected results are documented
- A technical report is complete
- The repository can be reproduced by another engineer
These are binary and they are not negotiable by scoring well elsewhere. A project with five 5s and no baseline is not complete; it is an impressive incomplete project, and recording it as complete is how a 34-month plan quietly becomes a 50-month one.
Note that the gate does not require the project to be good. It requires it to be finished and honest. A project whose hypothesis was falsified, whose implementation lost to the baseline, and whose report explains both, passes the gate cleanly.
Using the Scores
Per project: find the lowest category. Name the specific mechanic in the next project that will train it, and write that into the next project's weekly objectives. One category at a time — trying to raise all twelve is trying to raise none.
Per stage (stage reviews): run
python3 tools/scorecard.py stage <n> and trend, which plot all twelve across
the stage's projects. Three patterns to look for:
- A category that never moves. You are avoiding it. Failure analysis and originality are the usual suspects, because both require sitting with being wrong.
- A category that drops when projects get harder. Usually experimental rigor or communication — the things that get cut under time pressure. That is a scheduling problem, not a skill problem, and the fix is in the allocation.
- Everything rising smoothly. Suspicious. Re-read the anchors; you have probably started scoring against your own past work rather than against the definitions.
Across the journey: the categories you asked to develop map to specific ones. First-principles reasoning → 1. Low-level implementation → 3. Systems intuition → 5. Experimental discipline → 6. Performance analysis → 5 and 7. Original technical thinking → 9. Research-quality communication → 10. Those seven are the journey's actual objectives; the other five are the conditions that make them credible.
References
- Wiggins, G., McTighe, J. Understanding by Design, 2nd ed. ASCD, 2005. Rubric design: anchors at 1/3/5 with observable evidence, rather than adjectives.
- Ericsson, K. A. et al. The Role of Deliberate Practice in the Acquisition of Expert Performance. Psychological Review 100(3), 1993. Why identifying and targeting the single weakest component beats general practice.
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth: A Pragmatic Guide to Assessing Empirical Evaluations. ACM TOPLAS 38(4), 2016. The source of several anchors in categories 6 and 7.
- Hoefler, T., Belli, R. Scientific Benchmarking of Parallel Computing Systems. SC 2015. Twelve rules; category 7's 5-anchor is rules 1–4.
- Kruger, J., Dunning, D. Unskilled and Unaware of It. Journal of Personality and Social Psychology 77(6), 1999. The empirical reason for "score down when unsure".
Scores
scorecard.json is the twelve-category scorecard for every project,
recorded and trended by tools/scorecard.py.
cd tools
python3 scorecard.py record p01 --interactive # prompts each category with its 3-anchor
python3 scorecard.py show # the grid
python3 scorecard.py trend # what is rising, flat, falling
python3 scorecard.py weakest # what the next project must train
python3 scorecard.py stage 1 # stage-review summary + the four questions
This file is committed. Twelve categories × fifteen projects is 180 numbers spanning 34 months — it will outlive at least one laptop, and it is the only dataset in the whole program that shows you improving. Losing it means losing the evidence.
It deliberately computes no overall score. Averaging "first-principles understanding" with "code quality" produces a number that means nothing and lets a strength paper over a weakness. The weakest category is the output that matters, because it decides what the next project trains.
Score down when unsure. A realistic first project is 2–3 across most categories; 4s in Stage 1 mean the rubric is being read generously, not that the work was exceptional.
AI Assistant Usage Policy
Ten rules, each with the failure it prevents and the mechanism that enforces it.
The premise: an AI assistant can produce a working HNSW index in ninety seconds. If you accept it, you have a working index and you have learned nothing, and the 34 months become a very expensive way to acquire code you could have downloaded. The rules exist to keep the assistant on the side of the work that is not the point, and away from the work that is.
The single sentence version: AI may accelerate everything except the step where you would have had to think.
Table of Contents
- The Commitment
- Rule 1 — Design Before Asking
- Rule 2 — Predict Before You Run
- Rule 3 — Debug Before Asking
- Rule 4 — Adversarial Use Is Encouraged
- Rule 5 — Understand Before Accepting
- Rule 6 — Manual Central Mechanism
- Rule 7 — Libraries For Periphery, Not For The Mechanism
- Rule 8 — Never Accept An Unexecuted Number
- Rule 9 — Record Material Influence
- Rule 10 — Periodic Unassisted Rebuilds
- What AI Is Genuinely Good For Here
- The Self-Audit
- References
The Commitment
Copy this into notebook/000-ai-commitment.md, date it, and commit it before week 1.
I am doing this to develop capabilities, not to produce artifacts. Where the two conflict, capability wins. I will not accept generated code I cannot explain, I will not report a number I have not executed, and I will hand-write the central mechanism of every project. When I break one of these rules I will record it rather than hide it, because a policy I cannot audit is a policy I do not have.
The last clause is the one that matters. You will break these rules — at 11pm, on a Tuesday, when a segfault has beaten you for two sessions. The policy is not damaged by that. It is damaged by not writing it down.
Rule 1 — Design Before Asking
I write the initial design before requesting an AI-generated design.
Prevents: the loss of step 3 of the loop — the reconstruction skill that is the entire point of the journey. Once you have seen a good design you cannot un-see it.
Mechanism: notebook section 4 is written and committed before any design conversation. The commit timestamp is the enforcement.
What is allowed after your design exists: asking for a critique of yours; asking what the canonical approach does differently; asking what you have not considered. All of these are more valuable after your design, and they are the highest-return use of an assistant in this whole program.
Rule 2 — Predict Before You Run
I predict experiment outcomes before running experiments.
Prevents: hindsight bias, and the erosion of calibration. You cannot get better at predicting system behaviour if you never record a prediction that could be wrong.
Mechanism: notebook section 5, committed before the run. Applies to AI-suggested experiments too — if the assistant proposes an experiment, you predict its outcome before running it.
Corollary: never ask an assistant "what will this experiment show?" before you have written your own answer. Ask afterwards, and compare — that comparison is free calibration data.
Rule 3 — Debug Before Asking
I attempt debugging before requesting the complete fix.
Prevents: the loss of debugging skill, which is the single most transferable thing in this entire journey and the one that degrades fastest when outsourced.
Mechanism: a 45-minute rule. Before asking for a fix you must have spent 45 focused minutes and be able to state:
- What you expected to happen
- What happened instead
- The smallest input that reproduces it
- Two hypotheses you have ruled out, and how
If you can write those four things, you have done the work that debugging teaches, and asking is now a time optimisation rather than a substitution.
Note that step 3 usually solves it. Minimising a reproduction is most of debugging, and it is the part an assistant cannot do for you because it requires access to your system.
Exempt: environment and toolchain problems. Fighting a linker script teaches nothing about operating systems. Ask immediately — and in P12 the whole week-3 decision rule exists because toolchain time is pure waste.
Rule 4 — Adversarial Use Is Encouraged
I may ask AI to critique assumptions and generate adversarial tests.
This is not a restriction — it is the use that has the highest value and the lowest risk, and it is under-used.
Prompts worth having ready:
- "Here is my design and my reasoning. What assumption is doing the most work, and under what conditions is it false?"
- "What input would break this? Give me the ten nastiest cases including degenerate and adversarial ones."
- "Here is my experimental setup. What confounds it? What am I failing to measure?"
- "Here is my conclusion and the data. Argue that the conclusion does not follow."
- "What is the strongest version of the argument against this design choice?"
The fourth is the most valuable in this program. Systems people are good at building and bad at attacking their own results, and an assistant will produce a more honest attack than you will.
Note the asymmetry. Asking for a solution substitutes for your thinking. Asking for an attack forces more of it. Use the second freely.
Rule 5 — Understand Before Accepting
I must understand and explain generated code before accepting it.
Prevents: a codebase you cannot debug, extend, or defend — which is fatal in a program whose deliverable is your own understanding.
Mechanism — the explanation test. Before committing generated code, write in the commit message (or a comment) what it does and why it is correct. Not what it is called — why it works. If you cannot, do not commit it.
Stronger form, for anything non-trivial: delete it and retype it from your understanding. You will change things, and the changes are where the learning is.
Red flags that you have violated this rule without noticing:
- You do not know why a particular line is there
- You would not be able to modify it for a slightly different requirement
- You cannot predict what it does on an edge case
- A test fails and your first instinct is to ask rather than to read
Rule 6 — Manual Central Mechanism
I must manually implement the central mechanism of every project.
This is the most important rule and the least negotiable. Each project page names its mechanism; the list:
| Project | The mechanism you write by hand |
|---|---|
| P01 | Attention, multi-head, causal masking, RoPE |
| P02 | Graph construction, beam search, neighbour selection |
| P03 | Storage format, WAL, recovery, filtering strategies |
| P04 | WAL, memtable, SSTable, Bloom filter, compaction |
| P05 | Consensus, replication, failure detection, the fault injector |
| P06 | Coordinator, shuffle, speculative execution |
| P07 | Watermarks, windowing, checkpointing |
| P08 | Profile construction, retrieval orchestration, ranking, diversity |
| P09 | The user model, the click model, the experiment harness |
| P10 | Assignment, SRM, power analysis, the analysis pipeline |
| P11 | Lexer, parser, evaluator, compiler, VM, GC |
| P12 | Boot, interrupts, page tables, context switch, scheduler |
| P13 | The autodiff graph and every backward rule |
| P14 | The matmul kernels and the systolic simulator |
| P15 | The integration and the experiment design |
"Manually" means: you typed it, from your understanding, and you can rederive it. It does not mean you never looked anything up.
What is allowed alongside: an assistant may help with the code around the mechanism — CLI parsing, plotting, test scaffolding, build configuration, data loaders. That code is not what you came for.
The test: could you rewrite the mechanism from scratch in a week, without assistance? If not, rule 10 applies.
Rule 7 — Libraries For Periphery, Not For The Mechanism
I can use libraries for peripheral concerns, but not to hide the mechanism being studied.
The line is drawn per project on its page. The principle:
| Allowed | Forbidden |
|---|---|
| numpy for array storage and BLAS matmul in P13 | numpy or torch for autograd in P13 |
heapq for the priority queue in P02 | hnswlib as a component in P02 |
BTreeMap for the memtable in P04 | A Bloom-filter crate in P04 |
| A serial driver crate in P12 | A scheduler or allocator crate in P12 |
| matplotlib for every plot everywhere | — |
| PyTorch tensors and autograd in P01 | nn.MultiheadAttention in P01 |
Comparison is always allowed. Using hnswlib as an external baseline in P02's E12
is required. Using it inside your index is forbidden. The distinction is whether it is
the thing you are measuring or the thing you are measuring against.
When in doubt, ask: does importing this remove a decision I would otherwise have to make? If yes, it is the mechanism.
Rule 8 — Never Accept An Unexecuted Number
AI-generated benchmark results are never accepted without execution.
Assistants produce plausible numbers. Plausible numbers are worse than no numbers, because they are not obviously wrong and they propagate into your report.
Mechanism: every number in every report traces to a script in the repository and a raw data file. No exceptions, including for numbers that "everyone knows".
This applies to the numbers in this track too. Every measured figure in these pages was produced by running the named script — and reproducing them on your machine in week 1 is milestone 1 of Project 1. Several will differ from what you expect.
Extend the rule to any factual claim about system behaviour. "A context switch costs about a microsecond" is a claim that varies by two orders of magnitude across machines and definitions. P12 measures it at ~1,676 ns on one specific laptop under one specific definition, and even that number comes with the caveat that a ping-pong benchmark misses the cache-pollution cost entirely.
Rule 9 — Record Material Influence
I must record when and how AI materially influenced a design.
Prevents: a portfolio you cannot honestly describe, and — more importantly — losing track of which parts of your understanding are real.
Mechanism: an AI-LOG.md in every project repository.
## AI-LOG
| Date | Where | What | My design before | Why I accepted it |
|---|---|---|---|---|
| 2026-08-14 | Beam search stopping condition | Suggested checking the worst held result rather than a fixed hop count | I had a fixed hop limit | Mine terminated early on clustered data; theirs is the standard and I verified why |
| 2026-08-22 | Test suite | Generated 12 edge cases for the tokenizer | I had 4 | Pure additive coverage, no design content |
Material means it changed a design decision, an algorithm, or a conclusion. Routine completion, syntax lookup, and boilerplate do not need logging — logging everything means logging nothing.
Why this matters beyond honesty: when you re-read a project two years later and cannot remember why a design choice was made, the log tells you whether it was yours. That is the difference between a design you can defend in a review and one you can only describe.
Rule 10 — Periodic Unassisted Rebuilds
I should periodically rebuild important components without AI assistance.
Prevents: the slow substitution of recognition for recall. You can read code and think "yes, that's right" long after you have lost the ability to write it — and recognition feels exactly like understanding from the inside.
Mechanism — the rebuild drill. At each stage review, pick one component from the stage and rebuild it from scratch in one session (3 hours), with no assistant, no notes, and no reference to the original. Then diff.
Suggested targets, one per stage:
| Stage | Rebuild |
|---|---|
| 1 | Multi-head attention forward pass, or the beam search |
| 2 | The Bloom filter, or the SSTable reader |
| 3 | Raft's election logic, or the watermark tracker |
| 4 | The EMA profile and the metric suite |
| 5 | The blocked matmul kernel |
Scoring the drill: if you produce something working in 3 hours, you own it. If you produce something that does not work but whose bugs you can find, you mostly own it. If you cannot start, you never owned it — and you should schedule a proper re-read, because a component you cannot rebuild is a component you cannot defend.
The drill takes 15 hours across the whole journey. It is the cheapest insurance in the program.
What AI Is Genuinely Good For Here
Stated positively, because a policy that is only prohibitions gets ignored.
| Use | Why it is safe and valuable |
|---|---|
| Adversarial critique of your design | Forces more of your thinking, not less. See rule 4 |
| Generating edge cases and hostile inputs | You will not think of the pointer that spans a mapped/unmapped boundary. It will |
| Explaining a paper section you are stuck on | After you have tried. Faster than a forum, and you can ask follow-ups |
| Boilerplate: CLI parsing, plotting, config, build files | Not the mechanism, and it eats real hours |
| Reviewing your report for unsupported claims | "Which sentences here assert something the data does not show?" — genuinely effective |
| Language questions while learning Rust | Syntax and borrow-checker errors are not the learning objective |
| Finding the relevant literature | Then read the primary sources yourself |
| Rubber-ducking a design out loud | Explaining to something that asks questions back |
| Checking arithmetic in a derivation | Then verify by running it — rule 8 |
Notice that most of these are after your own attempt, or outside the mechanism. That is the whole shape of the policy.
The Self-Audit
At each stage review, answer these five in writing. They take ten minutes and they are the only thing that keeps the policy real.
- Which mechanism in this stage could I not rebuild from scratch? Name it. That is your rebuild drill target.
- Where did I ask before doing the 45 minutes? Count the times. A rising count is the signal.
- Is there code in my repositories I cannot explain? Find it. Read it or delete it.
- Did any number reach a report without being executed? Check one at random and re-run it.
- Has my
AI-LOG.mdgone quiet? Either you stopped using assistance, or you stopped logging. It is almost always the second.
Question 5 is the canary. A log that stops mid-project has never once meant the assistant stopped being used.
References
- Bjork, R. A., Bjork, E. L. Desirable Difficulties in Theory and Practice. Journal of Applied Research in Memory and Cognition 9(4), 2020. Why difficulty during acquisition improves retention and transfer — the theoretical basis for rules 1, 3, and 10.
- Roediger, H. L., Karpicke, J. D. Test-Enhanced Learning. Psychological Science 17(3), 2006. Retrieval practice beats re-study; the rebuild drill is retrieval practice.
- Karpicke, J. D., Blunt, J. R. Retrieval Practice Produces More Learning than Elaborative Studying with Concept Mapping. Science 331(6018), 2011.
- Koriat, A., Bjork, R. A. Illusions of Competence in Monitoring One's Knowledge. Journal of Experimental Psychology 31(2), 2005. Why recognition feels like recall — the empirical basis for rule 10.
- Feynman, R. P. Cargo Cult Science. Caltech, 1974. Rule 8, in essay form.
- Bainbridge, L. Ironies of Automation. Automatica 19(6), 1983. The classic result that automating the easy parts of a task degrades the operator's ability to handle the hard parts — written about process control, exactly applicable here.
How To Read
The track budgets ~183 hours of primary sources across 34 months and, until now, assumed you knew how to extract from them. That assumption is wrong for most engineers and specifically risky for you: you described yourself as spreading attention and staying in consumption mode, and reading is the activity that mode is made of.
This page is the protocol. It is short on theory and long on what to do with your hands.
Table of Contents
- The Core Problem
- The Three-Pass Method
- Reading After Building, Not Before
- The Extraction Note
- Reading Code
- When To Stop
- Worked Example — Raft §5.4.2
- Worked Example — Reading LevelDB
- The Anti-Patterns
- Budget Discipline
- References
The Core Problem
A systems paper is not written to teach you. It is written to convince a program committee that a contribution is novel and correct. Consequently:
- The contribution is usually one idea, wrapped in twenty pages of context, evaluation, and related work.
- The evaluation section is advocacy. Benchmarks are chosen by the authors.
- The hardest part is often one paragraph — and it is rarely flagged.
- The related-work section is the most useful part for you, and almost nobody reads it, because it is a map of what has already been tried.
So reading linearly, at uniform speed, from abstract to references, is the wrong algorithm. It spends most of your attention on the parts written for someone else.
And the deeper problem: reading produces recognition, which feels exactly like understanding from the inside and is not the same thing. You will finish the Raft paper feeling you understand Raft. You will discover in week 61 that you cannot implement §5.4.2. The protocol below is designed to convert recognition into something checkable before you have spent the week.
The Three-Pass Method
Adapted from Keshav's How to Read a Paper, with the modifications this track needs.
Pass 1 — Five minutes. Should I read this at all?
Read: title, abstract, section headings, conclusions, and the references (skimming for names you recognise).
Then answer, in one line each:
CATEGORY : what kind of paper is this? (new mechanism / evaluation / survey / position)
CONTEXT : what does it assume I already know?
CLAIM : what is the one-sentence contribution?
CORRECT : does the claim seem plausible? what would make it false?
FOR ME : which milestone does this serve, and could I do that milestone without it?
If "FOR ME" is blank, stop. That is the whole point of pass 1 and it is where the reading budget is protected. A paper with no milestone goes on the parking list.
Pass 1 rejects perhaps a third of what you pick up, at a cost of five minutes each.
Pass 2 — One hour. What does it actually say?
Read the body, skipping proofs and most of the evaluation. Look at every figure and table; a well-made figure carries more than the paragraph describing it.
Produce an extraction note. If you cannot, you have not understood it well enough to use it — which is a valid outcome and means either pass 3 or finding a better source.
Pass 2 is the default for most of this track's readings. Most papers deserve pass 2 and nothing more.
Pass 3 — Two to five hours. Could I reimplement this?
Reserved for papers you are about to build from. In this track that is a small set: Raft, HNSW, LSM-Tree, Dataflow, TPU, the AD survey.
The method: re-derive everything. Read a claim, close the paper, work it out, compare. Where you cannot re-derive, you have found either an unstated assumption or a gap in your understanding, and both are worth marking.
Pass 3 ends when you can sketch the mechanism on a whiteboard from memory and state where it breaks.
Reading After Building, Not Before
The single most important scheduling rule in this track, and it inverts the obvious order.
The rule: for any paper describing a mechanism you are about to build, do pass 1 before, and passes 2 and 3 after your own naive design exists and is committed.
Why. Step 3 of the loop — reconstructing a system from its constraints — is only trainable if you attempt the design before seeing the answer. Once you have read the paper you cannot un-read it, and that week's opportunity is permanently gone. There are only fifteen of them.
What this looks like in practice, from P02:
| Week | Activity |
|---|---|
| 10 | Build a random-graph index. It works badly. Diagnose why |
| 11 | Build NSW from your own reasoning about what would fix it |
| 11 | Then read Malkov 2014 (the single-layer NSW) — pass 2 |
| 12 | Build the hierarchy |
| 12 | Then read Malkov & Yashunin §1–3 — pass 2 |
| 13 | Then read Algorithm 4 — pass 3, because you are about to implement it |
Three separate readings of two papers, each immediately before or after the milestone it serves. Total: 5.5 hours. The alternative — reading both papers in week 9 — costs the same hours and destroys weeks 10 and 11.
The diff is the deliverable. After reading a paper post-design, write:
- What did they do that I did not think of?
- What did I do that they do not — and is mine wrong, or just different?
- Which of my decisions did they make differently for a reason I can now state?
Question 3 is where the learning concentrates. It is also the raw material for the report's section 3.
The Extraction Note
One per paper, ~200 words, in notebook/reading/<year>-<firstauthor>-<slug>.md. If a
paper does not justify 200 words it did not justify an hour.
# <Author year> — <Title>
MILESTONE : which milestone this serves
PASS : 1 / 2 / 3
TIME : actual minutes spent
## The one idea
<one sentence. If you need two, you have not found it yet.>
## The mechanism
<how it works, in your words, 3-5 sentences. No quoting.>
## The number
<the single most important quantitative claim, with its conditions.
If there is none, say so — it changes how much to trust the paper.>
## What surprised me
<the thing you did not expect. If nothing, you skimmed.>
## What they assume
<the assumption doing the most work, and when it fails.>
## What I distrust
<the evaluation choice you would attack. Every paper has one.>
## For my build
<the specific thing I will do differently because of this.>
## Unresolved
<what I still do not understand. Name it — it is the re-read target.>
"What I distrust" is not cynicism, it is calibration. Every evaluation section makes choices favourable to the authors: the baseline they picked, the workload, the scale, the metric. Naming one per paper builds the reflex that makes you a good reviewer of your own work — which is the skill P15 is finally graded on.
"Unresolved" is the field that compounds. Over 34 months these accumulate into a map of your own gaps, and retention.md turns them into review items.
Reading Code
Roughly a third of this track's reading is source code — LevelDB, xv6, hnswlib, etcd/raft,
CPython's ceval.c. Code needs a different protocol, because it has no abstract and no
figures.
The five-step method:
- Find the entry point and the data structure. Not the algorithm — the shape of the
state. In LevelDB that is
VersionandMemTable; in xv6 it isstruct proc. A system is its data structures; the functions are how they change. - Read the header/interface first, whole. It is the author's own summary of what matters, and it is short.
- Trace one operation end to end. One
Put. Onefork. One search. Follow it through every layer, writing the call chain by hand. Do not branch out. - Find the thing you would have got wrong. There is always one — an ordering constraint, a lock discipline, an error path. It is usually commented, and the comment usually explains a bug.
- Then read broadly.
Time-box it to two hours and stop at the boundary. Codebases are unbounded; papers at least end.
Read after your own implementation, for the same reason as papers. Reading LevelDB's
version_set.cc after building compaction is an education; before, it is copying.
When To Stop
Reading has no natural terminator, so it needs an artificial one. Four:
| Signal | Action |
|---|---|
| You have the extraction note | Stop. That was the deliverable |
| The milestone's question is answered | Stop, even mid-paper |
| The budgeted time is up | Stop mid-sentence. Note where. Papers resume better than code |
| You are reading a paper the current milestone does not need | Stop immediately. Parking list |
The most important stopping rule is the fourth, because it is the one that feels worst. The reference in section 4 will be genuinely interesting. Following it is how a 1-hour budget becomes an evening, and how a track becomes a reading list.
The 15% cap is a cap, not a target. A week with 20 minutes of reading and a working mechanism beat a week with 4 hours of reading and a broken one, every time.
Worked Example — Raft §5.4.2
The hardest paragraph in the hardest paper in the track, and the one whose absence causes a bug appearing in ~1 run in 10⁵ that destroys linearizability.
Pass 1 (week 55, 5 min). Category: new mechanism. Claim: consensus can be made understandable by decomposing into election / log replication / safety, with a strong leader. For me: P05 milestones 6–8. Read it.
Pass 2 (weeks 57–58, 3 h, split). §5.1–5.3 before milestone 4, the rest before milestone 6. Extraction note produced. "Unresolved: why can't a leader commit an old entry by counting replicas? The paper says so; I do not see the failure."
Pass 3 (week 61, 2 h). The unresolved item is now the milestone.
The method for one hard paragraph:
- State the rule. A leader may not commit an entry from a previous term by counting replicas; it must first commit an entry from its own term.
- Assume the rule is absent. What could go wrong?
- Construct the counterexample by hand, on paper, as a space-time diagram. Five nodes, terms, logs. This is Figure 8 in the paper — cover it and build your own first.
- Compare against theirs. If they differ, one of you is wrong and finding out which is the entire value of the exercise.
- Write the test. A test that constructs the interleaving deliberately, because it will not arise by chance in a thousand runs.
Budget: 2 hours for one paragraph. That is the correct allocation. §5.4.2 is worth more than the rest of the paper combined for an implementer, and reading it at uniform speed with §2 is how the bug ships.
Worked Example — Reading LevelDB
Scheduled after P04 milestone 9 (size-tiered compaction working). Two hours.
Step 1 — the state. db/version_set.h. Version is an immutable list of files per
level; VersionSet holds the current one plus a list of live older ones. Stop and
notice: versions are immutable and reference-counted. That is the concurrency design in
one observation — readers pin a version, compaction produces a new one, nobody locks.
Step 2 — the interface. include/leveldb/db.h, ~200 lines, whole. Note what is
absent: no transactions, no secondary indexes, no iterators over multiple DBs. The
scope boundary is the design.
Step 3 — trace one Put. DBImpl::Put → Write → WriteBatch → the WAL append →
MemTable::Add → maybe MakeRoomForWrite. Write that chain out by hand.
Step 4 — the thing you would have got wrong. In MakeRoomForWrite: a deliberate
1-millisecond sleep when L0 has too many files. A sleep, in a database write path. The
comment explains it as a throttle to smooth latency rather than let it collapse later —
this is the write stall, and seeing it in production code lands
harder than reading about it.
Step 5 — broaden, if time remains. version_set.cc's compaction picker.
Extraction note focuses on step 4, because that is what your own implementation is missing.
The Anti-Patterns
| Pattern | Why it fails | Instead |
|---|---|---|
| Reading a paper a day | Produces recognition, no ability. The most seductive item in Not Yet | One paper per milestone, extraction note mandatory |
| Reading before designing | Destroys the reconstruction exercise, permanently, one week at a time | Pass 1 before, 2–3 after |
| Reading linearly | Spends uniform attention on non-uniform value | Three passes, and skip proofs on pass 2 |
| Highlighting | Feels like work, produces nothing. Recognition again | Write in your own words or do not read it |
| Following references | 1-hour budget becomes an evening | Note it, park it, continue |
| Reading the evaluation as fact | It is advocacy | Fill "What I distrust" every time |
| Skipping related work | It is a map of what has been tried and failed | Read it on pass 2, especially for research directions |
| Re-reading instead of retrieving | Re-reading is among the least effective study methods measured | Close the paper and reconstruct. See retention |
Budget Discipline
~183 hours over 130 weeks ≈ 1.4 h/week, and it is deliberately front-loaded onto milestones rather than spread evenly. Some weeks are 3 hours; many are zero.
Per project, from readings.md: P05 has the largest budget at 20 hours, P04 at 15, P12 at 17. Those three account for 28% of all reading and it is correctly allocated — they are the projects where getting the concept wrong costs the most weeks.
Track it. In the weekly log:
READING : Raft ss5.4.2 (pass 3) — for milestone 7 — 2.0 h [budget 2.0]
If actual exceeds budget by more than 50% for two consecutive weeks, you have drifted into consumption mode. The remedy is mechanical, not motivational: cap the next two weeks at 30 minutes and see whether the milestones still complete. They will, and that is the lesson.
References
- Keshav, S. How to Read a Paper. ACM SIGCOMM CCR 37(3), 2007. Three pages; the source of the three-pass structure. Read it first — it costs ten minutes.
- Roediger, H. L., Karpicke, J. D. Test-Enhanced Learning. Psychological Science 17(3), 2006. Retrieval beats re-reading; the empirical basis for "close the paper and reconstruct".
- Karpicke, J. D., Blunt, J. R. Retrieval Practice Produces More Learning than Elaborative Studying with Concept Mapping. Science 331(6018), 2011.
- Dunlosky, J. et al. Improving Students' Learning With Effective Learning Techniques. Psychological Science in the Public Interest 14(1), 2013. Ranks ten techniques by measured effectiveness. Highlighting and re-reading score low utility; practice testing and distributed practice score high.
- Adler, M. J., Van Doren, C. How to Read a Book. Simon & Schuster, 1972. The inspectional/analytical/syntopical distinction that the three passes descend from.
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004. Read from the other side: knowing how papers are constructed tells you where the authors put the contribution and where they hid the weakness.
- Feynman, R. P. Cargo Cult Science. Caltech, 1974. Why "What I distrust" is a required field.
Retention Across 34 Months
Project 1 finishes in month 3. Project 15 needs it in month 34. Nothing in the plan, as originally written, connected those two facts.
The whole budget for retention was five 3-hour rebuild drills — 15 hours against 1,430 hours of acquisition, about 1%. That is not a retention plan, it is a gesture. This page is the actual one, and it costs ~55 hours over the journey (3.8%).
Table of Contents
- What Actually Decays
- The Four Mechanisms
- 1. The Review Queue
- 2. The Rebuild Drill
- 3. Forced Reuse
- 4. The Teaching Test
- The Schedule
- What Not To Try To Retain
- Measuring Whether It Works
- References
What Actually Decays
Not everything decays equally, and treating it uniformly wastes the budget. Three classes, in increasing order of durability:
| Class | Example | Half-life | Worth reviewing? |
|---|---|---|---|
| Arbitrary facts | The efConstruction default; RocksDB's exact fanout; a flag name | Weeks | No. Look it up. That is what documentation is for |
| Procedures | Implementing beam search; writing a page-table walk; deriving a backward rule | Months | Yes — and only retrieval practice works |
| Models | Why decode is memory-bound; why quorums must intersect; the RUM trade | Years, if built by construction | Mostly self-maintaining — but they rot silently into slogans |
The failure mode specific to this track is the third row. A model you built by measuring degrades into a phrase you can say. "Decode is memory-bound" survives; the ability to derive \(I = 2b/\text{bytes}\) and explain why batch size is the only variable does not. And the phrase feels identical from the inside — which is the illusion of competence, and it is why self-assessment alone will not catch it.
So the target is procedures and models, not facts. Everything below is aimed at those two.
The Four Mechanisms
| # | Mechanism | Cost | Targets | Frequency |
|---|---|---|---|---|
| 1 | Review queue | ~10 min/week | procedures + models | weekly |
| 2 | Rebuild drill | 3 h × 8 | procedures | stage boundaries + mid-stage |
| 3 | Forced reuse | 0 (already in the plan) | procedures | continuous |
| 4 | Teaching test | ~2 h × 6 | models | every ~6 months |
Total ≈ 55 hours, versus 15 in the original plan. The marginal 40 hours buy the difference between arriving at P15 with fifteen projects behind you and arriving with three you still remember.
1. The Review Queue
Ten minutes a week. The cheapest and highest-yield of the four.
Every project produces items. An item is a question with a checkable answer, never a fact to re-read:
Q: Derive the arithmetic intensity of transformer decode. Why does model size not appear?
Q: Why must a leader not commit a previous-term entry by counting replicas? Sketch the
counterexample.
Q: Given L1=128KB, what block size for a 512x512 fp32 matmul, and why is the measured
optimum smaller?
Q: A Bloom filter at 24 bits/key shows 0 false positives in 200k probes. What can you
conclude?
Answer out loud or on paper, before checking. Retrieval is the mechanism; recognition is not. If you look first, you have re-read, and re-reading is measured as low-utility.
Intervals
Standard expanding schedule, with one modification:
1 day -> 3 days -> 7 days -> 21 days -> 60 days -> 180 days
wrong at any interval -> reset to 1 day
The modification: an item you answer correctly but slowly or hesitantly moves back one interval rather than forward. Fluency is the point — a derivation you can grind out in ten minutes is not one you can use in a design review.
Running it
Reuse the review.py from your swe-interview-prep track, or a plain markdown file with
a due date per item. The tool matters far less than the ten minutes.
Item budget: 4–6 per project, ~80 total. Resist more. A hundred-item queue becomes a chore, gets skipped, and then does nothing. Pick the derivations you would be embarrassed to fumble.
2. The Rebuild Drill
Three hours, no assistant, no notes, no reference to the original. Then diff.
This is the only mechanism that tests procedural knowledge honestly, because writing code from scratch cannot be faked by recognition.
The schedule — eight drills, not five
The original plan had one per stage boundary. That leaves gaps of up to 29 weeks. Eight drills, placed at both stage boundaries and mid-stage:
| Week | Rebuild | Tests |
|---|---|---|
| W15 | Multi-head attention forward pass | P01 |
| W26 | Beam search + the distance counter | P02 |
| W40 | Bloom filter, including optimal k | P04 |
| W54 | The SSTable reader and its sparse index | P04 |
| W67 | Raft's election logic, including the vote rule | P05 |
| W83 | The watermark tracker with idle-partition handling | P07 |
| W99 | EMA profile + the full metric suite | P08 |
| W117 | Blocked matmul, and predict the optimal block size | P14 |
Scoring, which is the part that matters
| Outcome | Reading |
|---|---|
| Working in 3 h | You own it |
| Not working, but you can find your own bugs | You mostly own it |
| Cannot start | You never owned it. Schedule a proper re-read, and add three review-queue items |
The third outcome is the one the drill exists to detect, and it will happen at least once. It is information, not failure — an ability you believed you had and do not is far better discovered in week 54 than in week 122 when P15 depends on it.
Rules
- Delete the original from the screen. Different directory, no tab open.
- Time-box hard at 3 hours. The drill measures fluency, not persistence.
- Diff afterwards and write three lines: what you forgot, what you did better, what the original does that you now think is wrong.
That third line is the interesting one. Six months of intervening work sometimes makes your old design look wrong — and occasionally it is, which is a genuine result about your own progress.
3. Forced Reuse
Free, because it is already in the dependency graph. The strongest retention mechanism here, and it costs nothing extra.
A component you must use six months later cannot decay quietly — it fails loudly:
| Reused | Where | Gap |
|---|---|---|
| P02's index | P03 (W27), P08 (W84) | 12 → 69 weeks |
| P04's engine | P05 (W55), P07 (W76) | 12 → 33 weeks |
| P05's fault injector | P06 (W68), P07 (W76), P15 (W118) | 1 → 51 weeks |
| P01's model | P13 (W21), P08 (W84) | 13 → 76 weeks |
| P13's framework | P14 (W111) | 57 weeks |
| Everything | P15 (W118+) | up to 110 weeks |
Exploit this deliberately. When a later project pulls in an earlier component:
- Do not read your old code first. Try to use it from memory of its interface.
- Where the interface surprises you, that is a decayed item → review queue.
- Where you have to change the old component, ask whether the original design was wrong or whether the new requirement is.
Step 1 converts a routine integration into a free retrieval test, and it costs nothing.
4. The Teaching Test
Two hours, every ~6 months. Six times.
Explain one system, from scratch, to an audience that will ask questions — a colleague, a meetup, a blog post with comments open, or a recorded talk you actually publish.
Why it targets models specifically. Teaching forces you to reconstruct the why, and questions attack exactly the joints where your understanding has thinned into a slogan. You cannot answer "but why does batch size affect intensity and model size not?" from a memorised phrase.
| Month | Topic | Natural venue |
|---|---|---|
| M6 | What a Transformer costs, by sequence length | Internal brown-bag |
| M12 | The three amplifications, with your own numbers | Blog post (W43) |
| M18 | Why your Raft had a bug and how the checker found it | Internal or meetup |
| M24 | When offline metrics fail to predict online | Internal — your team cares about this one |
| M30 | What a syscall and a context switch actually cost | Blog post |
| M34 | The integrated system and its result | Conference or the paper's talk |
These coincide with the publication timeline in portfolio.md, so five of the six are already scheduled work. The addition is doing them live, with questions, rather than only in writing.
The question you cannot answer is the deliverable. Write it down. It is a review-queue item and possibly a re-read.
The Schedule
Everything above, on one calendar. Additions to the existing plan are marked +.
| When | Activity | Cost |
|---|---|---|
| Weekly, in the review session | Review queue | 10 min |
| End of every project | Add 4–6 queue items | 15 min |
| W15, W40, W54, W83, W99 | + Mid-stage rebuild drills | 3 h each |
| W26, W67, W117 | Stage-boundary rebuild drills | 3 h each |
| M6, M12, M18, M24, M30, M34 | + Teaching test (live) | 2 h each |
| Every integration | Use the old component from memory first | 0 |
| M7, M15, M22, M26, M31 | Re-take calibration C3 + C5 | 30 min |
Total: ~55 hours, 3.8% of the journey. For comparison, the writing allocation is 10% and the reading allocation is 15%. Retention at under 4% is not extravagant; it was at 1%.
What Not To Try To Retain
Actively let these go. Trying to hold everything is why retention plans get abandoned.
- Parameter defaults and flag names. Look them up.
efConstruction=200is not knowledge. - API surfaces of libraries. Including your own from two years ago — that is what the README is for.
- Exact numbers. Retain ratios and derivations. "L1:L2:DRAM ≈ 1:6.5:133" and the ability to re-derive; not "0.91 ns".
- Paper details beyond the one idea. The extraction note is the artifact; the paper is re-findable.
- Anything from a project whose exit criteria you cut. If you shipped without leveled compaction, do not carry review items about it.
- Syntax. Four languages over 34 months means constant lookup, and that is correct.
The test: would a competent engineer look this up rather than recall it? Then let it go. Retention is for the things you must have available while reasoning, not the things you must have available.
Measuring Whether It Works
Retention is invisible without measurement, which is why most retention plans quietly stop.
Three signals, all already collected:
- Rebuild-drill outcomes. Working / bugs-findable / cannot-start, per drill. A trajectory toward "cannot start" is the alarm.
- Calibration C3 and C5, re-taken at each stage review. Both measure skills the track claims to build. A flat score across two stages means the program is not working for you, and the response is to change the program.
- Forced-reuse friction. When P08 pulls in P02's index in week 84, how long before it is working? Note it. Under an hour means the interface was good and you remembered; half a day means something decayed.
Record all three in notebook/retention-log.md. Ten lines a year.
If the drills are all passing and C3/C5 are rising, cut this page's budget — reduce to five drills and four teaching tests and reinvest the hours. Retention effort should be sized to measured decay, not to anxiety about decay.
References
- Ebbinghaus, H. Memory: A Contribution to Experimental Psychology. 1885. The forgetting curve, and the finding that spacing repetitions beats massing them.
- Roediger, H. L., Karpicke, J. D. Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention. Psychological Science 17(3), 2006. Retrieval practice beats re-study, by a wide margin, at long delays.
- Karpicke, J. D., Roediger, H. L. The Critical Importance of Retrieval for Learning. Science 319(5865), 2008.
- Cepeda, N. J. et al. Distributed Practice in Verbal Recall Tasks: A Review and Quantitative Synthesis. Psychological Bulletin 132(3), 2006. Optimal spacing scales with the retention interval you need — which for a 34-month journey means intervals out to months, not days.
- Bjork, R. A., Bjork, E. L. Desirable Difficulties in Theory and Practice. JARMAC 9(4), 2020. Why the rebuild drill must be hard and unaided to be worth doing.
- Koriat, A., Bjork, R. A. Illusions of Competence in Monitoring One's Knowledge. Journal of Experimental Psychology 31(2), 2005. Recognition feels like recall — the empirical reason mechanism 2 exists at all.
- Dunlosky, J. et al. Improving Students' Learning With Effective Learning Techniques. Psychological Science in the Public Interest 14(1), 2013. Practice testing and distributed practice rank high utility; re-reading and highlighting rank low.
- Anderson, J. R. Acquisition of Cognitive Skill. Psychological Review 89(4), 1982. The procedural/declarative distinction underlying What Actually Decays.
External Feedback
The weakest structural property of this track, stated plainly: every score in it is self-assigned, against a rubric I wrote, by the person who did the work. The scorecard even cites Kruger–Dunning as the reason to score down when unsure — which is an admission that self-assessment is unreliable, followed by no mechanism to fix it.
Thirty-four months of that produces a confident practitioner of your own blind spots.
This page is the correction: five mechanisms, ordered by cost, of which the first two are free and mandatory.
Table of Contents
- Why Self-Assessment Is Not Enough
- The Five Mechanisms
- 1. The Adversarial Self-Review
- 2. Publishing Early
- 3. The Reproduction Exchange
- 4. A Reviewer
- 5. The Public Artifact
- The Minimum Viable Loop
- How To Receive a Review
- What Feedback Cannot Fix
- References
Why Self-Assessment Is Not Enough
Three distinct failures, each needing a different remedy — which is why one mechanism does not suffice.
1. You cannot see what you do not know. The scorecard asks whether your failure analysis is thorough. If your model of "thorough" is missing a category of failure, you score yourself 4 and are right by your own standard and wrong by the field's. Remedy: a reviewer, or published work that attracts correction.
2. Standards drift toward your own past work. By project eight you are scoring against project seven, not against the anchors. The scorecard warns about this ("everything rising smoothly is suspicious") and cannot detect it. Remedy: a fixed external reference — someone else's code, a published benchmark, a reproduction.
3. Reproducibility is unfalsifiable from the inside. "Another engineer could run this" is a claim about a person who is not you, and you cannot evaluate it. Your machine has your environment, your assumptions, your undocumented step. Remedy: an actual other person, which is why P15 requires one — and month 34 is far too late for the first attempt.
The Five Mechanisms
| # | Mechanism | Cost | Fixes | When |
|---|---|---|---|---|
| 1 | Adversarial self-review | 1 h/project | partially (2) | Every project |
| 2 | Publishing early | already scheduled | (1), (2) | 8× over the journey |
| 3 | Reproduction exchange | 2–3 h × 4 | (3) | Stage boundaries |
| 4 | A reviewer | 1 h/quarter of someone's time | (1), (2) | Quarterly |
| 5 | Public artifact | high | all three | 2–3× |
1 and 2 are mandatory and free. 3 costs a little coordination and fixes the failure nothing else can. 4 is the highest-value and requires another person. 5 is optional.
1. The Adversarial Self-Review
One hour, at the end of every project, before you score it.
Not a re-read. A deliberate role switch: you are now a reviewer who has been asked to find the reason this work should not be believed, and you are being judged on how good your objections are.
The mechanics matter, because the role switch fails if you do it from the same chair:
- Wait at least one night after finishing the report. Same-day is re-reading.
- Read the report only, not the code. A reviewer does not have your code loaded.
- Write objections as a numbered list, in
notebook/<project>-adversarial.md, in the second person. "You claim X; the data shows Y." - Then answer each one, in writing, in the first person.
- Objections you cannot answer become report limitations. That is the output.
The eleven questions
Work through all of them. The ones that feel inapplicable are often the productive ones.
- Which claim in this report has the weakest evidence?
- What baseline did you choose, and what would a hostile reviewer say you should have chosen instead?
- Which number would change most if the workload were realistic rather than synthetic?
- What did you measure because it was easy, rather than because it mattered?
- Where does the report say "we observe X" when it means "X happened once"?
- Which experiment has no negative control, and what would one have shown?
- What is the sample size, and can it resolve the effect being claimed?
- Which conclusion depends on an assumption stated nowhere in the report?
- If this result is wrong, what is the most likely reason?
- What would someone who built this professionally immediately notice is missing?
- Would you believe this report if someone else wrote it?
Question 11 is the whole exercise compressed. Ask it out loud.
Using an assistant here is explicitly encouraged
This is the one place where an AI is unambiguously on the right side of the policy: asking for a solution substitutes for your thinking, asking for an attack forces more of it.
Prompts that work:
- "Here is my report. Argue that the conclusion does not follow from the data."
- "What is the strongest objection a reviewer would raise about the baseline?"
- "Which sentences here assert something the data does not show?"
- "What did I fail to measure that would change the conclusion?"
An assistant will produce a more honest attack on your work than you will, because it has nothing invested in the answer. It cannot fix failure (1) — it does not know your field's standards better than the literature does — but it is genuinely good at (2).
2. Publishing Early
Already scheduled in portfolio.md: eight posts across the journey, starting at week 20. That timeline exists for portfolio reasons. It is also, and more importantly, the cheapest source of external correction available, and it should be treated as load-bearing rather than incidental.
What makes a post produce feedback rather than silence:
| Do | Why |
|---|---|
| Lead with the surprising number | Nobody engages with "I built an LSM tree". People engage with "my bytecode VM is 1.5× slower than the tree-walk it replaced" |
| State the setup precisely enough to attack | A post whose method cannot be criticised cannot be corrected either |
| Include the runnable code | One file beats a repo beats a snippet. It converts a reader into a reproducer |
| Say what you got wrong | The most-shared systems posts contain a mistake the author found. It also signals you want correction rather than applause |
| Ask a specific question at the end | "Has anyone measured this on x86?" gets answers. "Thoughts?" does not |
The first post is week 20 and it is deliberately small — the bytecode-vs-tree-walk result. Counterintuitive, fully reproducible in one file, and the kind of thing where someone who knows more will tell you so. That is the point.
Read the corrections as data. When someone says "your benchmark is measuring dispatch,
not the VM", that is a scorecard adjustment you could not have made yourself. Record it in
the project's notebook/<project>-adversarial.md.
3. The Reproduction Exchange
The only mechanism that fixes failure (3), and the one most likely to be skipped.
At each stage boundary, get one other person to run one of your repositories from a clean clone and report what broke. In exchange, do the same for them.
The protocol
- Pick the project with the clearest headline number.
- Give them only the repository URL and the headline claim: "
make benchshould print recall@10 ≥ 0.95 at efSearch=128, in under 10 minutes." - Answer no questions during the attempt. Every question they need to ask is a defect in your README, and you want the list.
- They report: did it run, how long did setup take, what did they have to work out, what number did they get.
- You fix the README. Then someone else tries.
Why this is worth the coordination
The failures it finds are invariably the same shape and invariably invisible to you: an undocumented dependency, a hard-coded path, a Python version assumption, a step you do without thinking. Every one is a genuine reproducibility defect and none is findable alone.
It also converts P15's hardest exit criterion from a surprise into a rehearsal. By month 34 you will have done this four times and the final reproduction will work.
Where to find the other person
In descending order of likelihood: a colleague on your team (offer to reciprocate on their side project); a friend who codes; someone who commented on one of your posts; a local meetup. Failing all of those: a fresh VM or container with nothing installed, following your own README literally and refusing yourself any recalled knowledge. Weaker, because you cannot un-know your own setup — but far better than nothing, and it still catches the missing dependency.
4. A Reviewer
The highest-value mechanism, and the only one that fixes failure (1).
One person, one hour per quarter, who reads one report and tells you what is wrong with it. Eleven or twelve conversations across the journey.
What to ask for, concretely
Vague requests get vague answers. Ask for exactly this:
"I've written up a benchmark of X. Could you spend 45 minutes on the report and tell me: (a) which claim you would not believe, (b) what baseline you would have expected, and (c) what a person who does this professionally would notice is missing? I'm not looking for encouragement — I'm looking for the objection I can't see."
That framing does three things: it bounds the time, it asks three specific questions, and it explicitly licenses criticism, which most people withhold by default.
Who
| Candidate | Strength | How to ask |
|---|---|---|
| A senior colleague in an adjacent domain | Knows the standards, already knows you | Offer reciprocity on their work |
| Someone whose blog you read | Domain-strong | Email with the specific ask above; a surprising number say yes to one well-scoped review |
| A former colleague | Low social cost | Quarterly catch-up with an artifact attached |
| A meetup / user group | Live questions, which surface different gaps | Present the result; the Q&A is the review |
| An academic in the area | Highest standards | Only with a genuinely novel result — see research directions |
The realistic answer for most people is the first row, and one hour a quarter is a small ask that most senior engineers enjoy being asked for.
If you genuinely cannot find one
Say so in the honest-status section of your portfolio, and lean harder on mechanisms 2 and 3. Publishing with an explicit question is a way of asking the internet to review you, and it works often enough to matter. But do not pretend this gap is closed — an unreviewed 34-month body of work has a knowable weakness and naming it is more credible than not.
5. The Public Artifact
Optional. Two or three times, at most.
A conference talk, a workshop paper, an open-source tool people actually use. High cost, high signal — the feedback is unsolicited, from strangers, with no social reason to be kind.
The tools most likely to attract real users are named in portfolio.md: the fault injector and the linearizability checker. A GitHub issue saying "this deadlocks under condition X" is the purest feedback in this document.
Do not schedule this. It should emerge from work that turned out well, not from a calendar entry — a talk given because a deadline arrived is a talk with nothing in it.
The Minimum Viable Loop
If you do nothing else on this page, do these. They cost ~1 hour per project plus four half-days across 34 months, and they close two of the three failures.
- Adversarial self-review before every scorecard, eleven questions, written down
- Publish at week 20, then follow the schedule in portfolio.md
- One reproduction exchange per stage boundary — five total, any willing person
- Record every external correction in the project's adversarial note, and let it move a scorecard score
That last item is what makes the loop real. A correction that does not change a score did not close a loop.
How To Receive a Review
The mechanism only pays if the response to criticism is useful, and the default human response is not.
| Do | Do not |
|---|---|
| Ask clarifying questions about the objection | Explain why they misunderstood |
| Write it down verbatim before responding | Respond immediately |
| Separate "this is wrong" from "I would have done it differently" — both useful, differently | Treat all criticism as equally binding |
| Say "I don't know" when you don't | Improvise a defence |
| Fix the thing, then tell them you did | Argue and change it silently |
| Ask "what would convince you?" | Ask "is it good?" |
The single most useful follow-up question: "What would you have expected to see instead?" It converts a vague discomfort into a specific missing experiment, and it is the question that most often produces the next week's work.
And on defensiveness: notice it, name it to yourself, and continue. The reflex to defend is strongest exactly where the criticism is most accurate, which makes it a useful signal rather than a failing. The cost of acting on it is a wrong belief you keep for thirty months.
What Feedback Cannot Fix
Honest boundaries, so you do not over-invest.
- Whether the journey is worth doing. That is a question about your goals, and outsiders will answer it with their goals.
- Motivation. Praise is pleasant and does not survive week 61. The mechanisms in sustainability.md do.
- Whether your implementation is correct. That is what tests and fault injection are for. A reviewer reading a report cannot find your race condition.
- Taste, quickly. Design judgement comes from building fifteen systems and reading their literature. A reviewer accelerates it; nothing substitutes for it.
Feedback fixes blind spots, drifting standards, and reproducibility. Those three, and they are exactly the three that self-assessment cannot reach.
References
- Kruger, J., Dunning, D. Unskilled and Unaware of It. Journal of Personality and Social Psychology 77(6), 1999. Self-assessment is least reliable precisely where ability is lowest — the reason this page exists.
- Ericsson, K. A. et al. The Role of Deliberate Practice in the Acquisition of Expert Performance. Psychological Review 100(3), 1993. Immediate, informative feedback is one of the defining conditions of deliberate practice; without it, repetition does not produce improvement.
- Collberg, C., Proebsting, T. A. Repeatability in Computer Systems Research. CACM 59(3), 2016. The study that found a large fraction of published systems results could not be rebuilt — evidence that authors systematically overestimate their own reproducibility.
- Kahneman, D. Thinking, Fast and Slow. FSG, 2011. On the difficulty of seeing one's own reasoning errors from the inside.
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth. ACM TOPLAS 38(4), 2016. What a rigorous evaluation looks like — a useful external standard when no human reviewer is available.
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004. On seeking criticism early and treating it as the most valuable input a project receives.
- Edmondson, A. Psychological Safety and Learning Behavior in Work Teams. Administrative Science Quarterly 44(2), 1999. Why the explicit "I'm not looking for encouragement" framing materially changes the quality of the review you get.
The Numbers
A working reference for every constant this journey depends on: where it comes from, how it was obtained, what assumptions hold it up, what breaks it, and — the part most reference cards omit — which decision it actually changes.
This is not a table to memorise. It is a set of derivations you should be able to reconstruct. A number you can only recall is a number you will misapply the moment the hardware, the dtype, or the access pattern changes.
Table of Contents
- How To Read This
- The Reference Machine
- 1. The Latency Hierarchy
- 2. Memory Bandwidth
- 3. Boundary Crossings
- 4. Concurrency Primitives
- 5. Arithmetic Throughput
- 6. The Interpreter Tax
- 7. Transformer Arithmetic
- 8. Retrieval and ANN
- 9. Storage Engines
- 10. Distributed Systems
- 11. Statistics and Experimentation
- 12. Recommendation Systems
- 13. Floating Point
- 14. How These Numbers Lie
- The One-Page Table
- References
How To Read This
Reproduce all of it first. Everything in sections 1–5 comes from one shipped program:
cd tools
cc -O2 -o machine-baseline machine-baseline.c
./machine-baseline # the tables below, on your machine
./machine-baseline --json > mine.json
python3 baseline.py mine.json # diff vs the reference + 8 invariant checks
baseline.py does not just diff. It checks eight invariants that must hold on any
machine — a monotone hierarchy, a syscall costing ≥50× an empty loop, fsync ≥100× a
syscall — and tells you which conclusions in this track still apply to you if one fails.
A failed invariant almost always means a broken measurement rather than exotic hardware.
Every figure carries one of three labels. The label is load-bearing; a plan that treats a reported number as a measured one is how you end up optimising against a fiction.
| Label | Meaning |
|---|---|
| measured | Produced by running a named script on the reference machine. Reproducible. Will differ on yours — the shape will not |
| derived | Computed from measured quantities or from an identity. The arithmetic is shown so you can check it |
| reported | From a datasheet or paper. Not verified here. Treated with suspicion, and the suspicion is documented |
And every entry answers four questions:
- What is the number?
- How was it obtained — with the derivation, not just the result.
- What assumption holds it up, and what makes it wrong?
- What decision does it change? A number that changes no decision is trivia.
The Reference Machine
Everything labelled measured comes from this machine. Record yours in
notebook/001-machine-baseline.md in week one
and re-derive the ratios; the absolute values will move by 2–5× and almost none of the
conclusions will.
platform : macOS 15.0, arm64 (Apple Silicon), 12 cores
python : CPython 3.14.0, numpy 2.4.6 (BLAS = Apple Accelerate)
compiler : clang, -O2 unless stated
load : NON-IDLE. loadavg ~4.3 during several runs.
Tail latencies here are pessimistic; medians are close to right.
filesystem: APFS on the internal SSD (/dev/disk3s5)
The load average is stated on purpose. A benchmark taken on a machine doing other work is not wrong, it is conditioned — and a reader who does not know that will misread every p99 on this page. See §14.
1. The Latency Hierarchy
Script: a pointer-chase over a single random cycle, one pointer per 64-byte line. measured
| Working set | ns/dependent load | Level | Cycles @ 3.5 GHz |
|---|---|---|---|
| 4 KB | 0.89 | L1d | 3.1 |
| 32 KB | 0.91 | L1d | 3.2 |
| 64 KB | 0.92 | L1d | 3.2 |
| 128 KB | 0.91 | L1d (still!) | 3.2 |
| 192 KB | 5.08 | L2 | 17.8 |
| 256 KB | 5.64 | L2 | 19.7 |
| 1 MB | 5.94 | L2 | 20.8 |
| 4 MB | 5.85 | L2 | 20.5 |
| 8 MB | 7.00 | L2 / SLC edge | 24.5 |
| 16 MB | 14.00 | transition | 49.0 |
| 32 MB | 69.08 | DRAM-ish | 241.8 |
| 64 MB | 97.85 | DRAM | 342.5 |
| 128 MB | 112.66 | DRAM | 394.3 |
| 512 MB | 121.10 | DRAM | 423.9 |
The derivation that matters
\[ \text{L1} : \text{L2} : \text{DRAM} \;=\; 0.91 : 5.94 : 121.10 \;=\; \mathbf{1 : 6.5 : 133} \]
Memorise the ratio, not the nanoseconds. The ratio has been roughly 1 : 10 : 100 for twenty years across wildly different hardware, because it is set by physics and economics (SRAM area per bit, distance to the memory controller, DRAM row-activation time) rather than by any vendor's choices. The absolute numbers halve every few years; the ratio does not move.
Why the measurement must be a random cycle
A dependent load chain is required: each load's address comes from the previous load's result, so the CPU cannot overlap them. Without that you measure throughput (several loads in flight) rather than latency.
A random cycle is required on top of that. My first attempt used a fixed 64-byte stride, which is exactly sequential cache lines — the hardware prefetcher recognises it instantly and every "miss" is already in flight. That version reported 1.30 ns at 512 MB, i.e. it claimed DRAM was as fast as L1. Sattolo's algorithm (a single random cyclic permutation) fixed it. Details in §14.
The 128 KB L1 is unusual and worth noticing
Most x86 cores have a 32–48 KB L1d. This machine holds 0.91 ns out to 128 KB, which is an Apple performance-core design choice. Consequence for you: any blocking or tiling constant you tune here is not portable. When P14 tells you to predict the optimal matmul block size from your L1 size, this is the number you predict from — and it is 4× the value you would assume from an x86 background.
What decisions this changes
- P14 tiling. The optimal block size is the largest \(B\) with \(3B^2 \times 4\text{ bytes} \le \text{L1}\). At 128 KB: \(B \le \sqrt{128{,}000/12} = 103\). Measured optimum was 32 — which is 3× smaller than the cache-capacity bound, because vector-register pressure and loop overhead bind first. That gap between the naive capacity prediction and the measured optimum is exactly the kind of thing you only learn by measuring.
- P12 context-switch cost. A switch that evicts a 1 MB working set costs \(16{,}384 \text{ lines} \times 121\text{ ns} \approx 2\,\text{ms}\) of subsequent refill in the worst case — three orders of magnitude more than the 1.7 µs switch itself. This is why E4 sweeps working-set size, and why a ping-pong benchmark understates switching so badly.
- P02 ANN. 100k vectors × 128 dims × 4 B = 51 MB — comfortably DRAM-resident, so brute force runs at bandwidth, not at latency (it is a streaming scan, fully prefetchable). The graph walk is the opposite: random pointer chasing at ~121 ns per hop. The graph trades a prefetchable scan for a dependent-load chain, and that is a second, independent reason it loses at small \(n\) beyond the interpreter overhead.
- Any data structure choice. A linked list traversal is a dependent-load chain; an array scan is prefetchable. At 133× the difference, this dominates asymptotic complexity for anything under ~10⁴ elements.
2. Memory Bandwidth
Script: sequential sum / store over a 512 MB buffer. measured
| Operation | GB/s |
|---|---|
| Sequential read | 57.5 |
| Sequential write | 62.9 |
| Read + write (both directions counted) | 99.1 |
Latency × bandwidth: the concurrency you must sustain. By Little's Law (\(L = \lambda W\)), to sustain 57.5 GB/s at 121 ns of latency you need
\[ L = 57.5 \times 10^{9}\,\text{B/s} \times 121 \times 10^{-9}\,\text{s} = 6{,}958\ \text{bytes in flight} \approx 109\ \text{cache lines} \]
derived. So the memory system must keep ~109 line-fills outstanding at all times to reach peak. A single dependent-load chain keeps exactly one in flight, which is why pointer chasing achieves \(64\,\text{B} / 121\,\text{ns} = 0.53\) GB/s — 108× below peak. Same hardware, same DRAM, two orders of magnitude apart, decided entirely by whether the accesses are independent.
This one calculation explains: why prefetching exists, why SoA beats AoS, why batching helps, why GPUs need thousands of threads, and why "our database is slow and the CPU is idle" is almost always a dependent-load problem.
Arithmetic intensity and the ridge point
For any kernel doing \(W\) FLOPs while moving \(Q\) bytes from DRAM:
\[ I = W/Q \quad\text{[FLOP/byte]}, \qquad \text{perf} = \min(P,\; I \times B), \qquad I_{\text{ridge}} = P/B \]
derived, from tools/roofline.py:
| Hardware | Dense peak | Bandwidth | Ridge | Note |
|---|---|---|---|---|
| H100 SXM | 989.4 TF/s | 3,350 GB/s | 295 F/B | reported. 1979 TF/s is the 2:4 sparse figure — do not use it for a dense GEMM |
| A100 80GB | 312 TF/s | 2,039 GB/s | 153 F/B | reported. 624 TF/s is sparse |
| Server CPU | 5.1 TF/s | 307 GB/s | 17 F/B | reported, AVX-512 fp32, 8ch DDR5-4800 |
| This machine | ~1.7 TF/s | 57.5 GB/s | ~29 F/B | derived from the measured BLAS peak and read bandwidth |
The asterisk is the point. Vendor headline FLOP/s are routinely quoted with 2:4 structured sparsity, doubling the number for a workload that does not apply to dense matmul. Use the sparse figure as your denominator and every efficiency you report is halved. Check the footnote every time.
3. Boundary Crossings
measured (C, -O2), except where noted.
| Operation | Cost | In L1 hits | Note |
|---|---|---|---|
| Empty loop iteration | 0.31 ns | 0.34 | The measurement floor |
getpid() | 1.23 ns | 1.4 | NOT a syscall — libc caches the pid |
clock_gettime(MONOTONIC) | 18.00 ns | 20 | Also not a syscall — served from a shared page |
close(-1) — real trap, fails immediately | 127.59 ns | 140 | A genuine user→kernel→user round trip |
| Pipe round trip, 2 processes | 3,272–3,864 ns | ~3,900 | 4 syscalls + 2 context switches |
| ⇒ implied context switch | 1,383–1,706 ns | ~1,680 | \((\text{rt} - 4\times128)/2\), derived |
write(4K) + fsync | 90–105 µs | ~107,000 | ~9,500–11,000 durable writes/s. measured, and real — fsync must reach the device |
Two of these are given as ranges, and that is the honest form. Across four runs of
machine-baseline on the same machine the pipe round trip varied by 18% and fsync by
16%, because both are scheduler- and device-sensitive in ways the pointer chase is not.
Quoting 1,676 ns to four significant figures — as an earlier draft of this page did —
implies a precision the measurement does not have. Run it three times and report the
spread; a single run of a load-sensitive benchmark is an anecdote.
The getpid() trap, in full
getpid() at 1.23 ns is four times an empty loop iteration. A mode switch cannot
cost four instructions; the number is physically impossible for a syscall. glibc and
Apple's libc cache the pid in userspace after the first call, so you are timing a
function call and a load.
Essentially every "syscalls only cost a nanosecond" claim on the internet traces to this benchmark. It is the single most common microbenchmarking error in systems work, and it is instructive precisely because the result looks plausible if you do not know the floor.
The fix: use a syscall that must trap and that fails immediately, so you measure the
boundary and not the work. close(-1) is ideal: it validates the descriptor, fails, and
returns. clock_gettime is not a valid choice either — 18 ns is too fast for a trap,
because it is served from a shared kernel/user page (the commpage on macOS, the vDSO on
Linux) precisely to avoid one.
What the numbers mean structurally
\[ \text{loop iteration} : \text{syscall} : \text{context switch} : \text{fsync} \;=\; 1 : 412 : 5{,}406 : 290{,}000 \]
derived. Five orders of magnitude across four operations you routinely treat as similar. Consequences:
- A syscall is ~140 L1 hits. Doing one per 4-byte read is why unbuffered I/O is
catastrophic and why
io_uring,sendmmsg, and vectored I/O exist. It is also whyclock_gettimewas moved out of the kernel — at 128 ns a timestamp in a hot loop would be a visible cost, and at 18 ns it is merely annoying. - A context switch is ~13 syscalls — in the best case. This is a ping-pong benchmark with a tiny working set and warm caches. The dominant real cost is cache and TLB pollution, which does not appear here at all. See §1 for the ~2 ms worst case.
- fsync is ~700 DRAM round trips. At ~10,900 durable writes/second, a fsync-per-write storage engine is capped at 10,900 writes/s regardless of everything else you do. That single number is why group commit exists, and it is the ceiling P04 measures itself against in E10.
A caution on the reads. The sequential-read and random-4K-read figures I measured on
this machine are page-cache numbers, not device numbers — 14 GB/s sequential and
1.06M IOPS at 4K both exceed what any consumer SSD can do, and the first exceeds the
DRAM bandwidth in §2. fcntl(F_NOCACHE) on APFS is advisory and
did not evict. macOS has no O_DIRECT. I therefore report no device-level read
numbers; measure yours in P04 milestone 1
with a working-set several times RAM, and state your methodology. The write+fsync figure
survives because fsync has to reach durable media to return.
4. Concurrency Primitives
measured, single thread, uncontended.
| Operation | Cost | vs relaxed atomic |
|---|---|---|
volatile long++ | 0.33 ns | — |
atomic_fetch_add, relaxed | 2.01 ns | 1.0× |
atomic_fetch_add, seq_cst | 3.97 ns | 2.0× |
pthread_mutex_lock + unlock | 6.38 ns | 3.2× |
malloc(64) + free | 18.71 ns | 9.3× |
malloc(64K) + free | 18.48 ns | 9.2× |
Reading these correctly
Sequential consistency costs exactly 2× relaxed here. The difference is the memory
barrier: seq_cst must order this operation against all others, which on arm64 means a
dmb ish (or the ldaddal acquire-release form) that prevents the store buffer from
being drained lazily. Relaxed atomics only guarantee atomicity of the read-modify-write
itself. Decision this changes: a statistics counter incremented on every request
should be relaxed — you want the count, not an ordering guarantee — and choosing
seq_cst by default doubles its cost for nothing.
An uncontended mutex is only 3.2× a relaxed atomic, which is far cheaper than most people assume. Modern mutexes are a compare-and-swap on the fast path and never enter the kernel unless contended. The catastrophic case is contention: once a waiter has to sleep, you pay a context switch (~1,676 ns, 260× the uncontended lock) plus the cache effects. Decision this changes: "avoid locks, they're slow" is wrong as stated. Avoid contended locks. An uncontended mutex around a rare operation is free; a spinlock on a hot cache line shared by 8 threads is a disaster. This is exactly what P12's E9 measures.
malloc is flat from 64 B to 64 KB — both ~18.6 ns. Small allocations come from a
per-thread size-class cache with no locking; 64 KB still fits below the mmap threshold.
The number jumps sharply once the allocator must call mmap, which is a syscall
(≥128 ns) plus page-fault costs on first touch. Decision this changes: allocation is
~20 ns, i.e. ~22 L1 hits, i.e. not free in an inner loop but nowhere near the cost of
a syscall. Pooling matters at 10⁷ allocations/s, not at 10⁵.
5. Arithmetic Throughput
Single-threaded C, \(N = 512\) fp32 matmul, \(2N^3 = 268\) MFLOP. measured.
| Variant | -O2 | -O3 -ffast-math -mcpu=native |
|---|---|---|
naive i,j,k | 1.91 GF/s | 3.14 GF/s |
loop-reordered i,k,j | 27.33 GF/s | 27.27 GF/s |
| blocked, B=16 | 10.06 | 46.44 |
| blocked, B=32 | 15.16 | 58.30 GF/s |
| blocked, B=64 | 21.64 | 38.29 |
| blocked, B=128 | 26.15 | 33.60 |
| Accelerate BLAS (AMX) | — | 1,679 GF/s |
Four separate lessons, all in one table
1. Loop order alone is 14.3× (1.91 → 27.33, same flags, same arithmetic, same
instruction count). i,j,k strides B by \(N\) — a new cache line every inner
iteration — while i,k,j walks B contiguously. derived: at \(N=512\), the i,j,k
inner loop touches \(512 \times 64\text{B} = 32\) KB of B per i,j pair with zero
reuse across j, so it streams the whole matrix from L2/DRAM \(N\) times.
2. At -O2, blocking is worse than plain reordering (26.15 vs 27.33 at the best
block size) and only becomes worth 2.1× once the inner loop vectorises. An
optimisation's value is conditional on which other optimisations are present. Measure
one in isolation and you will conclude it is useless when it is not — and this is a
completely general trap, not a matmul quirk.
3. The optimum block size is 32, not the capacity bound of 103. Register pressure and loop overhead bind before L1 capacity does.
4. Hand-written peaks at 58.3 GF/s against Accelerate's 1,679 — a 28.8× gap. That is not sloppy code. Accelerate dispatches to Apple's AMX matrix coprocessor: dedicated silicon with its own register file and a systolic datapath. This is P14's entire thesis, measurable on a laptop before writing a line of the project. Verified across sizes (N=256: 1,008 GF/s; N=512: 1,679; N=1024: 1,705; N=2048: 1,316) with fp32-consistent relative error ~10⁻⁷, so it is real arithmetic and not a lazy evaluation artifact.
The systolic-array derivation
derived, and worth doing by hand once. A \(k \times k\) array of multiply-accumulate cells, weight-stationary, activations flowing horizontally and partial sums vertically:
- operands fetched per cycle: \(O(k)\) (one column)
- MACs performed per cycle: \(k^2\)
- operand reuse: \(O(k)\)
At \(k = 256\) (TPUv1): \(2 \times 65{,}536 \times 700\times10^6 = 91.75\) TOPS, against the reported 92 TOPS. You can derive a real accelerator's headline number from two integers. The cost is total inflexibility — no branches, no gather, one operation — which is the same restriction-buys-performance trade as MapReduce's programming model, implemented in silicon.
6. The Interpreter Tax
CPython 3.14.0, timeit, min of 5 repeats. measured.
| Operation | ns | In L1 hits |
|---|---|---|
pass (loop overhead floor) | 8.28 | 9 |
| local variable read | 10.20 | 11 |
| global variable read | 10.43 | 11 |
try/except (no raise) | 10.24 | 11 |
| int add | 12.79 | 14 |
obj.x via __slots__ | 12.82 | 14 |
| tuple unpack | 13.06 | 14 |
obj.x (instance dict) | 13.12 | 14 |
list[0] | 14.95 | 16 |
| list comprehension, per element | 16.23 | 18 |
| float add | 17.69 | 19 |
dict['k'] | 19.45 | 21 |
isinstance() | 21.68 | 24 |
| function call | 24.73 | 27 |
| method call | 25.77 | 28 |
| string concat | 27.65 | 30 |
numpy.float32 scalar add | 34.43 | 38 |
next(generator) | 37.16 | 41 |
| f-string | 45.74 | 50 |
raise + catch ValueError | 86.92 | 96 |
| numpy add, 1-element array | 254.56 | 280 |
| numpy add, 1M array (per element) | 0.2551 | 0.28 |
numpy.dot, 1M (per element) | 0.0136 | 0.015 |
The dispatch crossover, derived
numpy array addition costs ~254.56 ns of fixed overhead plus ~0.2551 ns per element. Overhead equals element work at
\[ n^{*} = \frac{254.56}{0.2551} \approx \mathbf{998\ \text{elements}} \]
Below ~1,000 elements, a numpy operation is more dispatch than arithmetic. At \(n = 1\) you pay 254 ns to perform one addition that costs 0.26 ns — a 998× overhead ratio. This is the single most important number in P13 Phase II, and it is why operator fusion and graph mode exist: they amortise dispatch across more arithmetic.
It is also why numpy.dot at 0.0136 ns/element is 18.8× cheaper per element than
+ at 0.2551 — the dot product is one BLAS call over a contiguous buffer with FMA and
vectorisation, whereas elementwise add is memory-bound at 3 arrays × 4 bytes = 12 bytes
per 1 FLOP (\(I = 0.083\) FLOP/byte, far below the ~29 ridge).
Python vs C, quantified
| Python | C equivalent | Ratio | |
|---|---|---|---|
| dict lookup | 19.45 ns | ~1–5 ns hash+probe | ~4–20× |
| function call | 24.73 ns | ~1–2 ns | ~15–25× |
| float add | 17.69 ns | ~0.3 ns | ~60× |
| attribute access | 13.12 ns | ~0.9 ns (L1 load) | ~15× |
The measured 67.5× per-distance ratio in P02 (899 ns interpreted vs 13.3 ns BLAS) is exactly this tax, compounded because each distance involves a function call, an attribute access, a numpy dispatch, and a heap operation. That is the mechanism behind "my algorithm is right and my implementation is wrong."
Object sizes — the memory tax
measured, sys.getsizeof:
| Object | Bytes |
|---|---|
float | 24 |
int | 28 |
| tuple (empty) | 48 |
__slots__ instance, 2 attrs | 48 |
| list (empty) | 56 |
| dict (empty) | 64 |
| set (empty) | 216 |
plain instance + __dict__, 2 attrs | 344 |
__slots__ is a 7.2× reduction (344 → 48 bytes) for a two-attribute object.
Decision this changes: in P09 you simulate 10⁵–10⁶ user objects. At 344 B that is
344 MB before any data; at 48 B it is 48 MB. The difference decides whether the simulator
fits in memory, and it is one line of code.
Also note a float is 24 bytes for 8 bytes of payload — 3× overhead — which is why a
Python list of 10⁶ floats costs ~32 MB (56 + 8 B/pointer + 24 B/object) against numpy's
4 MB for fp32. 8×, before any speed consideration.
7. Transformer Arithmetic
derived, for \(B{=}1, H{=}12, d_h{=}64\) (so \(d_{model}=768\), GPT-2 small). Per layer, forward, 2 FLOPs per MAC.
| Component | FLOPs | Scaling |
|---|---|---|
| Q,K,V projections | \(3 \cdot 2BTd^2\) | linear in \(T\) |
| \(QK^\top\) | \(2BHT^2 d_h\) | quadratic |
| scores·V | \(2BHT^2 d_h\) | quadratic |
| output projection | \(2BTd^2\) | linear |
| FFN (4× expansion) | \(16BTd^2\) | linear |
| T | Attention GF | FFN GF | Quadratic share | Score matrix (fp32) |
|---|---|---|---|---|
| 128 | 0.65 | 1.21 | 2.7% | 0.8 MB |
| 512 | 3.22 | 4.83 | 10.0% | 12.6 MB |
| 1024 | 8.05 | 9.66 | 18.2% | 50.3 MB |
| 2048 | 22.55 | 19.33 | 30.8% | 201.3 MB |
| 4096 | 70.87 | 38.65 | 47.1% | 805.3 MB |
| 8192 | 244.81 | 77.31 | 64.0% | 3,221 MB |
At GPT-2's original 1024 context, attention is 18% of the compute. The quadratic term does not dominate until ~4096. Most "attention is the bottleneck" claims are context-length-dependent and wrong for the context they are made about.
Memory hits the wall long before FLOPs do: 3.2 GB for a single batch element's score matrix at \(T=8192\). This is the entire motivation for FlashAttention — the compute is identical, the materialisation is what is avoided.
RoPE, verified
measured. The defining property is that \(\langle \text{RoPE}(q,m), \text{RoPE}(k,n)\rangle\) depends only on \(m-n\):
m n m-n dot
5 3 2 -8.115791933
105 103 2 -8.115791933
7 5 2 -8.115791933
1000 998 2 -8.115791933
5 4 1 -8.519139825
50 49 1 -8.519139825
Identical to nine decimal places across a 200× range of absolute position. Norm preserved exactly (7.315343549 → 7.315343549), because rotations are orthogonal. Write both as tests before implementing — they catch the two standard bugs (wrong dimension pairing, and rotating values as well as queries/keys).
Decode is bandwidth-bound, and the identity that proves it
Per generated token, every weight is read once and shared across the batch:
\[ W = 2Nb, \qquad Q = N \cdot \text{bytes}, \qquad I = \frac{2Nb}{N\cdot\text{bytes}} = \frac{2b}{\text{bytes}} \]
Arithmetic intensity in decode depends on batch size and nothing else — not on model
size, not on kernel quality. derived, from tools/roofline.py,
7B params bf16 on H100:
| Batch | I (F/B) | Step (ms) | tok/s | MFU | Regime |
|---|---|---|---|---|---|
| 1 | 1.0 | 4.179 | 239 | 0.3% | memory-bound |
| 8 | 8.0 | 4.179 | 1,914 | 2.7% | memory-bound |
| 64 | 64.0 | 4.179 | 15,314 | 21.7% | memory-bound |
| 256 | 256.0 | 4.179 | 61,257 | 86.7% | memory-bound |
| 512 | 512.0 | 7.245 | 70,671 | 100% | compute-bound |
A batch-1 chatbot uses 0.3% of an H100's multipliers. The step time is identical from batch 1 to 256 because you are paying to move 14 GB of weights either way. This single table justifies continuous batching, explains why serving economics are what they are, and gives the batch needed to leave the memory-bound regime:
\[ b^{*} = I_{\text{ridge}} \times \text{bytes} / 2 \]
= 295 (bf16, H100), 153 (bf16, A100), 148 (fp8, H100). Quantising to fp8 halves the required batch because it halves \(Q\) while leaving \(W\) unchanged — the real reason low precision wins at inference is smaller operands, not faster arithmetic.
8. Retrieval and ANN
Relative contrast predicts difficulty; dimension does not
measured, uniform on the unit sphere, \(n = 10{,}000\), \(\mathrm{RC} = d_{\text{mean}}/d_1\):
| d | RC | \(d_{10}/d_1\) |
|---|---|---|
| 16 | 2.220 | 1.2303 |
| 64 | 1.356 | 1.0717 |
| 128 | 1.224 | 1.0465 |
| 512 | 1.097 | 1.0196 |
At \(d = 512\) the tenth neighbour is 2% further than the first. Nothing can reliably distinguish them, so an ANN benchmark on uniform high-dimensional data measures the dataset, not the index. Always report RC alongside recall.
The clustering trap, measured
A Gaussian perturbation with per-axis \(\sigma\) in \(d\) dimensions has expected norm \(\sigma\sqrt{d}\). Around unit-norm cluster centres:
| σ | σ√d (d=64) | RC |
|---|---|---|
| uniform | — | 1.356 |
| 0.25 | 2.00 | 1.393 ← indistinguishable from uniform |
| 0.15 | 1.20 | 1.608 |
| 0.10 | 0.80 | 1.992 |
| 0.05 | 0.40 | 3.371 |
Verify your independent variable actually varies before spending a run on it. Ten minutes of checking saved a worthless experiment here.
The two-factor speedup model
The most transferable diagnostic in the whole journey. derived, validated twice:
\[ \text{speedup} = \underbrace{\frac{n}{\text{dists/query}}}_{\text{algorithmic}} \Big/ \underbrace{\frac{\text{ns/dist}_{\text{graph}}}{\text{ns/dist}_{\text{brute}}}}_{\text{constant factor}} \]
| Dataset | Algorithmic | Constant factor | Predicted | Measured |
|---|---|---|---|---|
| uniform (RC 1.36) | 10,000/1,459 = 6.9× | 899 / 13.3 = 67.5× | 0.10× | 0.10× |
| clustered (RC 3.36) | 10,000/321 = 31.1× | 1317 / 12.8 = 102.9× | 0.30× | 0.30× |
Exact to two significant figures in both cases. It says something specific: the algorithm is right and the implementation is wrong — a different bug with a different fix than "the algorithm is wrong." Break-even in pure Python lands near \(n \approx 1.5\times10^5\); in compiled code the constant factor is ~1–2× and the crossover falls to a few thousand. This is why every serious ANN index is C++.
The clustered recall ceiling
measured, \(n{=}10\)k, \(d{=}64\), M=16, efC=100:
| efSearch | uniform recall@10 | clustered recall@10 | uniform dists/q | clustered dists/q |
|---|---|---|---|---|
| 10 | 0.3605 | 0.4840 | 397 | 193 |
| 64 | 0.8160 | 0.8345 | 1,459 | 321 |
| 128 | 0.9480 | 0.9030 | 2,484 | 501 |
| 256 | 0.9930 | 0.9670 | 4,059 | 840 |
Clustered is faster everywhere and worse above ef≈96. The clustered search cannot spend its budget — 840 distances at ef=256 versus 4,059. Mechanism: intra-cluster distance 0.521, inter-cluster 1.413 (measured, 20k sampled pairs), so a distance-based degree cap deletes every inter-cluster bridge deterministically. Full analysis in the worked notebook entry.
Filtered search over-fetch
derived. Post-filtering with selectivity \(s\) needs \(K \ge k/s\) candidates:
| s | K for k=10 | % of a 1M corpus |
|---|---|---|
| 0.1 | 100 | 0.01% |
| 0.01 | 1,000 | 0.10% |
| 0.001 | 10,000 | 1.00% |
| 0.0001 | 100,000 | 10.00% |
At \(s = 10^{-4}\) you are running efSearch ≥ 100,000, which is brute force with a
worse constant. This is the production p99 cliff on narrow filters. And it
understates the problem: independence between filter and similarity fails whenever the
filtered attribute correlates with position in embedding space, and in the adversarial
case no \(K\) suffices. Meanwhile brute force over the filtered set is \(O(sn)\) — at
\(s=10^{-4}\) on 1M vectors that is 100 distance computations, exact, and faster than
either alternative.
9. Storage Engines
The three amplifications
derived, fanout \(T = 10\), 64 MB base level.
Leveled: \(W \approx TL+1\), \(R \approx L+1\), \(S \approx 1 + 1/T\). Size-tiered: \(W \approx L+1\), \(R \approx TL\), \(S \approx 2\).
| Data | Levels | Leveled W / R / S | Size-tiered W / R / S |
|---|---|---|---|
| 1 GB | 2 | 21 / 3 / 1.10 | 3 / 20 / 2.11 |
| 8 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 64 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.11 |
| 512 GB | 4 | 41 / 5 / 1.10 | 5 / 40 / 2.11 |
Read this as a choice, not a ranking. Leveled writes each byte ~31× to keep reads at 4 tables and space at 1.1×; size-tiered writes ~4× and pays with 30 tables per read and 2.1× the disk. On write-heavy ingest size-tiered is ~8× cheaper in device wear; on read-heavy serving leveled is ~7× cheaper in seeks. There is no third option that wins both — that is the RUM conjecture with numbers attached.
Bloom filters
Optimal \(k = (m/n)\ln 2\), giving \(\text{fpr} = 0.6185^{m/n}\). derived, and measured against 100k keys / 200k absent probes:
| bits/key | k | Fill ratio | Theory | Measured | RAM/100k keys |
|---|---|---|---|---|---|
| 4 | 3 | 0.5275 | 0.14689 | 0.14709 | 0.05 MB |
| 8 | 6 | 0.5281 | 0.02158 | 0.02204 | 0.10 MB |
| 10 | 7 | 0.5042 | 0.00819 | 0.00822 | 0.12 MB |
| 16 | 11 | 0.4973 | 0.00046 | 0.00047 | 0.20 MB |
Theory matches within 5%. Fill ratio sits at 0.5 at every optimum — that is the entropy argument appearing in the data: at the optimum each bit carries maximum information.
Read amplification for an absent key with 40 runs on disk (derived):
| bits/key | fpr | Disk reads | Improvement |
|---|---|---|---|
| none | 1.0 | 40.0 | 1× |
| 4 | 0.147 | 5.88 | 7× |
| 10 | 0.00819 | 0.328 | 122× |
| 16 | 0.00046 | 0.018 | 2,179× |
125 KB of RAM per 100k keys turns 40 disk reads into 0.33. That is why 10 bits/key is the near-universal default in RocksDB, LevelDB and Cassandra — and now you can derive it rather than cite it.
Measurement resolution caveat, and it generalises. At 24 bits/key the predicted fpr is ~10⁻⁵, so 200k probes expect 1.2 false positives. Observing 0 or 3 is Poisson noise, not a result. To measure a rate \(p\) you need ~\(100/p\) trials for a ~10% relative standard error — so 10⁻⁵ needs ~10 million probes. State the resolution of your experiment before reporting any ratio.
10. Distributed Systems
Quorum intersection
Two quorums of size \(Q\) from \(N\) replicas intersect iff \(2Q > N\), i.e. \(Q \ge \lfloor N/2 \rfloor + 1\). If \(2Q \le N\), two disjoint quorums can each decide without seeing the other. Split-brain is not an implementation bug; it is this inequality being violated — most often by an operator changing the replica count.
derived, \(N=3\), independent failure probability \(p\):
| p | Single node | 2-of-3 quorum | Downtime/year |
|---|---|---|---|
| 0.01 | 99.00% | 99.9702% | 3.65 d → 2.6 h |
| 0.05 | 95.00% | 99.2750% | 18.25 d → 15.9 h |
| 0.10 | 90.00% | 97.2000% | 36.5 d → 10.2 d |
The assumption doing all the work is independence, and it is usually false: same rack, same power domain, same bad deploy, same poisoned request. Correlated failure collapses this table entirely. Say so in any availability claim you make.
The tail at scale
If a request touches \(N\) independent components each exceeding its p99 1% of the time, \(P(\text{at least one slow}) = 1 - 0.99^N\). derived:
| N | P(≥1 slow) |
|---|---|
| 1 | 1.00% |
| 10 | 9.56% |
| 100 | 63.40% |
| 1000 | 99.996% |
At 100 components the majority of requests hit a p99 event. Tail latency is not an edge case at scale, it is the common case — and it is why fan-out architectures need hedged requests, and why "our p50 is fine" tells you nothing.
Stragglers
Job completion is a maximum over tasks, and maxima behave badly. Simulated, 200 tasks of 10 s on 20 workers, greedy list scheduling, 2,000 trials (derived):
| Scenario | Completion | vs ideal | With backup tasks |
|---|---|---|---|
| no stragglers | 100.00 s | 1.00× | — |
| 1% at 10× | 112.45 s | 1.12× | 108.64 s (1.09×) |
| 5% at 10× | 149.87 s | 1.50× | 110.01 s (1.10×) |
| 1% at 50× | 448.20 s | 4.48× | 108.64 s (1.09×) |
Two tasks out of two hundred inflate the job 4.5×. Backup tasks recover almost all of it. That is why MapReduce §3.6 exists, and it is far more persuasive as a table you generated than a sentence you read.
11. Statistics and Experimentation
Sample size
\[ n_{\text{per arm}} = \frac{2(z_{1-\alpha/2} + z_{\text{power}})^2\sigma^2}{\delta^2} \]
At α=0.05, power=0.8: \((1.96 + 0.8416)^2 = 7.849\), which is where the folklore "16σ²/δ²" comes from. derived, σ=0.5:
| MDE δ | n per arm |
|---|---|
| 0.05 | 1,570 |
| 0.025 | 6,280 |
| 0.0125 | 25,117 |
Exactly 4× per halving of the effect. Do this before proposing an experiment: if your realistic effect is 0.5% and you get 20,000 users a week, the experiment needs a year and should not be run. This calculation kills more proposals than any other number here.
Peeking
Simulated A/A tests, 4,000 trials, 500 users per look, stop at first significance (derived):
| Looks | False-positive rate |
|---|---|
| 1 | 5.12% |
| 2 | 8.58% |
| 5 | 14.47% |
| 10 | 19.30% |
| 20 | 24.15% |
| 50 | 32.80% |
Checking a dashboard daily for a fortnight turns a 5% error rate into ~25%. One in four "wins" is noise. Not a subtlety — the largest single source of false results in industrial experimentation.
Sample-ratio mismatch
χ² with 1 d.f.; alarm at p < 0.001, i.e. χ² > 10.83 (derived):
| Split | χ² | Verdict |
|---|---|---|
| 200,000 / 200,000 | 0.000 | ok |
| 201,000 / 199,000 | 10.000 | ok (just) |
| 202,000 / 198,000 | 40.000 | ALARM |
A 0.5% imbalance on 400k users is a five-sigma event. It means the arms are not comparable populations, so no analysis of the metric is valid. SRM is a gate before analysis, not a footnote after it.
Multiple comparisons
\(1 - 0.95^m\): 22.6% at 5 metrics, 40.1% at 10, 64.2% at 20. Declare one primary metric in advance; everything else is a guardrail or exploratory.
12. Recommendation Systems
EMA half-life
\(u_t = \alpha x_t + (1-\alpha)u_{t-1}\); half-life \(h = \ln(0.5)/\ln(1-\alpha)\). derived:
| α | Half-life (interactions) | Effective window 1/α | Weight on last 10 |
|---|---|---|---|
| 0.02 | 34.31 | 50.0 | 18.3% |
| 0.05 | 13.51 | 20.0 | 40.1% |
| 0.10 | 6.58 | 10.0 | 65.1% |
| 0.20 | 3.11 | 5.0 | 89.3% |
| 0.50 | 1.00 | 2.0 | 99.9% |
At α=0.5 the profile is "the last two things you clicked". At α=0.02 it will not notice a genuine interest change for a month. The mean profile is the α→0 limit and can never adapt. Choose α from a stated assumption about drift rate, then test that assumption — which is precisely what P09 exists to make possible.
Popularity concentration
Zipf exponent α, share of total engagement mass (derived):
| Zipf α | Top 1% of items | Top 10% |
|---|---|---|
| 0.5 | 9.4% | 31.1% |
| 0.8 | 30.0% | 57.1% |
| 1.0 | 53.0% | 76.5% |
| 1.2 | 75.1% | 90.3% |
At α=1.0, recommending only the top 1% of the catalogue captures 53% of engagement. A trivial bestseller list beats a mediocre personalised model on accuracy metrics, while covering 1% of the catalogue. This is why the popularity baseline is mandatory and why coverage must be reported next to NDCG, every time.
The retrieval ceiling
\[ \text{recall}_{\text{end-to-end}}@k \le \text{recall}_{\text{retrieval}}@K \]
Obvious once stated, routinely ignored. If retrieval recall@K is 0.90, no ranker can exceed 0.90 end to end. Teams spend quarters on ranking while the retrieval stage silently caps them. Your P02 index's recall is a hard ceiling on your P08 recommender, and measuring where that ceiling binds is one of the most satisfying cross-project measurements in the journey.
13. Floating Point
reported (IEEE 754), with the consequences that matter here.
| Format | Bits (s/e/m) | Decimal digits | Max | Min normal |
|---|---|---|---|---|
| fp64 | 1/11/52 | ~15.9 | 1.8e308 | 2.2e-308 |
| fp32 | 1/8/23 | ~7.2 | 3.4e38 | 1.2e-38 |
| bf16 | 1/8/7 | ~2.4 | 3.4e38 | 1.2e-38 |
| fp16 | 1/5/10 | ~3.3 | 65,504 | 6.1e-5 |
bf16 and fp16 are both 16 bits and they are not interchangeable. bf16 keeps fp32's 8-bit exponent and sacrifices mantissa; fp16 keeps 10 mantissa bits and shrinks the exponent to 5. Gradients routinely underflow fp16's 6.1e-5 minimum — which is exactly why fp16 training needs loss scaling and bf16 does not. bf16 won for training because range matters more than precision when your values span many orders of magnitude.
Machine epsilon for fp32 is \(2^{-23} = 1.19\times10^{-7}\). This sets your gradient check tolerance: 1e-5 relative is achievable, 1e-7 is not, and demanding 1e-9 in fp32 means chasing a bug that does not exist.
Floating-point addition is not associative. \((a+b)+c \ne a+(b+c)\) in general, so a
different summation order legitimately gives a different answer. Consequences you will
meet: your autodiff will not match PyTorch bit-for-bit and that is not necessarily a
bug (test in fp64 to distinguish); a fused kernel that reassociates changes results and
must document it; and numpy.sum uses pairwise summation while a naive loop does not, so
they disagree on large arrays and numpy is the more accurate one.
14. How These Numbers Lie
Four measurements on this page were wrong before they were right. Each failure is a general trap, and finding them is more instructive than the final numbers.
1. The prefetcher ate my latency measurement
First attempt: pointer chase with a fixed 64-byte stride. Reported 1.30 ns at 512 MB — claiming DRAM was as fast as L1.
Why: a constant stride of exactly one cache line is the single easiest pattern for a hardware prefetcher. Every "miss" was already in flight. Worse, with stride 8 pointers into a power-of-two array, \(\gcd\) made the cycle revisit only \(n/8\) distinct addresses, shrinking the true working set 8×.
Fix: Sattolo's algorithm — a single random cyclic permutation over one pointer per cache line. Random order defeats stride prefetchers; a single cycle guarantees every load depends on the previous one. Lesson: if a memory benchmark reports a flat line across the hierarchy, the prefetcher is doing your work.
2. The compiler deleted my benchmark
Second attempt at the same measurement returned 0.00 ns at every size. The chase
loop was eliminated: its only consumer was a local volatile that was then discarded.
Fix: a file-scope volatile sink assigned after the timed loop, plus
asm volatile("" ::: "memory"). Lesson: if a benchmark reports zero, it did not run.
Always print the raw elapsed time and iteration count, not just the derived per-op
figure — the derived figure hides the failure and the raw one exposes it instantly.
3. Three attempts at branch misprediction, three different wrong things
- Attempt 1:
if (data[i]) c++;— predictable vs random arrays. Both 0.19–0.20 ns, no difference. clang compiled the branch into a conditional add (cmov). There was no branch to mispredict. - Attempt 2: volatile counters in both arms to force a real branch. Predictable 2.02 ns, unpredictable 1.74 ns — the unpredictable case was faster. The volatile stores now dominated, and the predictable pattern hammered one address 1,024 times in a row, creating a store-forwarding dependency that the alternating case avoided. I was measuring store throughput.
- Attempt 3: abandoned.
I therefore report no measured branch-misprediction cost. The architectural figure is ~10–20 cycles (reported), which at 3.5 GHz is ~3–6 ns. Lesson: when a microbenchmark resists three careful attempts, the honest output is "not measured", not the number you expected to find. Publishing attempt 2's −0.28 ns as "misprediction is free" would have been worse than silence.
4. The page cache pretended to be a disk
Measured 14,976 MB/s sequential read and 1,063,490 IOPS at 4K random, with
fcntl(F_NOCACHE) set and incompressible random data, on the real internal SSD.
Both are impossible: 14.9 GB/s exceeds the 57.5 GB/s DRAM bandwidth by a suspicious
margin for a device read, and no consumer SSD does 1M IOPS at 4K. F_NOCACHE on APFS is
advisory; macOS has no O_DIRECT.
What survived: sequential write + fsync (~6,700–7,900 MB/s) and 4K write + fsync (~90 µs), because fsync must reach durable media to return. Those are real. The reads are page-cache numbers and are labelled as such.
Lesson: sanity-check every measurement against a physical bound you already know. The DRAM bandwidth number from §2 is what exposed the disk number in §3. Numbers that violate a bound you have independently measured are not surprising results; they are broken experiments.
The One-Page Table
Everything normalised to the L1 hit (0.91 ns) on the reference machine. Print this.
| Operation | Time | × L1 hit |
|---|---|---|
| L1 hit | 0.91 ns | 1 |
| Branch (predicted, in-loop) | ~0.2 ns | 0.2 |
| Relaxed atomic add | 2.01 ns | 2.2 |
| seq_cst atomic add | 3.97 ns | 4.4 |
| L2 hit | 5.94 ns | 6.5 |
| Uncontended mutex lock+unlock | 6.38 ns | 7.0 |
Python pass (loop floor) | 8.28 ns | 9 |
| Python dict lookup | 19.45 ns | 21 |
malloc + free | 18.71 ns | 21 |
| Python function call | 24.73 ns | 27 |
| DRAM (random) | 121.10 ns | 133 |
| Real syscall | 127.59 ns | 140 |
| numpy op dispatch (1-elem) | 254.56 ns | 280 |
| ANN distance, interpreted | ~899 ns | 988 |
| Context switch (best case) | ~1,530 ns (1,383–1,706) | ~1,680 |
| write(4K) + fsync | ~95,000 ns (90–105 µs) | ~104,000 |
| Context switch + 1 MB WS refill | ~2 ms (derived) | ~2.2M |
The three ratios to carry in your head
- L1 : L2 : DRAM = 1 : 6.5 : 133. Stable across decades and vendors.
- Syscall : context switch ≈ 1 : 12, and both are dwarfed by the cache pollution that follows. (Both endpoints vary run to run; the ratio is stable.)
- fsync : DRAM ≈ 780 : 1. Durability is the most expensive thing a program can ask for, by three orders of magnitude.
References
- Jeff Dean, Latency Numbers Every Programmer Should Know (via Peter Norvig, Teach Yourself Programming in Ten Years). The ancestor of this page. Most circulating copies are ~2012 vintage and the absolute values are stale; the ratios are not.
- Drepper, U. What Every Programmer Should Know About Memory. Red Hat, 2007. The definitive treatment of §1 and §2, including why pointer chasing is the correct latency probe.
- Williams, S., Waterman, A., Patterson, D. Roofline. CACM 52(4), 2009.
- Goldberg, D. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 1991. §13.
- Mytkowicz, T. et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009. Measurement bias from link order and environment size — the formal version of §14.
- Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013. §10.
- Hoefler, T., Belli, R. Scientific Benchmarking of Parallel Computing Systems. SC 2015. Twelve rules; §14 is what happens when you break them.
- Little, J. D. C. A Proof for the Queuing Formula L = λW. Operations Research 9(3), 1961. The bytes-in-flight derivation in §2.
- Gregg, B. Systems Performance, 2nd ed. Pearson, 2020. The USE method and the correct way to measure disk without fooling yourself.
- Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970. §9.
- Sattolo, S. An algorithm to generate a random cyclic permutation. Information
Processing Letters 22(6), 1986. The single-cycle shuffle in
tools/machine-baseline.c; Fisher–Yates would produce several cycles and the walk would visit only a fraction of the working set. - Kohavi, R., Tang, D., Xu, Y. Trustworthy Online Controlled Experiments. Cambridge, 2020. §11.
Proofs
Eighteen results this track leans on, each derived and then numerically verified.
Every claim here is checked by tools/proofs.py — 116 checks, all
passing. A derivation on a page is an assertion until something tests it, and shipping
the first without the second is what rule 8
forbids.
cd tools && python3 proofs.py # 116/116 checks passed
python3 proofs.py --verbose # with the numbers behind each
Table of Contents
- How To Use This
- P1 — Why Attention Divides by √dₖ
- P2 — The Optimal Bloom Hash Count
- P3 — The Bloom False-Positive Rate
- P4 — Quorum Intersection
- P5 — Little's Law and Bytes in Flight
- P6 — Decode Intensity Is Batch Size
- P7 — Why Reverse-Mode Autodiff
- P8 — The Matmul Backward Rule
- P9 — Sample Size and the Four-Times Law
- P10 — Why Peeking Destroys Your Error Rate
- P11 — EMA Half-Life
- P12 — Post-Filter Over-Fetch
- P13 — The Three Amplifications
- P14 — The Tail at Scale
- P15 — Systolic Operand Reuse
- P16 — Distance Concentration
- P17 — Cosine and L2 Coincide on Unit Vectors
- P18 — Zipf Head Mass
- References
How To Use This
Not as a reference to look things up in. As a set of derivations to reproduce.
The calibration battery measures whether you can do back-of-envelope arithmetic, and the highest-return remedy for a low score is working through this page on paper. Each proof is short enough to redo in ten minutes and each one licences a decision you will otherwise make by folklore.
Each entry has four parts: Claim · Proof · Verification (real output from
proofs.py) · What it licenses.
P1 — Why Attention Divides by √dₖ
Claim. For queries and keys with i.i.d. zero-mean unit-variance components, \(\mathrm{Var}(q \cdot k) = d_k\). Therefore scores have typical magnitude \(\sqrt{d_k}\), and dividing by \(\sqrt{d_k}\) restores unit variance.
Proof. Let \(q, k \in \mathbb{R}^{d_k}\) with all components independent, \(\mathbb{E}[q_i] = \mathbb{E}[k_i] = 0\), \(\mathrm{Var}(q_i) = \mathrm{Var}(k_i) = 1\).
\[ q \cdot k = \sum_{i=1}^{d_k} q_i k_i \]
Each term has \(\mathbb{E}[q_i k_i] = \mathbb{E}[q_i]\mathbb{E}[k_i] = 0\) by independence, and
\[ \mathrm{Var}(q_i k_i) = \mathbb{E}[q_i^2 k_i^2] - 0 = \mathbb{E}[q_i^2]\mathbb{E}[k_i^2] = 1 \]
The \(d_k\) terms are mutually independent, so variances add:
\[ \mathrm{Var}(q \cdot k) = \sum_{i=1}^{d_k} \mathrm{Var}(q_i k_i) = d_k \qquad\blacksquare \]
Why that matters. Standard deviation \(\sqrt{d_k}\) means at \(d_k = 64\) scores are typically ±8, and gaps between the largest and second-largest are several units. \(\mathrm{softmax}\) of such a vector is nearly one-hot, and its Jacobian \(\partial p_i/\partial z_j = p_i(\delta_{ij} - p_j)\) vanishes when any \(p_i \to 1\). The layer stops learning.
Verification — 20,000 samples per dimension:
P1 Var(q·k) = d_k at d=16 measured 16.0, predicted 16
P1 Var(q·k) = d_k at d=64 measured 63.7, predicted 64
P1 Var(q·k) = d_k at d=256 measured 254.8, predicted 256
P1 Var(q·k) = d_k at d=1024 measured 1006.0, predicted 1024
P1 scaling raises softmax entropy
entropy unscaled 0.006 -> scaled 2.240 (max possible 2.996)
Entropy 0.006 out of a possible 2.996 is the saturation, measured. Scaling recovers 2.240 — most of the available entropy — and the layer can learn again.
What it licenses. P01's E-scale experiment: predict what removing the scale does to attention entropy, then measure. You now know the prediction is "collapse toward zero", and why.
P2 — The Optimal Bloom Hash Count
Claim. With \(m\) bits and \(n\) keys, false-positive rate is minimised at \(k^{*} = (m/n)\ln 2\).
Proof. After inserting \(n\) keys with \(k\) hashes, a given bit is still 0 with probability
\[ \left(1 - \frac{1}{m}\right)^{kn} \approx e^{-kn/m} \]
A false positive requires all \(k\) probed bits to be 1, so (treating bits as independent — the source of the small measured gap)
\[ f(k) = \left(1 - e^{-kn/m}\right)^{k} \]
Let \(c = n/m\) and minimise \(\ln f = k \ln(1 - e^{-kc})\):
\[ \frac{d}{dk}\ln f = \ln(1 - e^{-kc}) + k \cdot \frac{c,e^{-kc}}{1 - e^{-kc}} = 0 \]
Substitute \(x = e^{-kc}\), so \(k = -\ln x / c\):
\[ \ln(1-x) - \frac{\ln x \cdot x}{1-x} = 0 \]
which is satisfied at \(x = 1/2\) by symmetry of \(\ln(1-x)\) and \(\frac{x\ln x}{1-x}\) about that point. Then \(e^{-kc} = 1/2\) gives
\[ k^{*} = \frac{\ln 2}{c} = \frac{m}{n}\ln 2 \approx 0.693 \cdot \text{bits per key} \qquad\blacksquare \]
The elegant consequence. At \(x = 1/2\), each bit is 1 with probability exactly one half — the filter is at maximum entropy, carrying the most information per bit. That is the information-theoretic reason this is the optimum, not a coincidence.
Verification — closed form against a brute-force grid search over \(k\):
| bits/key | numeric argmin | closed form |
|---|---|---|
| 4 | 2.770 | 2.773 |
| 8 | 5.550 | 5.545 |
| 10 | 6.930 | 6.931 |
| 16 | 11.090 | 11.090 |
| 20 | 13.860 | 13.863 |
P2 at k* each bit is 1 with probability 1/2 P(bit=1) = 0.500000000000
What it licenses. The universal 10-bits/key default gives \(k = 7\), and you can now derive it rather than cite RocksDB. Also Monkey: if the optimum depends only on \(m/n\), allocating uniformly across LSM levels of very different \(n\) cannot be optimal.
P3 — The Bloom False-Positive Rate
Claim. At the optimal \(k\), \(\text{fpr} = 2^{-k^{*}} = 0.6185^{m/n}\).
Proof. Substituting \(e^{-k^{*}n/m} = 1/2\) into \(f(k) = (1 - e^{-kn/m})^k\):
\[ f(k^{*}) = \left(1 - \tfrac{1}{2}\right)^{k^{*}} = 2^{-k^{*}} = 2^{-(m/n)\ln 2} = \left(2^{-\ln 2}\right)^{m/n} = 0.61850\ldots^{,m/n} \qquad\blacksquare \]
Verification:
| bits/key | \(f(k^{*})\) | \(0.6185^{m/n}\) |
|---|---|---|
| 4 | 0.146342 | 0.146339 |
| 8 | 0.021416 | 0.021415 |
| 10 | 0.008193 | 0.008192 |
| 16 | 0.000459 | 0.000459 |
| 20 | 0.000067 | 0.000067 |
And against a real filter with 100k keys and 200k absent probes
(tools/bloom.py): theory 0.00819, measured 0.00822.
What it licenses. Each additional 10 bits/key divides the fpr by \(0.6185^{10} = 0.0082\), i.e. ~122×. That single ratio sizes every Bloom decision in P04, and it turns 40 disk reads for an absent key into 0.33.
P4 — Quorum Intersection
Claim. Every two quorums of size \(Q\) drawn from \(N\) replicas intersect iff \(2Q > N\).
Proof. (⇐) Suppose \(2Q > N\) and let \(A, B\) be quorums with \(A \cap B = \emptyset\). Then \(|A \cup B| = |A| + |B| = 2Q > N\), but \(A \cup B\) is a subset of the \(N\) replicas, so \(|A \cup B| \le N\) — contradiction. Hence they intersect.
(⇒) Suppose \(2Q \le N\). Take \(A\) as any \(Q\) replicas and \(B\) as \(Q\) of the remaining \(N - Q \ge Q\). These are disjoint quorums, so intersection is not guaranteed. \(\blacksquare\)
Therefore \(Q \ge \lfloor N/2 \rfloor + 1\).
Verification — exhaustive over all \(N \in [2,9]\) and all \(Q \in [1,N]\), testing every pair of \(Q\)-subsets for disjointness (48 cases, all agreeing with the predicate):
P4 N=3 Q=1: disjoint pair exists: True; 2Q>N: False
P4 N=3 Q=2: disjoint pair exists: False; 2Q>N: True
P4 N=6 Q=3: disjoint pair exists: True; 2Q>N: False
P4 N=6 Q=4: disjoint pair exists: False; 2Q>N: True
Note \(N=6, Q=3\): a majority of six is four, not three. Even splits are exactly where operators get this wrong, which is why even replica counts are discouraged.
What it licenses. Split-brain is not an implementation bug — it is this inequality being violated, usually by someone changing the replica count. And the availability arithmetic in numbers §10 rests on it.
P5 — Little's Law and Bytes in Flight
Claim. For any stable system, \(L = \lambda W\) — items in system = arrival rate × time in system. No distributional assumptions.
Proof sketch. Over a long interval \(T\), let \(A(T)\) be arrivals and \(\int_0^T L(t),dt\) the accumulated item-time. Each item contributes exactly its sojourn time, so \(\int_0^T L(t)dt = \sum_{i} W_i\). Dividing by \(T\):
\[ \bar{L} = \frac{\sum_i W_i}{T} = \frac{A(T)}{T} \cdot \frac{\sum_i W_i}{A(T)} = \lambda \bar{W} \qquad\blacksquare \]
The only requirement is that the limits exist — hence "any stable system".
Verification — Poisson arrivals at λ=500/s, fixed 50 ms sojourn, 400 s simulated, count integrated over time:
P5 Little's Law L = lambda*W measured L 24.94, predicted 25.00
The application that matters here. To sustain bandwidth \(B\) at latency \(\ell\), the memory system must hold \(B \times \ell\) bytes in flight:
P5 bytes in flight to sustain 57.5 GB/s at 121 ns
6958 bytes = 108.7 cache lines
What it licenses. A single dependent-load chain keeps one line in flight, so it achieves \(64/121\text{ns} = 0.53\) GB/s — 108× below peak on identical hardware. That one calculation explains prefetching, structure-of-arrays layouts, batching, why GPUs need thousands of threads, and why a graph walk loses to a scan.
P6 — Decode Intensity Is Batch Size
Claim. For dense autoregressive decode, arithmetic intensity is \(I = 2b/\text{bytes}\), independent of model size.
Proof. Per generated token with batch \(b\) and \(N\) parameters: every weight participates in exactly one multiply-accumulate per sequence, so
\[ W = 2Nb \quad\text{FLOPs} \]
The weights are read from memory once and shared across the batch:
\[ Q = N \cdot \text{bytes per parameter} \]
\[ I = \frac{W}{Q} = \frac{2Nb}{N \cdot \text{bytes}} = \frac{2b}{\text{bytes}} \qquad\blacksquare \]
\(N\) cancels. Model size affects how long a step takes, never whether you are memory-bound.
Verification — intensity computed for \(N\) spanning \(10^8\) to \(4\times10^{11}\) (a 4,000× range) at each batch size:
P6 intensity independent of N (b=1, 2B) I = 1.0 for every N
P6 intensity independent of N (b=8, 2B) I = 8.0 for every N
P6 intensity independent of N (b=64, 2B) I = 64.0 for every N
P6 intensity independent of N (b=512, 2B) I = 512.0 for every N
What it licenses. Setting \(I = I_{\text{ridge}}\) gives the batch needed to leave the memory-bound regime:
\[ b^{*} = \frac{I_{\text{ridge}} \times \text{bytes}}{2} \]
= 295 on an H100 at bf16, 148 at fp8. A batch-1 chatbot runs at ~0.3% MFU. This is the whole argument for continuous batching, and the real reason low precision wins at inference is smaller operands, not faster arithmetic.
P7 — Why Reverse-Mode Autodiff
Claim. For \(f : \mathbb{R}^n \to \mathbb{R}^m\), forward mode needs \(n\) passes to build the full Jacobian and reverse mode needs \(m\).
Proof. By the chain rule, \(J = J_L J_{L-1} \cdots J_1\). You never materialise these; you multiply by vectors, and matrix products are associative, so you may bracket either way:
- Right to left: \(J_L(J_{L-1}(\cdots(J_1 v)))\). Each step is a Jacobian-vector product. Seeding \(v = e_j\) yields column \(j\) of \(J\) — the derivative of every output with respect to one input. Full Jacobian: \(n\) passes.
- Left to right: \(((u^\top J_L)J_{L-1})\cdots J_1\). Each step is a vector-Jacobian product. Seeding \(u = e_i\) yields row \(i\) — the derivative of one output with respect to everything. Full Jacobian: \(m\) passes. \(\blacksquare\)
Verification — a 4-layer linear chain \(\mathbb{R}^6 \to \mathbb{R}^2\), Jacobian assembled both ways:
P7 forward and reverse produce the same Jacobian
max |J_fwd - J_rev| = 7.11e-15; forward used 6 passes, reverse 2
Identical to machine precision, at 3× the cost for forward mode on this shape.
What it licenses. Training has \(n \approx 10^7\)–\(10^{11}\) parameters and \(m = 1\) scalar loss. For a 10M-parameter model with a 10 ms forward pass:
| Method | Passes | Wall clock |
|---|---|---|
| Finite differences | \(n+1\) | ~28 hours |
| Forward-mode AD | \(n\) | ~28 hours |
| Reverse-mode AD | ~2 | ~20 ms |
A factor of \(5\times10^6\). That ratio is the entire reason deep learning is computationally possible, and the price is memory — every intermediate must live until its adjoint is consumed, which is what gradient checkpointing trades back.
P8 — The Matmul Backward Rule
Claim. For \(C = AB\) with upstream gradient \(\bar{C} = \partial L/\partial C\): \(\bar{A} = \bar{C}B^\top\) and \(\bar{B} = A^\top\bar{C}\).
Proof. Write \(C_{ij} = \sum_k A_{ik}B_{kj}\). By the chain rule,
\[ \bar{A}_{ik} = \frac{\partial L}{\partial A_{ik}} = \sum_{i^{\prime}j} \frac{\partial L}{\partial C_{i^{\prime}j}} \frac{\partial C_{i^{\prime}j}}{\partial A_{ik}} \]
Now \(\partial C_{i^{\prime}j}/\partial A_{ik} = \delta_{ii^{\prime}}B_{kj}\), because \(A_{ik}\) appears in \(C_{i^{\prime}j}\) only when \(i^{\prime} = i\), multiplied by \(B_{kj}\). So
\[ \bar{A}_{ik} = \sum_j \bar{C}_{ij}B_{kj} = \sum_j \bar{C}_{ij}(B^\top)_{jk} = (\bar{C}B^\top)_{ik} \]
Symmetrically, \(\partial C_{ij^{\prime}}/\partial B_{kj} = \delta_{jj^{\prime}}A_{ik}\) gives \(\bar{B}_{kj} = \sum_i A_{ik}\bar{C}_{ij} = (A^\top\bar{C})_{kj}\). \(\blacksquare\)
Verification — analytic gradients against central finite differences on a \(4\times3\times5\) product, every element of both \(A\) and \(B\):
P8 dA = dC B^T and dB = A^T dC
worst relative error vs finite differences: 1.20e-08
What it licenses. A backward pass is two matmuls of the same shape as the forward one, hence ~2× the cost — which is where the \(C \approx 6ND\) training-FLOPs rule comes from (1 forward + 2 backward). It is also the exit criterion for P13 Phase I: derive this, do not copy it.
P9 — Sample Size and the Four-Times Law
Claim. \(n_{\text{per arm}} = \dfrac{2(z_{1-\alpha/2} + z_{\text{power}})^2\sigma^2}{\delta^2}\), so halving the detectable effect quadruples the sample.
Proof. For a two-sample test with equal \(n\) and variance \(\sigma^2\), the difference in means has standard error \(\mathrm{SE} = \sigma\sqrt{2/n}\). To reject at level \(\alpha\) you need \(|\hat\delta| > z_{1-\alpha/2}\mathrm{SE}\); to do so with probability \(1-\beta\) when the true effect is \(\delta\), the distribution must be shifted far enough that
\[ \delta = (z_{1-\alpha/2} + z_{\text{power}}),\mathrm{SE} = (z_{1-\alpha/2} + z_{\text{power}}),\sigma\sqrt{2/n} \]
Solving for \(n\) gives the formula. Since \(n \propto \delta^{-2}\), replacing \(\delta\) by \(\delta/2\) multiplies \(n\) by 4. \(\blacksquare\)
Verification:
P9 (z_0.975 + z_0.80)^2 = 7.848880
the folklore '16 sigma^2/delta^2' is 2*7.849 = 15.70
P9 halving the MDE quadruples n
n = 1570, 6280, 25117; ratios 4.000, 4.000
P9 the formula delivers ~80% power
n=393 per arm, measured power 0.809 over 3000 trials
The last line is the important one: the formula is not just algebra, it delivers the power it promises — 0.809 measured against 0.80 nominal.
What it licenses. Compute this before building a variant. P10 makes it a pre-registration requirement, and it kills more proposed experiments than any other number in the track.
P10 — Why Peeking Destroys Your Error Rate
Claim. Testing repeatedly and stopping at the first significant result inflates the false-positive rate far above \(\alpha\).
Proof sketch. Let \(E_i\) be the event "significant at look \(i\)". You reject if \(\bigcup_i E_i\) occurs. Each \(P(E_i) \approx \alpha\), and while the \(E_i\) are positively correlated (they share data), they are far from identical — each look adds fresh data and a fresh chance for the random walk of the test statistic to cross the boundary. So
\[ P\left(\bigcup_{i=1}^{L} E_i\right) > \alpha \quad\text{and grows with } L \]
In the continuous limit, the test statistic under the null is a Brownian motion; by the law of the iterated logarithm it crosses any fixed boundary almost surely given enough looks. Peek forever and you reject with probability 1. \(\blacksquare\)
Verification — A/A simulations, 3,000 trials, 400 users per look, α=0.05:
| Looks | False-positive rate |
|---|---|
| 1 | 5.07% (nominal) |
| 5 | 13.47% |
| 20 | 23.50% |
What it licenses. Checking a dashboard daily for a fortnight turns a 5% error rate into roughly 25% — one in four "wins" is noise. The remedy is a pre-registered fixed sample size, or an explicitly sequential method that pays for the looks. This is the single largest source of false results in industrial experimentation.
P11 — EMA Half-Life
Claim. For \(u_t = \alpha x_t + (1-\alpha)u_{t-1}\), an observation's weight halves after \(h = \ln(0.5)/\ln(1-\alpha)\) steps.
Proof. Unrolling the recurrence,
\[ u_t = \alpha\sum_{i=0}^{\infty}(1-\alpha)^i x_{t-i} \]
so the observation \(i\) steps back carries weight \(w(i) = \alpha(1-\alpha)^i\). Setting \(w(h)/w(0) = 1/2\):
\[ (1-\alpha)^h = \tfrac{1}{2} \quad\Longrightarrow\quad h = \frac{\ln 0.5}{\ln(1-\alpha)} \qquad\blacksquare \]
The weights also form a geometric series summing to \(\alpha \cdot \frac{1}{1-(1-\alpha)} = 1\), so this is a proper weighted average.
Verification:
| α | half-life | \(w(h)/w(0)\) |
|---|---|---|
| 0.02 | 34.31 | 0.500000000 |
| 0.05 | 13.51 | 0.500000000 |
| 0.10 | 6.58 | 0.500000000 |
| 0.20 | 3.11 | 0.500000000 |
| 0.50 | 1.00 | 0.500000000 |
P11 EMA weights sum to 1 sum = 1.000000000000
What it licenses. At α=0.5 the profile is "the last two things you clicked"; at α=0.02 it will not notice a real interest change for a month. Choose α from a stated assumption about drift rate — and P09 is what finally lets you test that assumption against known ground truth.
P12 — Post-Filter Over-Fetch
Claim. Post-filtering an ANN result with selectivity \(s\) requires \(K \ge k/s\) candidates to return \(k\) results.
Proof. If the filter is independent of similarity, each of the top-\(K\) matches with probability \(s\), so the number surviving is \(\mathrm{Binomial}(K, s)\) with mean \(Ks\). Requiring \(\mathbb{E}[\text{survivors}] \ge k\) gives \(K \ge k/s\). \(\blacksquare\)
Two caveats the proof exposes. (1) This is an expectation: at \(K = k/s\) you fall short about half the time, so real systems over-fetch further. (2) Independence fails whenever the filtered attribute correlates with position in embedding space — and in the adversarial case, where all matching items lie outside the unfiltered top-\(K\), no \(K\) suffices.
Verification — 400 trials per selectivity, counting survivors among the top \(k/s\):
| \(s\) | \(K = k/s\) | mean survivors (target 10) |
|---|---|---|
| 0.5 | 20 | 10.17 |
| 0.1 | 100 | 9.89 |
| 0.01 | 1,000 | 10.01 |
| 0.001 | 10,000 | 10.11 |
What it licenses. At \(s = 10^{-4}\) on a 1M corpus you scan 10% of everything —
efSearch ≥ 100,000, which is brute force with a worse constant. This is the production
p99 cliff on narrow filters. Note the escape: exact brute force over the filtered set
is \(O(sn)\) = 100 distance computations, faster than either alternative and exact.
P13 — The Three Amplifications
Claim. With fanout \(T\) and \(L\) levels, leveled compaction gives \(W \approx TL\), \(R \approx L\), \(S \approx 1 + 1/T\); size-tiered gives \(W \approx L\), \(R \approx TL\), \(S \approx 2\).
Proof.
Leveled. Level \(i\) holds non-overlapping runs totalling \(T^i\) times the base size. Merging one byte from level \(i\) into level \(i+1\) requires rewriting the overlapping portion of level \(i+1\), which is \(T\)× larger — so each byte is rewritten \(\approx T\) times per level, and it traverses \(L\) levels: \(W \approx TL\). A point read consults at most one run per level plus L0: \(R \approx L + 1\). Since each level is fully merged, at most one obsolete copy of a key exists per level, dominated by the largest: \(S \approx 1 + 1/T\).
Size-tiered. Runs of similar size accumulate until \(T\) of them merge into one of the next tier. Each byte is written once per tier: \(W \approx L\). But up to \(T\) runs coexist per level, all of which a read must consult: \(R \approx TL\). And up to \(T\) copies of a key coexist, with compaction needing free space equal to its inputs: \(S \approx 2\). \(\blacksquare\)
Verification — \(T = 10\), 64 MB base:
| Data | \(L\) | Leveled W/R/S | Size-tiered W/R/S |
|---|---|---|---|
| 1 GB | 2 | 21 / 3 / 1.10 | 3 / 20 / 2.00 |
| 8 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.00 |
| 64 GB | 3 | 31 / 4 / 1.10 | 4 / 30 / 2.00 |
| 512 GB | 4 | 41 / 5 / 1.10 | 5 / 40 / 2.00 |
P13 neither strategy dominates on all three axes
leveled wins R and S, size-tiered wins W -- that is the conjecture, felt
What it licenses. This is the RUM conjecture with numbers. On write-heavy ingest size-tiered is ~8× cheaper in device wear; on read-heavy serving leveled is ~7× cheaper in seeks. There is no third option that wins both, and P04's headline figure is the crossover between them.
P14 — The Tail at Scale
Claim. If a request touches \(N\) independent components each slow with probability \(p\), the request is slow with probability \(1 - (1-p)^N\).
Proof. The request is fast only if every component is fast. By independence that is \((1-p)^N\), so the complement is \(1 - (1-p)^N\). \(\blacksquare\)
For small \(p\), \(1-(1-p)^N \approx Np\) — the tail probability grows roughly linearly in fan-out until it saturates.
Verification — 40,000 trials per \(N\), \(p = 0.01\):
| \(N\) | closed form | simulated |
|---|---|---|
| 1 | 1.00% | 1.03% |
| 10 | 9.56% | 9.21% |
| 100 | 63.40% | 62.92% |
| 500 | 99.34% | 99.31% |
What it licenses. At 100 components, the majority of requests hit a p99 event. Tail latency is not an edge case at scale, it is the common case — which is why P06 measures straggler inflation and why hedged requests exist. The independence assumption is generous; correlated slowness (a shared dependency, a GC storm) makes it worse.
P15 — Systolic Operand Reuse
Claim. A \(k \times k\) weight-stationary systolic array achieves \(O(k)\) operand reuse.
Proof. Each of the \(k^2\) cells holds one weight and performs one multiply-accumulate per cycle: \(k^2\) MACs/cycle. Per cycle the array ingests one column of \(k\) activations and emits one row of \(k\) partial sums — \(2k\) operand transfers. Hence
\[ \text{reuse} = \frac{k^2}{2k} = \frac{k}{2} = O(k) \qquad\blacksquare \]
Achieved by wiring, not caching: no tags, no misses, no replacement policy.
Verification:
| \(k\) | MACs/cycle | operands/cycle | reuse |
|---|---|---|---|
| 8 | 64 | 16 | 4.0× |
| 64 | 4,096 | 128 | 32.0× |
| 256 | 65,536 | 512 | 128.0× |
And the headline figure of a real accelerator from two integers:
P15 TPUv1 92 TOPS from k=256 at 700 MHz
2 * 256^2 * 700MHz = 91.75 TOPS (reported: 92)
What it licenses. You can derive a commercial accelerator's specification from its array dimension and clock. The cost is total inflexibility — no branches, no gather, one operation — which is the same restriction-buys-performance trade as MapReduce's programming model, implemented in silicon.
P16 — Distance Concentration
Claim. For i.i.d. coordinates, relative contrast \(\mathrm{RC} = d_{\text{mean}}/d_1 \to 1\) as dimension grows.
Proof sketch. For \(x, y\) with i.i.d. components, \(|x-y|^2\) is a sum of \(d\) i.i.d. terms, so by the law of large numbers its mean grows like \(d\) while by the central limit theorem its standard deviation grows like \(\sqrt{d}\). The relative spread is therefore
\[ \frac{\text{sd}(|x-y|)}{\mathbb{E}[|x-y|]} = O!\left(\frac{1}{\sqrt{d}}\right) \to 0 \]
All pairwise distances converge to the same value, so the nearest neighbour ceases to be meaningfully nearer than the mean and \(\mathrm{RC} \to 1\). \(\blacksquare\)
Verification — 1,500 uniform points on the unit sphere:
| \(d\) | RC |
|---|---|
| 2 | 4110.66 |
| 8 | 3.41 |
| 64 | 1.25 |
| 512 | 1.076 |
What it licenses. RC, not \(d\), predicts ANN difficulty — which is why every recall number in this track is reported with its RC. It also explains why a benchmark on uniform high-dimensional data measures the dataset rather than the index, and why real embeddings (concentrated near a low-dimensional manifold) behave far better than their ambient \(d\) suggests.
P17 — Cosine and L2 Coincide on Unit Vectors
Claim. For \(|a| = |b| = 1\): \(|a-b|^2 = 2 - 2\langle a,b\rangle\), so ranking by max dot product, max cosine, and min L2 give identical orderings.
Proof.
\[ |a-b|^2 = \langle a-b, a-b\rangle = |a|^2 + |b|^2 - 2\langle a,b\rangle = 2 - 2\langle a,b\rangle \]
\(|a-b|^2\) is a strictly decreasing affine function of \(\langle a,b\rangle\), and \(x \mapsto \sqrt{x}\) is increasing, so the orderings coincide exactly. Cosine equals the dot product because the norms are 1. \(\blacksquare\)
Verification — 400 unit vectors in \(\mathbb{R}^{32}\):
P17 ||a-b||^2 = 2 - 2<a,b> on unit vectors max deviation 8.88e-16
P17 max-dot and min-L2 give identical orderings top-10 identical: True
P17 the equivalence FAILS on unnormalised vectors
What it licenses. You may pick whichever metric is fastest to compute — and, more importantly, the equivalence holds only under normalisation. Forgetting to normalise silently changes the ranking with no error raised, which is exactly P02's E6 and one of the quietest recall bugs in retrieval systems.
P18 — Zipf Head Mass
Claim. Under a Zipf law with exponent \(\alpha\), the top \(f\) fraction of items carries a share of the mass that rises steeply with \(\alpha\).
Proof. With \(w(r) = r^{-\alpha}\) over ranks \(1..n\), the share of the top \(m\) is
\[ S(m) = \frac{\sum_{r=1}^{m} r^{-\alpha}}{\sum_{r=1}^{n} r^{-\alpha}} \approx \frac{\int_1^m r^{-\alpha}dr}{\int_1^n r^{-\alpha}dr} = \frac{m^{1-\alpha}-1}{n^{1-\alpha}-1} \quad (\alpha \ne 1) \]
For \(\alpha = 1\) both integrals are logarithms and \(S(m) \approx \ln m/\ln n\). \(\blacksquare\)
Verification — \(n = 10{,}000\), exact sums:
| \(\alpha\) | top 1% | top 10% |
|---|---|---|
| 0.5 | 9.4% | 31.1% |
| 0.8 | 30.0% | 57.1% |
| 1.0 | 53.0% | 76.5% |
| 1.2 | 75.1% | 90.3% |
What it licenses. At \(\alpha = 1\), recommending only the top 1% of a catalogue captures 53% of all engagement. A trivial bestseller list therefore beats a mediocre personalised model on any accuracy metric while covering 1% of the catalogue — which is why the popularity baseline is mandatory in P08 and why coverage must be reported next to NDCG every time.
References
- Vaswani, A. et al. Attention Is All You Need. NeurIPS 2017. §3.2.1 states the \(\sqrt{d_k}\) scaling with the variance argument in a footnote — P1 is that footnote, expanded.
- Bloom, B. H. Space/time trade-offs in hash coding with allowable errors. CACM 13(7), 1970. P2 and P3.
- Mitzenmacher, M., Upfal, E. Probability and Computing, 2nd ed. Cambridge, 2017. The independence-approximation caveat in P2, and P14.
- Gifford, D. K. Weighted Voting for Replicated Data. SOSP 1979. The origin of P4.
- Little, J. D. C. A Proof for the Queuing Formula L = λW. Operations Research 9(3), 1961. P5, including the distribution-free argument.
- Williams, S., Waterman, A., Patterson, D. Roofline. CACM 52(4), 2009. P6, P15.
- Baydin, A. G. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 18, 2018. P7 and P8, with the mode-cost analysis in §3.
- Griewank, A., Walther, A. Evaluating Derivatives, 2nd ed. SIAM, 2008. The rigorous form of P7.
- Cohen, J. Statistical Power Analysis for the Behavioral Sciences, 2nd ed. Lawrence Erlbaum, 1988. P9.
- Armitage, P., McPherson, C. K., Rowe, B. C. Repeated Significance Tests on Accumulating Data. JRSS A 132(2), 1969. The original quantification of P10.
- Johari, R. et al. Peeking at A/B Tests. KDD 2017. P10, and the principled remedy.
- O'Neil, P. et al. The Log-Structured Merge-Tree. Acta Informatica 33, 1996. P13.
- Athanassoulis, M. et al. Designing Access Methods: The RUM Conjecture. EDBT 2016. The framing of P13.
- Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013. P14.
- Kung, H. T., Leiserson, C. E. Systolic Arrays for VLSI. 1978. P15.
- Jouppi, N. P. et al. In-Datacenter Performance Analysis of a TPU. ISCA 2017. The 92 TOPS figure P15 reproduces.
- Beyer, K. et al. When Is "Nearest Neighbor" Meaningful? ICDT 1999. P16.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012. Relative contrast, P16.
- Clauset, A., Shalizi, C. R., Newman, M. E. J. Power-Law Distributions in Empirical Data. SIAM Review 51(4), 2009. P18, and how routinely these are mis-fitted.
Glossary
Not definitions — explanations. Every entry answers five questions: what it is from first principles, why it exists (what problem forced its invention), how it works internally, what it connects to, and where it shows up in production.
An entry you can read and still not implement the thing is a failed entry. Where a mechanism has a number attached, the number is here too.
Table of Contents
- Measurement and Performance
- Memory and Hardware
- Storage
- Distributed Systems
- Streaming
- Retrieval
- Machine-Learning Systems
- Recommendation and Experimentation
- Languages and Runtimes
- Operating Systems
- References
Measurement and Performance
Amplification (read / write / space)
What. The ratio between work the system actually does and work the user asked for. Write amplification = bytes written to the device ÷ bytes the user wrote. Read amplification = blocks read ÷ blocks logically needed. Space amplification = bytes on disk ÷ bytes of live data.
Why. Without these three numbers, "my storage engine is fast" is unfalsifiable. They turn a vague performance discussion into an accounting identity, and they make the trade-offs impossible to hide: you cannot improve all three, so any design decision must say which one it is spending.
How. You instrument them with counters inside the code, not by watching iostat.
The OS can tell you bytes went to the device; it cannot attribute them to a memtable
flush versus an L2→L3 compaction. Retrofitting these counters after the fact always
misses paths, which is why P04 puts them in milestone 2.
Connects to. The RUM conjecture is the formal statement that you must choose. Compaction is the knob. Bloom filters buy read amplification with memory.
Production. Leveled compaction at fanout 10 gives W/R/S ≈ 31/4/1.10; size-tiered gives 4/30/2.11 (numbers). RocksDB exposes both and makes you choose; the choice is the single biggest performance lever in an LSM deployment.
Try it — the trade, computed:
import math
T, base = 10, 64e6
for data in (8e9, 512e9):
L = max(1, math.ceil(math.log(data/base, T)))
print(f"{data/1e9:>5.0f} GB, {L} levels: "
f"leveled W/R/S = {T*L+1:>3}/{L+1}/{1+1/T:.2f} "
f"tiered = {L+1:>3}/{T*L}/2.00")
print("no strategy wins all three -- that is the RUM conjecture")
8 GB, 3 levels: leveled W/R/S = 31/4/1.10 tiered = 4/30/2.00
512 GB, 4 levels: leveled W/R/S = 41/5/1.10 tiered = 5/40/2.00
no strategy wins all three -- that is the RUM conjecture
Arithmetic intensity
What. FLOPs performed per byte moved from DRAM: \(I = W/Q\). Units are FLOP/byte.
Why. Because compute got cheap and memory did not. A modern accelerator can do hundreds of FLOPs in the time it takes to fetch one byte from HBM, so the question "is this kernel fast?" is really "does it do enough arithmetic per byte to keep the multipliers fed?"
How. \(Q\) is DRAM traffic, not total loads — a value served from L2 costs no DRAM traffic. This is why tiling works: it does not change \(W\), it shrinks \(Q\) by making each fetched byte serve more arithmetic, sliding the kernel rightward along the roofline until it hits the compute ceiling.
Connects to. Roofline, ridge point, operator fusion, systolic array.
Production. A 4096³ bf16 GEMM has \(I = 1365\) with perfect reuse and \(I = 1.0\) with none — a 295× runtime difference on identical arithmetic (numbers). In transformer decode, \(I = 2b/\text{bytes}\), i.e. arithmetic intensity equals batch size and nothing else, which is the whole reason continuous batching exists.
Try it — the same GEMM, 295× apart:
def gemm_intensity(m,n,k,bytes_per=2):
W = 2*m*n*k
Q_best = (m*k + k*n + m*n)*bytes_per # each matrix streamed once
Q_none = (m*k + m*k*n + m*n)*bytes_per # B re-read per row of A
return W/Q_best, W/Q_none
b,w = gemm_intensity(4096,4096,4096)
print(f"perfect reuse I={b:8.1f} FLOP/byte -> compute-bound on an H100 (ridge 295)")
print(f"no reuse I={w:8.1f} FLOP/byte -> memory-bound; same arithmetic, 295x slower")
perfect reuse I= 1365.3 FLOP/byte -> compute-bound on an H100 (ridge 295)
no reuse I= 1.0 FLOP/byte -> memory-bound; same arithmetic, 295x slower
Bootstrap (confidence interval)
What. A non-parametric way to put an uncertainty interval on any statistic: resample your observed data with replacement many times, recompute the statistic on each resample, and take the empirical quantiles of those values.
Why. Latency distributions are right-skewed, often multi-modal, and never normal. A t-interval on the mean assumes normality and is simply wrong for them. The bootstrap assumes only that your sample is representative.
How. Fifteen lines: draw \(n\) samples with replacement from your \(n\)
observations, compute the median, repeat 2,000×, sort, take the 2.5th and 97.5th
percentiles. Implemented in tools/bench.py.
Connects to. Tail latency — you bootstrap the median and the percentiles, not the mean. Common random numbers reduces the variance the bootstrap then measures.
Production. The reason to bother: it lets you say "no measurable difference" honestly when two intervals overlap. Reporting "3% faster" from overlapping intervals is the most common benchmarking lie, and a harness that cannot produce that verdict will manufacture wins for you.
Try it — the honest verdict, in eight lines:
import random, statistics
def ci_median(s, iters=2000, conf=95, seed=0):
rng=random.Random(seed); n=len(s)
meds=sorted(statistics.median([s[rng.randrange(n)] for _ in range(n)]) for _ in range(iters))
lo=(100-conf)/2/100; return meds[int(lo*iters)], meds[int((1-lo)*iters)-1]
rng=random.Random(1)
a=[rng.gauss(100,10) for _ in range(300)]
b=[rng.gauss(101,10) for _ in range(300)]
print("A median 95% CI:", tuple(round(x,2) for x in ci_median(a)))
print("B median 95% CI:", tuple(round(x,2) for x in ci_median(b)))
print("overlap -> report NO MEASURABLE DIFFERENCE, not a 1% win")
A median 95% CI: (99.98, 102.03)
B median 95% CI: (99.27, 101.64)
overlap -> report NO MEASURABLE DIFFERENCE, not a 1% win
Coordinated omission
What. A measurement bug in which a load generator that waits for a response stops issuing requests during a stall, and therefore never records the latencies its own stall caused.
Why it matters. It makes the tail look far better than it is. If your system freezes for 1 second at 1,000 req/s, ~1,000 requests should have been recorded at latencies from 1 ms to 1,000 ms. A closed-loop generator records one slow request and then resumes, so your p99 barely moves.
How to avoid. Issue requests on a schedule (open-loop) rather than after the previous response, and record latency from the request's intended send time.
Connects to. Tail latency, Little's Law.
Production. Almost every naive benchmark of a server has this bug. It is the main reason internally-measured p99s look much better than user-observed ones.
Little's Law
What. \(L = \lambda W\): the average number of items in a stable system equals arrival rate × average time in system. No assumptions about the arrival or service distribution.
Why. It converts between three things you can each measure and lets you check them against each other. Any two give you the third.
How. Applied to memory: to sustain 57.5 GB/s at 121 ns latency you need \(57.5\times10^9 \times 121\times10^{-9} = 6{,}958\) bytes — ~109 cache lines in flight at all times (numbers). A single dependent-load chain keeps exactly one in flight, achieving 0.53 GB/s — 108× below peak on the same hardware.
Connects to. Memory-level parallelism, backpressure, and the utilisation law in math.md.
Production. The cleanest way to sanity-check a capacity claim. If someone says "10,000 QPS at 50 ms latency with 100 threads", Little's Law says you need \(10{,}000 \times 0.05 = 500\) concurrent requests. With 100 threads, it is impossible.
Try it — two applications of one identity:
lam, W = 10_000, 0.050 # 10k req/s at 50 ms
print(f"requests in flight = {lam*W:.0f}")
print(f"with 100 threads: {'impossible' if lam*W > 100 else 'fine'}")
bw, lat = 57.5e9, 121e-9
print(f"bytes in flight to sustain {bw/1e9:.1f} GB/s at {lat*1e9:.0f} ns = "
f"{bw*lat:.0f} ({bw*lat/64:.0f} cache lines)")
requests in flight = 500
with 100 threads: impossible
bytes in flight to sustain 57.5 GB/s at 121 ns = 6958 (109 cache lines)
Relative contrast
What. \(\mathrm{RC} = d_{\text{mean}} / d_1\) — the mean distance from a query to the whole dataset divided by the distance to its true nearest neighbour.
Why. Because ambient dimension \(d\) does not predict nearest-neighbour difficulty and everyone uses it as if it does. Real embeddings live near a low-dimensional manifold and are far easier than their \(d\) suggests.
How. As RC → 1, every point is about as far as every other, greedy descent has no gradient to follow, and any distance-based method degenerates toward random. Measured on uniform data at \(n{=}10\)k: RC = 2.22 at d=16, 1.36 at d=64, 1.10 at d=512 (numbers).
Connects to. Curse of dimensionality, HNSW, greedy graph search.
Production. Report RC with every recall number. An ANN benchmark on uniform high-dimensional data measures the dataset, not the index, and its conclusions do not transfer to your corpus.
Roofline model
What. Achievable performance is \(\min(P,\; I\times B)\), where \(P\) is peak compute, \(B\) is peak bandwidth, and \(I\) is arithmetic intensity. Plotted on log-log axes it looks like a slanted roof meeting a flat ceiling.
Why. It answers "am I doing badly, and in which direction?" with two measurements instead of a profiler session. Below the roof you have headroom; on the slanted part you are bandwidth-limited and more FLOP/s buys nothing.
How. Compute \(I\) for your kernel, look up the ridge point, and
compare. Implemented in tools/roofline.py.
Connects to. Tiling and fusion move you right; quantisation moves you right by shrinking \(Q\).
Production. The standard first question about any GPU kernel. Also the reason "we upgraded to a faster GPU and nothing improved" happens: if you were bandwidth-bound, you bought FLOP/s you cannot use.
Ridge point
What. \(I_{\text{ridge}} = P/B\), the arithmetic intensity at which a kernel stops being memory-bound and becomes compute-bound.
Why. It is the single number that tells you what "efficient" means on a given machine, and it varies enormously: 295 FLOP/byte on an H100, 153 on an A100, ~17 on a server CPU (numbers).
How. Note what the spread means: a kernel with \(I = 50\) is compute-bound on a CPU and badly memory-bound on an H100. The same code changes regime when you change hardware, which is why porting a kernel and keeping the same optimisation strategy so often disappoints.
Connects to. Roofline. In decode, batch size is intensity, so \(b^{*} = I_{\text{ridge}}\times\text{bytes}/2\) — batch 295 on an H100 at bf16, 148 at fp8.
Production. Quantising weights to fp8 halves the batch needed to saturate the multipliers. The real win from low precision at inference is smaller operands, not faster arithmetic.
Tail latency
What. The high percentiles — p95, p99, p99.9 — of a latency distribution, as opposed to the mean or median.
Why. The mean is the number that hides the bug. A system with a 1 ms mean and a 2 s p99 is broken for 1% of requests, and if a user action touches 100 such services, the majority of user actions hit a p99 event: \(1 - 0.99^{100} = 63.4\%\) (numbers).
How. Use nearest-rank percentiles so the reported value is an observation that actually occurred, and keep the raw samples — you cannot recover a distribution from a summary. Watch coordinated omission.
Connects to. Stragglers are the batch-processing version of the same maximum-over-N problem — see P06; hedged requests are one mitigation.
Production. Report p50/p95/p99 with a bootstrap interval, always. A mean latency in a report is a scorecard deduction in this track for exactly this reason.
Try it — nearest-rank, and why the mean lies:
def pct(samples, q):
import math
s=sorted(samples); rank=max(1, math.ceil(q/100*len(s)))
return s[rank-1] # a value that ACTUALLY OCCURRED
lat=[1,1,1,2,2,3,3,5,9,400] # ms
print("mean", sum(lat)/len(lat), " p50", pct(lat,50), " p99", pct(lat,99))
print("the mean (42.7) describes no request that happened")
mean 42.7 p50 2 p99 400
the mean (42.7) describes no request that happened
Memory and Hardware
Cache line
What. The unit of transfer between memory levels — 64 bytes on nearly all current hardware. You never load a byte; you load the line containing it.
Why. Spatial locality: programs that touch address \(x\) usually touch \(x+1\) soon. Amortising the fixed cost of a DRAM transaction over 64 bytes is nearly free if the prediction holds, and pure waste if it does not.
How. Consequences follow directly. A struct that straddles a line costs two fetches. An array-of-structs walk that reads one field wastes the rest of every line — hence structure-of-arrays layouts. Two threads writing different variables in the same line serialise, because coherence operates at line granularity: false sharing.
Connects to. Prefetching, tiling, false sharing.
Production. Padding a per-thread counter to 64 bytes is a one-line change that can give near-linear scaling where there was none.
Curse of dimensionality
What. In high-dimensional spaces, distances between random points concentrate: the ratio of the farthest to the nearest neighbour tends to 1.
Why it happens. Sum \(d\) independent coordinate differences. The mean grows like \(d\) while the standard deviation grows like \(\sqrt{d}\), so the relative spread shrinks as \(1/\sqrt{d}\). Every point drifts toward the same distance from every other.
How it bites. Once distances are nearly equal, "nearest" stops carrying information, partitioning schemes cannot prune (every cell is a candidate), and greedy descent has no gradient. k-d trees degenerate to full scans by around \(d \approx 20\) — far lower than most people expect.
Connects to. Relative contrast is the quantitative version. Intrinsic vs ambient dimension is the escape hatch: real embeddings concentrate on a low-dimensional manifold and behave far better than their \(d\) implies.
Production. The reason ANN libraries exist at all, and the reason benchmarks on synthetic uniform data mislead.
False sharing
What. Two threads write to distinct variables that happen to occupy the same cache line. No logical conflict, but the coherence protocol ping-pongs the line between cores and both threads stall.
Why it exists. Coherence is maintained per line, not per byte, because per-byte tracking metadata would cost more than the data.
How to see it. A parallel program whose throughput decreases with more threads, with no lock in sight. Fix by padding each thread's data to a full line, or by accumulating in a thread-local and merging once at the end.
Connects to. Cache line, atomics.
Production. A classic cause of "our 32-core box performs like a 4-core box". Also why per-CPU counters in kernels are padded.
Memory-level parallelism
What. The number of independent memory requests a core can have outstanding at once.
Why. Latency is fixed by physics; throughput is not. The only way to hide a 121 ns DRAM latency is to have many fetches in flight simultaneously.
How. Independent loads overlap; dependent loads cannot. This is why a pointer chase measures latency and an array scan measures bandwidth, and why the same DRAM delivers 0.53 GB/s to one and 57.5 GB/s to the other — a 108× spread on identical hardware (numbers).
Connects to. Little's Law gives the required concurrency; prefetching supplies it automatically for predictable patterns; GPUs supply it with thousands of threads.
Production. The reason linked lists lose to arrays far beyond what complexity analysis suggests, and the reason batching a graph traversal (process 8 nodes at once) can be several times faster than the obvious loop.
Prefetcher
What. Hardware that observes the access stream, detects a pattern, and issues loads before the program asks.
Why. To create memory-level parallelism automatically for the common case of sequential or constant-stride access.
How. Typical units detect sequential lines, constant strides, and sometimes simple strided patterns across pages. They cannot follow pointers, because the address is not computable until the previous load returns.
Connects to. This is precisely why a latency benchmark must use a random cycle: a fixed-stride chase is the easiest possible pattern to prefetch, and my first attempt at measuring DRAM latency reported 1.30 ns at 512 MB because of it (numbers).
Production. The reason sequential scans over 100 GB can outrun index lookups over 1 GB. "Big-O ignores the constant" understates it: the constant here varies by 100× depending on whether the prefetcher can help.
Systolic array
What. A grid of multiply-accumulate cells where operands flow rhythmically between neighbours rather than being fetched from a register file per operation.
Why. A CPU core is limited by operand delivery: every MAC needs values read from a register file with few ports. Adding multipliers does not help because they starve.
How. In a weight-stationary \(k \times k\) array, each cell holds one weight, activations flow horizontally, partial sums flow vertically. Per cycle you fetch \(O(k)\) values and perform \(k^2\) MACs — operand reuse \(O(k)\), achieved by wiring rather than caching, with no tags, misses, or replacement policy. At \(k=256\) and 700 MHz that is \(2\times65{,}536\times7\times10^8 = 91.75\) TOPS, matching TPUv1's reported 92.
Connects to. Arithmetic intensity — the array is a hardware solution to the same problem tiling solves in software.
Production. TPUs, Apple's AMX, and the tensor cores in modern GPUs. The cost is total inflexibility: no branches, no gather, one operation. Restriction buys efficiency — the same trade as MapReduce's programming model, in silicon.
Try it — operand reuse, and TPUv1 from two integers:
for k in (8, 64, 256):
print(f"k={k:>4}: {k*k:>6} MACs/cycle from {2*k:>4} operands = {k/2:>5.1f}x reuse")
k, clock = 256, 700e6
print(f"TPUv1: 2*{k}^2*{clock/1e6:.0f}MHz = {2*k*k*clock/1e12:.2f} TOPS (reported 92)")
k= 8: 64 MACs/cycle from 16 operands = 4.0x reuse
k= 64: 4096 MACs/cycle from 128 operands = 32.0x reuse
k= 256: 65536 MACs/cycle from 512 operands = 128.0x reuse
TPUv1: 2*256^2*700MHz = 91.75 TOPS (reported 92)
Tiling (blocking)
What. Restructuring a loop nest so it operates on sub-blocks that fit in a cache level, instead of streaming whole arrays.
Why. To raise arithmetic intensity without changing the arithmetic. A naive matmul re-reads matrix \(B\) once per row of \(A\); a tiled one loads a block of \(B\) once and uses it for a whole block of \(A\).
How. Choose block \(B\) so three \(B \times B\) fp32 tiles fit in L1: \(3B^2 \times 4 \le \text{L1}\). On a 128 KB L1 that gives \(B \le 103\) — but the measured optimum is 32, because vector-register pressure and loop overhead bind before cache capacity does (numbers). Derive the bound, then measure; the gap is the lesson.
Connects to. Cache line, roofline. Multi-level tiling (register/L1/L2) is what real BLAS does.
Production. Measured progression on one laptop: naive 1.91 → loop-reordered 27.33 → blocked+vectorised 58.30 GFLOP/s, against Accelerate's 1,679. Note that loop reordering alone was 14.3× and blocking only paid once vectorisation was enabled.
Storage
Bloom filter
What. A probabilistic set membership structure that answers "definitely not present" or "possibly present", never producing a false negative.
Why. A point read for an absent key in an LSM must consult every run on disk. With 40 runs that is 40 random reads to answer "no". A few bits per key in RAM turns most of those into an in-memory rejection.
How. \(m\) bits, \(n\) keys, \(k\) hash functions. Insert sets \(k\) bits; query checks them. \(P(\text{bit still }0) \approx e^{-kn/m}\), so \(\text{fpr} \approx (1-e^{-kn/m})^k\). Minimising over \(k\) gives \(k_{\text{opt}} = (m/n)\ln 2\) and \(\text{fpr} = 0.6185^{m/n}\). At the optimum each bit is 1 with probability exactly ½ — the filter is at maximum entropy, which is the information-theoretic reason that is the optimum. In practice you compute one 128-bit hash and derive all \(k\) probes by Kirsch–Mitzenmacher double hashing.
Connects to. Read amplification, LSM tree. Cannot support range queries — it hashes keys, destroying order.
Production. 10 bits/key gives ~0.82% fpr with \(k=7\), costs 125 KB per 100k keys, and turns 40 disk reads into 0.33 — a 122× reduction (numbers). This is why 10 is the default everywhere. Monkey shows uniform allocation across levels is not optimal.
Compaction
What. Background merging of immutable sorted files into fewer, larger, non-overlapping ones.
Why. An LSM tree makes writes cheap by never updating in place, which means obsolete versions and tombstones accumulate. Without compaction, reads must consult ever more files and space grows without bound.
How. Two families. Size-tiered merges runs of similar size: each byte is rewritten about once per level, so write amplification ≈ \(L\), but up to \(T\) runs coexist per level so read amplification ≈ \(TL\) and space ≈ 2×. Leveled keeps each level as non-overlapping runs, \(T\)× larger than the one above: merging rewrites ~\(T\) bytes of target per byte of source, so write amplification ≈ \(TL\) but reads touch ~\(L\) files and space ≈ 1.1×.
Connects to. Amplification, RUM conjecture, write stall.
Production. The p99 during compaction is the number that matters and the one nobody reports. A steady-state p99 measured with no compaction running is a number that does not exist in production.
Kirsch–Mitzenmacher double hashing
What. Deriving \(k\) hash values from two: \(g_i(x) = h_1(x) + i\cdot h_2(x) \bmod m\).
Why. Computing \(k\) independent hashes is wasteful when \(k = 7\) or more.
How. The result is that this gives the same asymptotic false-positive rate as \(k\) independent hashes. So a Bloom lookup is one hash plus \(k\) array probes. Take a 128-bit digest, split into two 64-bit halves, and make \(h_2\) odd so it generates the full residue ring.
Connects to. Bloom filter.
Production. What every production Bloom filter does. Implemented in
tools/bloom.py.
LSM tree
What. Log-structured merge tree: writes go to an in-memory sorted structure backed by a log; when it fills, it is flushed as an immutable sorted file; files are periodically merged.
Why. Because sequential writes are dramatically cheaper than random ones on every storage medium, and because immutability makes concurrency and crash-safety far simpler than in-place update.
How. Write path: append to WAL → insert into memtable → on threshold, flush to an SSTable → compact. Read path: memtable → immutable memtable → each on-disk run, newest first, with a Bloom filter and a sparse index per run to avoid touching most of them.
Connects to. B-tree is the in-place alternative; amplification is how you compare them.
Production. RocksDB (inside Kafka Streams and Flink), LevelDB's descendants (Cassandra, DynamoDB), and the segment-and-merge structure of every Lucene index and therefore of OpenSearch. You have operated LSM trees for years.
B-tree
What. A balanced search tree with high fanout, updated in place, where each node is a disk page.
Why. To keep the number of page reads for a lookup logarithmic with a very large base — fanout of hundreds means a billion keys sit three or four levels deep.
How. Contrast with LSM: a B-tree does a random write per update (write amplification ≈ 1 page per modified key, but random) and one read path per lookup (low read amplification). LSM converts random writes into sequential ones at the cost of reading more files later.
Connects to. RUM conjecture — B-trees and LSMs sit at different corners of the same triangle.
Production. PostgreSQL, MySQL/InnoDB, SQLite. The rule of thumb: B-trees for read-heavy and update-in-place workloads, LSM for write-heavy and append-mostly.
RUM conjecture
What. You cannot simultaneously optimise Read overhead, Update overhead, and Memory (space) overhead; improving one degrades at least one other.
Why it matters. It converts a sprawling design space into a single question: which one am I spending? Reading it takes ninety seconds; feeling it takes nine weeks of building an LSM.
How it generalises. The same shape appears everywhere in this journey: recall vs latency in ANN, consistency vs availability under partition, precision vs throughput in quantisation, completeness vs latency in watermarks. Once you recognise the shape, new systems become legible quickly.
Connects to. Amplification, compaction, CAP.
Sparse index
What. An index with one entry per block rather than per key: the first key of each block and its offset.
Why. A dense index over a billion keys does not fit in memory. A sparse one over 4 KB blocks with ~100 keys each is 100× smaller and still narrows a lookup to a single block, which you then scan.
How. Binary search the sparse index to find the block that could contain the key, read that one block, scan it. One disk read instead of a tree walk.
Connects to. LSM tree, SSTable. Block size is the tunable: a larger block means a smaller index and more wasted read per lookup.
Production. Every SSTable format. Learned indexes propose replacing the binary search with a model.
SSTable
What. Sorted String Table: an immutable file of key-value pairs in sorted order, plus a Bloom filter, a sparse index, block checksums, and a footer giving their offsets.
Why. Immutability makes it safe to read concurrently with no locking, cheap to cache, and trivially crash-consistent (a partially written file is discarded, never repaired). Sortedness makes range scans a merge and lookups a binary search.
How. Write: buffer sorted entries into blocks, CRC each block, append the filter and
index, write the footer, fsync, then atomically rename into place — the rename is
what makes the file appear all-at-once.
Connects to. LSM tree, compaction, atomic rename.
Tombstone
What. A marker recording that a key was deleted, written like any other entry rather than removing data in place.
Why. In an immutable-file design you cannot delete from a file that is already written and may be being read. The delete must be a write.
How. A read that encounters a tombstone as the newest version for a key returns "not found". The tombstone can only be physically dropped during a compaction that includes the bottom level — otherwise an older version in a lower level would resurface.
Connects to. Compaction, space amplification.
Production. In HNSW, deletion also uses tombstones because a node is referenced by its
neighbours' adjacency lists and patching every in-edge would require a reverse index.
Consequence: recall drifts down with churn, because tombstoned nodes occupy beam slots
without producing results — effective efSearch falls to roughly
\(ef \times (1 - \text{tombstone fraction})\).
Try it — what churn does to effective efSearch:
for frac in (0.0, 0.1, 0.3, 0.5):
print(f"tombstone fraction {frac:.0%}: effective efSearch of 128 -> {128*(1-frac):.0f}")
print("deleted nodes occupy beam slots without producing results")
tombstone fraction 0%: effective efSearch of 128 -> 128
tombstone fraction 10%: effective efSearch of 128 -> 115
tombstone fraction 30%: effective efSearch of 128 -> 90
tombstone fraction 50%: effective efSearch of 128 -> 64
deleted nodes occupy beam slots without producing results
Write-ahead logging
What. Append the intended change to a sequential log and fsync it before modifying
the main structure.
Why. Crash atomicity. After a crash, the log tells you what was intended; replay makes the state consistent. Without it, a crash mid-update leaves a structure that is neither the old nor the new version.
How. The rule is log before data. Records carry a checksum so a torn tail — a
partial record at the end, which is normal after a crash, not corruption — is detected and
replay stops there. Group commit batches many logical writes into one fsync to amortise
its cost.
Connects to. fsync, ARIES, LSM tree, checkpointing.
Production. The fsync is the bottleneck: ~90 µs per durable 4 KB write on the reference machine, capping a fsync-per-write engine at ~10,900 writes/s regardless of everything else (numbers). Group commit is not an optimisation, it is the difference between 10⁴ and 10⁶ writes/s.
fsync
What. A system call that forces previously written data for a file out of the OS page cache and onto durable media, returning only when the device says it is safe.
Why. write() returning successfully guarantees nothing about durability — it has
merely copied bytes into the page cache. Without fsync, a power loss loses them.
How. The cost is ~90 µs on the reference machine — ~743 DRAM round trips, three
orders of magnitude above anything else in a write path. Note also that a failed fsync
is treacherous: on some systems the error is reported once and the dirty pages are
dropped, so a naive retry sees success and loses data.
Connects to. Write-ahead logging, atomic rename.
Production. The single number that sets the ceiling of every durable-write system.
Atomic rename
What. Using rename() to publish a fully-written temporary file under its final name,
relying on rename being atomic within a filesystem.
Why. It converts "a file that might be half written" into "a file that either exists completely or does not exist", which is the only crash-consistency primitive most applications need.
How. Write to foo.tmp, fsync the file, rename to foo, fsync the directory
(the step everyone forgets — without it the rename itself may not be durable).
Connects to. SSTable publication, MapReduce output commit, checkpointing.
Production. Same pattern in three projects here: P03's segment flush, P04's SSTable publication, P06's reduce-output commit. Make the state transition and the position advance atomic is one idea wearing three costumes.
Learned index
What. Replacing a sparse index's binary search with a model that predicts a key's position, plus a bounded correction search.
Why. A sorted array's cumulative distribution function is an index; if the CDF is smooth, a small model approximates it in far less space than explicit entries.
Connects to. Sparse index.
Production. Real but narrower than the original excitement suggested: gains depend heavily on key distribution and updates are awkward. Worth measuring, worth being honest about — a good extension for P04.
Monkey allocation
What. Allocating more Bloom filter bits per key to smaller (higher) LSM levels rather than the same number everywhere.
Why. A level's contribution to false-positive cost is independent of its size — one wasted read either way — but its memory cost scales with the number of keys it holds. Uniform allocation therefore over-spends on the huge bottom level.
Connects to. Bloom filter, compaction.
Production. A genuinely surprising result and one of the best hypothesis sources in P04: same total memory, measurably lower read amplification.
Write stall
What. Deliberately blocking or throttling incoming writes because compaction cannot keep up.
Why. Without it, an LSM under sustained overload accumulates compaction debt: runs pile up, read amplification climbs, compaction gets slower, and the system collapses non-gracefully. A stall trades latency for stability.
Connects to. Compaction, backpressure — the same idea at a different layer.
Production. The failure mode people skip testing. Finding your engine's breaking point and characterising how it breaks is worth more than another 10% of throughput.
ARIES
What. The canonical crash-recovery algorithm: write-ahead logging with log sequence numbers, three recovery phases (analysis, redo, undo), and support for fine-grained locking and partial rollback.
Why. It establishes the vocabulary — LSN, redo, undo, checkpoint, dirty page table — that every subsequent recovery design reuses.
How. Redo everything first (including uncommitted work) to restore the exact state at crash time, then undo the losers. Repeating history before undoing is the counterintuitive step that makes fine-grained locking recoverable.
Connects to. WAL, checkpointing.
Production. Read §1–3 for the concepts; you will not implement full ARIES in this journey, and you will use its vocabulary constantly.
Distributed Systems
Asynchronous model
What. A system model with no bound on message delay or relative processing speed.
Why. Because real networks have no such bound. Any protocol proved correct only under a synchrony assumption will fail exactly when that assumption breaks, which is during the incident.
How. In this model you cannot distinguish a crashed node from a slow one — the single asymmetry from which nearly everything else in distributed systems follows.
Connects to. FLP, failure detector, CAP.
CAP theorem
What. During a network partition, a system must choose between consistency (linearizability) and availability.
Why the usual statement is wrong. "Pick two of three" is a slogan, not the theorem. Partition tolerance is not a choice — partitions happen. The theorem is a statement about what you do when one occurs.
How to think about it properly. PACELC extends it usefully: if Partition, choose A or C; Else, choose Latency or Consistency. The else-branch is where systems spend 99.9% of their time and CAP says nothing about it.
Connects to. Linearizability, quorum.
Production. Dynamo chose AP with vector clocks and read repair; Spanner chose CP with bounded clocks. Both are correct; they answer different questions.
PACELC
What. If Partition, choose Availability or Consistency; Else, choose Latency or Consistency.
Why. Because the partition-free case is the common case and CAP ignores it. Every synchronous replication scheme pays latency for consistency all the time, not just during partitions.
Connects to. CAP, quorum, read strategies.
Failure detector
What. A component that reports which nodes it suspects have failed.
Why. FLP says deterministic async consensus is impossible; practical systems escape by adding a timing assumption, encapsulated in a detector that is allowed to be wrong.
How. It tells you nothing about the remote node — only about your own observations. Three worlds are consistent with a missed heartbeat: crashed, network dropped it, or alive-but-slow (GC pause, disk stall, CPU starvation). The timeout \(T\) is a trade with no correct value: too small gives false positives, spurious elections, and a feedback loop where load causes elections that cause load; too large gives an availability gap of \(T\) on every real crash. Phi-accrual replaces the binary with a suspicion level.
Connects to. Asynchronous model, Raft, split-brain.
Production. Tuning this is most of the operational work in a consensus deployment.
Phi-accrual failure detector
What. A detector that outputs a continuous suspicion level \(\varphi\) derived from the observed distribution of heartbeat inter-arrival times, rather than a boolean.
Why. A fixed timeout must be set for the worst case, so it over-waits in the common case. Modelling the distribution lets each consumer pick its own threshold.
How. Track inter-arrival times, fit a distribution (typically normal), and define \(\varphi = -\log_{10} P(\text{arrival later than now})\). A fast-failover component can act at \(\varphi = 3\); a conservative one waits for 8.
Connects to. Failure detector.
Production. Cassandra and Akka use it. The measurable win is a better false-positive/detection-time frontier under heavy-tailed delay.
FLP impossibility
What. In an asynchronous system with even one crash failure, no deterministic algorithm can guarantee consensus terminates.
Why it matters. It is not an engineering limitation to be out-engineered. It says that any consensus protocol must give up something: determinism (randomised protocols), termination guarantees (Paxos/Raft guarantee safety always, liveness only under partial synchrony), or the async model itself.
How practical systems escape. Raft is always safe and is live only when the network behaves well enough for a leader to hold an election. That distinction — safety unconditional, liveness conditional — is the design pattern.
Connects to. Asynchronous model, Raft, failure detector.
Quorum intersection
What. With \(N\) replicas and quorum size \(Q\), any two quorums intersect iff \(2Q > N\), i.e. \(Q \ge \lfloor N/2\rfloor + 1\).
Why. So a decision made by one quorum is visible to the next. If \(2Q \le N\), two disjoint quorums can each decide without seeing the other.
How this reframes split-brain. Split-brain is not an implementation bug; it is this inequality being violated — most often by an operator changing the replica count without thinking about the arithmetic.
Connects to. Raft, split-brain, CAP.
Production. With \(N=3\) and \(p=0.01\) independent failure, availability goes from 99.00% to 99.9702% — 3.65 days of downtime a year to 2.6 hours. The assumption doing all the work is independence, and correlated failure (same rack, same deploy, same poisoned request) collapses it.
Try it — the smallest safe quorum, brute-forced:
from itertools import combinations
for N in (3,5,6):
for Q in range(1, N+1):
disjoint = any(not (set(a)&set(b)) for a in combinations(range(N),Q)
for b in combinations(range(N),Q))
if not disjoint:
print(f"N={N}: smallest safe quorum = {Q} (2Q={2*Q} > N={N})"); break
N=3: smallest safe quorum = 2 (2Q=4 > N=3)
N=5: smallest safe quorum = 3 (2Q=6 > N=5)
N=6: smallest safe quorum = 4 (2Q=8 > N=6)
Linearizability
What. The strongest single-object consistency condition: every operation appears to take effect instantaneously at some point between its invocation and its response, and that order is consistent with real time.
Why. It is the model that matches intuition — a read sees the most recent write — and therefore the one whose absence surprises people.
How you check it. Record a history of invoke/return events with values, then search for a sequential ordering consistent with real-time constraints that satisfies the object spec (Wing–Gong, with pruning). The search is exponential in the worst case, so keep histories to a few thousand operations.
Connects to. Not the same as serializability (a multi-object transaction property with no real-time requirement) or sequential consistency (program order preserved, no real-time requirement). These three are routinely conflated; being precise about which one you provide is a distinguishing mark.
Production. P05's checker must report zero violations across ≥1,000 seeded fault runs, and finding one real bug in your own code with it is an exit criterion.
Raft
What. A leader-based consensus algorithm designed for understandability: a replicated log where one elected leader accepts writes and replicates them to followers.
Why. Paxos is correct and famously hard to implement correctly. Raft decomposes the problem into leader election, log replication, and safety, with an explicit strong-leader constraint that removes most of the concurrency.
How. Time is divided into terms, each with at most one leader. A candidate wins by receiving votes from a majority; a voter grants at most one vote per term and only to a candidate whose log is at least as up to date as its own. Entries commit once replicated to a majority. The subtle part — §5.4.2 — is that a leader may not commit an entry from a previous term by counting replicas; it must first commit an entry from its own term. The bug this prevents appears in roughly one in 10⁵ runs and destroys linearizability.
Connects to. FLP, quorum, failure detector, split-brain.
Production. etcd, Consul, CockroachDB, TiKV. Write a test that constructs the §5.4.2 interleaving deliberately — it will not appear by chance.
Split-brain
What. Two nodes simultaneously believing they are the authoritative leader, each accepting writes.
Why it happens. A partition, or a leader that was paused (GC, SIGSTOP) long enough
to be replaced and then resumed still believing it leads.
How to prevent it. Quorum intersection plus fencing tokens: a monotonically increasing number issued with leadership, which downstream systems use to reject writes from a stale leader. Without fencing, a delayed write from an old leader can arrive after the new leader's and win.
Connects to. Quorum, Raft, asymmetric partition.
Asymmetric partition
What. A network fault where A can reach B but B cannot reach A.
Why it deserves its own entry. Symmetric partitions are easy to reason about and easy to test. Asymmetric ones break designs that symmetric tests pass: a leader that can send heartbeats but not receive votes keeps believing it leads while the cluster elects a successor.
Connects to. Split-brain, fault injection.
Production. Your fault injector must support it, and it is the injection most likely to find a real bug in your Raft.
Fault injection
What. Deliberately introducing failures — drops, delays, duplicates, reordering, partitions, pauses, corruption, clock skew — under test.
Why. Distributed bugs do not reproduce. Waiting for them in production is not a testing strategy.
How — and this is the part that matters. The injector must be deterministic and replayable: seeded, with a recorded schedule that reproduces the exact interleaving. A distributed bug you cannot replay is a distributed bug you cannot fix. This is why P05 builds the injector in milestones 1–2, before any distributed feature.
Connects to. Linearizability checking — injection finds the schedule, the checker recognises the violation.
Production. Jepsen is the public standard for this. The injector is also the most reusable artifact in Stage 3.
Idempotency
What. An operation that produces the same effect whether applied once or many times.
Why. Because exactly-once delivery is impossible and retries are therefore mandatory. If retries are safe, at-least-once delivery is sufficient.
How. For a KV store: client id + monotonic sequence number, with a server-side dedup table recording the last sequence and its response per client. The awkward part is that the table grows without bound and needs a session-expiry policy.
Connects to. Exactly-once, atomic rename.
ReadIndex
What. A way to serve a linearizable read from a Raft leader without appending an entry to the log: record the current commit index, confirm leadership with a heartbeat round to a majority, wait until the state machine has applied that index, then read.
Why. Reads are usually the majority of traffic and writing a log entry per read is wasteful. But reading from a leader that has silently been deposed returns stale data, so some confirmation is required.
How the three options compare. Read-from-leader-without-check is fastest and can be stale; ReadIndex costs one round trip and is linearizable; lease reads are local (no round trip) and linearizable only under a bounded clock-drift assumption.
Connects to. Linearizability, Raft.
Production. The latency/staleness/assumption frontier is a real design choice; state which point you chose and what it costs you.
Consistent hashing
What. Mapping keys and nodes onto the same ring so that adding or removing a node relocates only \(O(K/N)\) keys instead of nearly all of them.
Why. With plain hash(key) % N, changing \(N\) remaps almost every key — a full
data reshuffle on every membership change.
How. Naive consistent hashing balances badly (random points on a ring produce uneven arcs), so each physical node is represented by many virtual nodes. More virtual nodes means better balance and a bigger routing table.
Connects to. Partitioning, rebalancing, and the AP design point of CAP.
Streaming
Event time vs processing time
What. Event time is when the thing happened, carried in the record. Processing time is when your system saw it.
Why it is the central distinction in streaming. They differ by an unbounded and variable amount, and choosing the wrong one produces silently wrong answers.
A concrete failure. A user reads an article at 23:58 on an offline phone that syncs at 00:07. Keyed on processing time, your "reads per day" attributes it to the wrong day — and over a month every daily number is wrong by the size of the offline-sync population. That is a systematic bias correlated with a user segment, not noise. Keyed on event time, it lands correctly — but now the 23:00–00:00 window cannot close at 00:00, because more events may arrive. You have traded a wrong answer for a late answer.
Connects to. Watermark is the dial that governs that trade.
Watermark
What. An assertion emitted at processing time \(t\): no event with event time ≤ \(W(t)\) will arrive after this point.
Why. Because with an unbounded input you must decide when to stop waiting before you can emit any result at all. A watermark is a formal admission that you are giving up on completeness in exchange for an answer.
How, and the three consequences. (1) The assertion can be wrong — an event below the current watermark is late, and you must have a policy: drop, fire again with a correction, or route to a side output. There is no free option. (2) It is a heuristic: perfect watermarks need a known maximum delay, which you do not have, so real systems use \(W(t) = \max(\text{observed event time}) - \delta\) with \(\delta\) a completeness/latency dial. (3) It must be the minimum across all inputs — and one idle partition therefore holds it back forever, stalling every window. That last failure is the most common operational problem in streaming, and its standard fix (idle-partition detection with a timeout) weakens the guarantee.
Connects to. Event time, windowing, late event.
Production. Sweep \(\delta\) and plot completeness against emission latency. That curve is the streaming analogue of ANN's recall/QPS curve, and it makes the same point: you choose a point on a frontier, and the only sin is not stating which.
Try it — the min rule, and the idle-partition stall:
partitions = {"p0": 1000, "p1": 995, "p2": 1002} # max event time seen per partition
delay = 30
wm = min(partitions.values()) - delay
print(f"watermark = min({list(partitions.values())}) - {delay} = {wm}")
partitions["p1"] = None # p1 goes idle
alive = [v for v in partitions.values() if v is not None]
print(f"if p1 idles and you take min of the rest: {min(alive)-delay} (windows advance)")
print("if you keep waiting on p1: watermark frozen, EVERY window stalls")
watermark = min([1000, 995, 1002]) - 30 = 965
if p1 idles and you take min of the rest: 970 (windows advance)
if you keep waiting on p1: watermark frozen, EVERY window stalls
Windowing
What. Grouping unbounded events into bounded sets for aggregation.
Types and why they differ. Tumbling: fixed, non-overlapping — each event in one window. Sliding: fixed size, smaller step — each event in several windows, so state grows proportionally to size/step. Session: dynamic, closed by a gap of inactivity — and therefore the hardest, because a late event can bridge two existing sessions and force a merge plus a retraction of already-emitted results.
Connects to. Watermark decides when a window fires; state backend holds it until then.
Production. Session windows with no timeout are the classic unbounded-state leak: state accumulates for every key ever seen. Plot state size over time in every experiment.
Late event
What. An event arriving with an event time below the current watermark.
Why unavoidable. The watermark is a heuristic. Setting \(\delta\) large enough to eliminate late events means never emitting anything promptly.
How to handle. Three policies, all of which must be counted: drop (fast, loses data), late firing (emit a correction, requires downstream to handle updates), or side output (route elsewhere for reconciliation).
Connects to. Watermark, retractions.
Production. Every event must land in a window, fire late, or be explicitly counted as dropped — and the three must sum to the input. If they do not, you are losing data silently.
Checkpointing
What. Periodically persisting operator state together with the input positions that produced it, atomically.
Why the "together" is the whole point. Recovering means restoring state and rewinding input to the checkpointed offsets. If you persist state and offsets separately, you get duplicates or gaps depending on the order — the classic bug, and one worth introducing deliberately once to see it.
How. Stop-the-world is simplest: pause, snapshot, resume. Chandy–Lamport barrier snapshots interleave markers with the data stream so operators snapshot without a global pause, at the cost of alignment delay under backpressure (which is why unaligned checkpoints exist).
Connects to. Exactly-once, WAL, atomic rename — three projects, one idea: make the state transition and the position advance atomic.
Production. Checkpoint interval is a real optimum: too frequent costs steady-state throughput, too rare costs recovery time.
Exactly-once
What. Three different claims that get conflated, only two of which are achievable.
| Claim | Achievable? | Why |
|---|---|---|
| exactly-once delivery | No | Two-generals: a sender cannot know whether a lost ack means the message arrived |
| exactly-once processing | Yes, internally | Checkpoint state and offsets atomically; replay from the checkpoint |
| exactly-once effect at the sink | Yes, conditionally | Needs an idempotent sink (keyed upsert) or a transactional one (2PC tied to the checkpoint) |
Why precision matters. "Exactly-once semantics" on a marketing page is meaningless without saying with respect to what, and under which sink assumptions.
Connects to. Idempotency, checkpointing.
Backpressure
What. Propagating "slow down" upstream when a downstream stage cannot keep up.
Why. Without it, an unbounded queue absorbs the mismatch until memory is exhausted. Backpressure converts an availability failure into a latency degradation, which is almost always the better trade.
How to test it. Not by measuring throughput — by measuring memory. Slow the sink 10× and verify that lag grows while memory stays bounded. A system that "handles" backpressure by buffering is not handling it.
Connects to. Write stall is the same idea in a storage engine; Little's Law predicts the queue.
Consumer lag
What. How far behind the head of the log a consumer is, in records and in seconds.
Why it is the primary operational metric. It is the leading indicator: lag grows before latency SLOs break, and it is the only metric that distinguishes "slow" from "stopped".
Connects to. Distinguish it from watermark lag (wall clock minus watermark), which measures how far behind event time you are and is often more informative for correctness.
State backend
What. Where a stateful operator keeps its keyed state — in memory, or in an embedded store.
Why. State outgrows memory, and it must survive operator restart, which means it must be checkpointable.
How. Every production system uses an LSM (RocksDB in Flink and Kafka Streams) because the access pattern is write-heavy keyed updates with range scans for windows — exactly what an LSM is good at.
Connects to. LSM tree, checkpointing.
Retrieval
HNSW
What. Hierarchical Navigable Small World: a stack of proximity graphs with exponentially decaying layer membership, searched greedily from a sparse top layer down to a dense layer 0.
Why the hierarchy. A single-layer NSW relies on accidental long-range edges formed by early insertions. The hierarchy makes long-range navigation structural rather than lucky: upper layers are sparse, so a few hops cover large distances.
How. Each point joins layers \(0..\ell\) with
\(\ell = \lfloor -\ln(U(0,1))\cdot m_L \rfloor\) — a geometric distribution, so layer
\(i\) holds ~\(1/e^i\) of the points. Search descends greedily with beam width 1 until
a local minimum at each layer, then runs a beam search of width efSearch at layer 0. It
is, structurally, a skip list in a metric space, and seeing that makes the layer
distribution obvious.
The part everyone skips. Algorithm 4, the neighbour-selection heuristic. It is not an optimisation; it is a connectivity guarantee.
Connects to. Greedy graph search, relative contrast, efSearch.
Production. OpenSearch, Elasticsearch, pgvector, Qdrant, Weaviate, FAISS.
Navigable small world
What. A graph with high clustering and short path lengths, in which greedy routing by distance works.
Why it works. Kleinberg's result: greedy routing achieves \(O(\log^2 n)\) hops only when long-range links follow a specific distance distribution. Too few long links and you crawl locally; too many and greedy descent has no gradient.
How it arises here. In NSW, insertion order supplies it accidentally — early insertions happen into a nearly-empty graph so their edges are necessarily long.
Connects to. HNSW replaces the accident with structure.
Greedy graph search
What. From an entry point, repeatedly move to the neighbour closest to the query,
maintaining a beam of the best ef candidates found.
Why approximate. The stopping rule — halt when the closest unexplored candidate is farther than the worst held result — is only sound if the graph is locally metric. It is false near a local minimum, and that is exactly where the missing recall goes.
How the beam helps. A wider beam explores more paths out of local minima, trading latency for recall — the efSearch knob.
Connects to. Memory-level parallelism: a graph walk is a dependent-load chain (~121 ns per hop), while brute force is a prefetchable scan. This is a second, independent reason graph search loses at small \(n\).
efSearch / efConstruction
What. Beam widths. efSearch at query time; efConstruction during insertion.
Why two. Build quality and query quality are separable: a well-built graph can be searched cheaply, but a badly-built one cannot be rescued by a wide query beam.
How to choose. Recall is strongly concave in efSearch — the first units buy a
lot, the last buy fractions of a percent at linear latency cost. Fix the recall you need
first and read the latency off the curve; never pick a latency and report whatever
recall falls out.
Connects to. HNSW. Measured curves in numbers.
Neighbour-selection heuristic
What. HNSW's Algorithm 4: when pruning a node's edges, keep a candidate only if it is closer to the base node than to any already-selected neighbour.
Why it exists. Keeping simply the \(M\) nearest neighbours destroys long-range
connectivity on clustered data. Measured: intra-cluster distance 0.521, inter-cluster
1.413, so a distance-based cap deletes every bridge, deterministically, leaving
well-connected islands with no paths between them. Recall then plateaus at 0.967 while
uniform data reaches 0.993 — and raising efSearch does not help because the search
cannot spend its budget (840 distance computations at ef=256 versus uniform's 4,059).
How the heuristic fixes it. By preferring candidates in directions not already covered, it preserves exactly the bridges the naive rule deletes.
Connects to. HNSW. Full analysis in the worked notebook entry.
Production. The best available example of "an optimisation that is actually a correctness property."
Pre-filtering vs post-filtering
What. Two ways to combine a metadata predicate with vector search. Post-filter: retrieve top-\(K\), then discard non-matches. Pre-filter: restrict the graph walk to matching nodes.
Why neither is always right. Post-filtering needs \(K \ge k/s\) for selectivity
\(s\) — at \(s = 10^{-4}\) that is efSearch ≥ 100,000, i.e. brute force with a worse
constant. This is the production p99 cliff on narrow filters. Pre-filtering has no
over-fetch problem but walks an induced subgraph you did not build, which may be
disconnected — the same failure as the clustered ceiling, now caused by a query rather
than by the data.
The third option people forget. Brute force over the filtered set is \(O(sn)\) — at \(s=10^{-4}\) on 1M vectors, 100 distance computations, exact and faster than either alternative.
Connects to. Neighbour-selection heuristic, selectivity.
Production. A query planner needs all three strategies and empirical crossovers.
Try it — the over-fetch cliff:
import math
for s in (0.1, 0.01, 0.001, 0.0001):
K = math.ceil(10/s)
print(f"selectivity {s:<8} -> need K={K:>7,} candidates "
f"({K/1e6:.1%} of a 1M corpus)")
print("at 1e-4 you are running brute force with a worse constant")
selectivity 0.1 -> need K= 100 candidates (0.0% of a 1M corpus)
selectivity 0.01 -> need K= 1,000 candidates (0.1% of a 1M corpus)
selectivity 0.001 -> need K= 10,000 candidates (1.0% of a 1M corpus)
selectivity 0.0001 -> need K=100,000 candidates (10.0% of a 1M corpus)
at 1e-4 you are running brute force with a worse constant
Selectivity
What. The fraction of the corpus satisfying a predicate.
Why it drives everything. It decides which filtering strategy wins, and a wrong estimate is worse than no plan at all.
Connects to. Pre/post-filtering.
Production. Log estimated vs actual selectivity on every query from day one; planner mispredictions otherwise look like unexplained performance bugs.
Product quantization
What. Splitting a vector into \(m\) sub-vectors, k-means clustering each subspace, and storing only the centroid ids — so a 512-dim fp32 vector (2 KB) becomes \(m\) bytes.
Why. Memory, at billion scale, is the binding constraint. PQ trades recall for a 32–64× size reduction.
How. Distances are computed asymmetrically: the query stays full-precision, and a small lookup table of query-to-centroid distances per subspace turns distance computation into \(m\) table lookups and adds.
Connects to. The other major ANN family alongside graphs; the two compose (IVF-PQ, HNSW-PQ).
Machine-Learning Systems
Attention
What. Each position produces a query, a key, and a value. Scores are query·key over all positions, softmaxed into weights, and used to take a weighted sum of values: \(\text{softmax}(QK^\top/\sqrt{d_k} + M)V\).
Why it exists. It gives each position a content-addressed, position-invariant lookup over all other positions, while remaining parallel across positions (unlike recurrence) and constant in parameters as sequence length grows (unlike a fixed-window MLP).
How, and the \(\sqrt{d_k}\). With unit-variance components, \(q\cdot k\) is a sum of \(d_k\) independent terms, so its variance is \(d_k\) and typical magnitude \(\sqrt{d_k}\). At \(d_k = 64\) scores are ±8, the softmax saturates to near-one-hot, and its gradient vanishes. Dividing by \(\sqrt{d_k}\) restores unit variance and keeps the layer learning. This is a variance calculation, not a heuristic.
Connects to. Multi-head, causal mask, KV cache, FlashAttention.
Production. At \(T=1024\), attention is only 18.2% of a GPT-2-small layer's FLOPs; the quadratic term does not dominate until ~4096 (numbers).
Multi-head attention
What. Splitting \(d\) into \(H\) heads of size \(d/H\), attending independently, concatenating, and projecting.
Why. One head produces one attention distribution — it can attend to one thing. \(H\) heads give \(H\) distributions at identical total parameter and FLOP cost, because the concatenate-then-project arithmetic is the same. Representational diversity, free.
The limit. Each head sees a \(d/H\)-dimensional subspace, so below some head size heads become too small to be useful. Finding that floor empirically is a P01 experiment.
Connects to. Grouped-query attention shares K/V across heads to shrink the KV cache.
Causal mask
What. Setting \(M_{ij} = -\infty\) for \(j > i\) so position \(i\) cannot attend to the future.
Why. Autoregressive training computes the loss at every position in parallel; without the mask, position \(i\) sees its own target.
How — and the bug. The mask must be applied before the softmax, so masked positions receive exactly zero weight. Applied after, they receive small nonzero weight and the model trains fine while quietly cheating.
How to test it. Perturb token \(t{+}1\) and assert the logits at positions \(\le t\) are bit-identical. This catches masking-after-softmax, off-by-one, and accidental bidirectionality in one property test.
Production. Label leakage shows up as a validation loss below the entropy floor. A model trained on shuffled targets should converge to exactly \(\ln V\) nats — memorise that number for your vocabulary and you can spot leakage instantly.
RoPE
What. Rotary position embedding: rotate query and key vectors by an angle proportional to position, in \(d/2\) independent 2-D planes, with \(\theta_i = m/\text{base}^{2i/d}\).
Why it differs in kind. Learned and sinusoidal encodings add a position vector to the token embedding. RoPE rotates, and because a rotation by \(m\) composed with the inverse of a rotation by \(n\) is a rotation by \(m-n\), the query·key dot product depends only on \(m-n\). Attention becomes relative-position-aware with no explicit relative term.
How to verify. Measured: the dot product for offset 2 is identical to nine decimals at positions (5,3), (105,103), (7,5) and (1000,998); norms are preserved exactly because rotations are orthogonal. Write both as tests before implementing.
Connects to. Two incompatible conventions exist (interleaved pairs vs split halves); either is fine if consistent, and mixing them produces a model that trains but extrapolates badly.
KV cache
What. Caching the key and value tensors for all previous positions during autoregressive generation.
Why. Without it, generating token \(n\) recomputes attention over all \(n-1\) previous positions — making generation quadratic in output length instead of linear.
How much it costs. \(2 \times L \times T \times d \times \text{bytes}\) per sequence (2 for K and V, \(L\) layers). Derive it, then measure it; they must agree within 5%, and a gap means you have misunderstood what is cached.
Connects to. The cache is why decode is memory-bound: arithmetic intensity in decode equals batch size. GQA and PagedAttention both exist to shrink or manage it.
Grouped-query attention
What. Sharing one K/V head across a group of Q heads.
Why. The KV cache is often the binding memory constraint at long context. GQA divides it by the group size at a small quality cost.
Connects to. Multi-query attention is the extreme case (one K/V head for all queries).
PagedAttention
What. Managing KV cache memory in fixed-size blocks with an indirection table, rather than as one contiguous per-sequence allocation.
Why. Contiguous allocation must reserve for the maximum sequence length, wasting most of it, and fragments badly across sequences of different lengths.
How. Exactly virtual memory applied to the KV cache — a page table per sequence, blocks allocated on demand, and sharing of common prefixes across sequences by reference-counting blocks.
Connects to. Paging. A good example of an OS idea transplanted into an ML runtime, which is the kind of connection this journey exists to make visible.
FlashAttention
What. Computing exact attention without ever materialising the \(T \times T\) score matrix, by tiling and using an online-softmax normalisation.
Why. Memory, not FLOPs, is the wall. At \(T=8192\) the score matrix is 3.2 GB for a single batch element in fp32 (numbers).
How. Process query/key blocks in tiles that fit in SRAM, maintaining a running max and running sum so the softmax can be normalised incrementally and correctly. The arithmetic is unchanged; the DRAM traffic is what is optimised.
Connects to. Arithmetic intensity, tiling. It is the same idea as blocked matmul, applied to a different kernel.
Reverse-mode automatic differentiation
What. Computing gradients by traversing the operation graph backwards, propagating adjoints (vector-Jacobian products).
Why reverse and not forward. For \(f:\mathbb{R}^n\to\mathbb{R}^m\), forward mode costs \(O(n)\) passes (one per input) and reverse costs \(O(m)\) (one per output). Training has \(n \approx 10^7\text{–}10^{11}\) parameters and \(m = 1\) scalar loss. Reverse mode is ~5×10⁶ times cheaper for a 10M-parameter model — 20 ms versus 28 hours. That ratio is the entire reason deep learning is computationally feasible.
How, mechanically. It is bookkeeping, not calculus. Per-op derivative rules are trivial; the engineering is recording the graph, traversing in reverse topological order, and accumulating contributions when a value is used more than once. The price is memory: every intermediate must stay alive until its adjoint is consumed.
Connects to. Gradient checkpointing trades that memory back for recompute. For \(C = AB\): \(\bar A = \bar C B^\top\), \(\bar B = A^\top \bar C\) — a backward pass is about 2× a forward pass, which is where \(C \approx 6ND\) comes from.
The bug to know. Missing gradient accumulation makes gradients too small by an exact integer factor, which looks like a learning-rate problem and gets "fixed" by raising the learning rate.
Gradient checkpointing
What. Discarding intermediate activations during the forward pass and recomputing them during the backward pass.
Why. Reverse-mode memory grows with graph depth, and memory is what stops a model fitting.
How. Storing every \(\sqrt{n}\)-th activation and recomputing the rest gives \(O(\sqrt{n})\) memory for roughly one extra forward pass.
Connects to. Reverse-mode AD. The canonical time-for-space trade.
Operator fusion
What. Combining a chain of elementwise operations into a single kernel.
Why — and it is not FLOPs. For d = relu(a*b + c) over \(N\) fp32 elements:
unfused, three kernels move \(32N\) bytes for \(3N\) FLOPs (\(I = 0.094\)); fused,
one kernel moves \(16N\) bytes for the same \(3N\) FLOPs (\(I = 0.188\)). Exactly
2× the arithmetic intensity, and half the DRAM traffic, with identical arithmetic.
Connects to. Arithmetic intensity, roofline. Both versions are far below any ridge point, so both are memory-bound and halving bytes should halve time. Predict 2×, measure, explain the gap — usually launch overhead at small \(N\).
Production. What torch.compile, XLA and TVM spend most of their effort on.
Try it — identical FLOPs, half the bytes:
N=1_000_000
unfused = (12+12+8)*N # 3 kernels, each reading and writing DRAM
fused = 16*N # read a,b,c once; write d once
flops = 3*N
print(f"unfused: {unfused/N:>2.0f} bytes/elem, I={flops/unfused:.3f} FLOP/byte")
print(f"fused: {fused/N:>2.0f} bytes/elem, I={flops/fused:.3f} FLOP/byte "
f"({(flops/fused)/(flops/unfused):.0f}x)")
print("identical FLOPs. Fusion is a DATA MOVEMENT optimisation.")
unfused: 32 bytes/elem, I=0.094 FLOP/byte
fused: 16 bytes/elem, I=0.188 FLOP/byte (2x)
identical FLOPs. Fusion is a DATA MOVEMENT optimisation.
Dispatch overhead
What. The fixed per-operation cost a framework pays before any arithmetic: Python call, argument parsing, dtype/device/layout resolution, kernel selection, output allocation.
Why it dominates more often than expected. Measured: numpy array addition costs 254.56 ns of fixed overhead plus 0.2551 ns per element, so overhead equals element work at ~998 elements. Below ~1,000 elements a numpy op is more dispatch than arithmetic; at \(n=1\) the ratio is 998×.
Connects to. Operator fusion and graph mode amortise it. The same phenomenon as P02's constant factor and P11's bytecode result — three domains, one lesson.
Production. Why small-batch inference is often framework-bound rather than compute-bound, and why CUDA graphs exist.
Try it — the crossover, from two measured constants:
fixed, per_elem = 254.56, 0.2551 # ns, measured for numpy elementwise add
print(f"crossover: {fixed/per_elem:.0f} elements")
for n in (1, 100, 1000, 100000):
tot = fixed + n*per_elem
print(f"n={n:>7}: {fixed/tot:>5.1%} of the time is dispatch")
crossover: 998 elements
n= 1: 99.9% of the time is dispatch
n= 100: 90.9% of the time is dispatch
n= 1000: 49.9% of the time is dispatch
n= 100000: 1.0% of the time is dispatch
Quantization
What. Representing weights and/or activations in fewer bits — fp16, bf16, fp8, int8.
Why — and the reason is not faster arithmetic. It shrinks \(Q\), the bytes moved, while leaving \(W\) unchanged, raising arithmetic intensity. fp8 halves the batch size needed to leave the memory-bound regime (295 → 148 on an H100).
How it breaks. Per-tensor scaling assumes a single dynamic range. Transformer activations have outlier channels whose range destroys the scale for everything else — the LLM.int8() result. Per-channel scaling is the fix.
Connects to. bf16 vs fp16, roofline.
bf16 vs fp16
What. Two 16-bit floats that are not interchangeable. bf16 is 1/8/7 (sign/exponent/ mantissa); fp16 is 1/5/10.
Why bf16 won for training. It keeps fp32's 8-bit exponent, so its range is identical to fp32 (max 3.4e38, min normal 1.2e-38) and values simply truncate. fp16's 5-bit exponent bottoms out at 6.1e-5, and gradients routinely underflow it — which is exactly why fp16 training needs loss scaling and bf16 does not. Range matters more than precision when values span orders of magnitude.
Connects to. Floating point.
Recommendation and Experimentation
Implicit feedback
What. Behavioural signals — clicks, dwell, purchases — as opposed to explicit ratings.
Why it is hard. A click is not a rating, and the absence of a click is not a negative. It may mean disliked, unseen, or seen-and-deferred, and you cannot tell which.
Connects to. Position bias, off-policy evaluation.
Position bias
What. The strongest predictor of a click is where the item was shown, not how good it was.
Why it matters. Training on raw clicks teaches the model to reproduce your old ranker's ordering, not user preference. It is a feedback loop that looks like learning.
How it is modelled. The examination hypothesis factorises \(P(\text{click}) = P(\text{examine} \mid \text{rank}) \times P(\text{attract} \mid \text{examine})\), with examination decaying as \(r^{-\gamma}\), \(\gamma \approx 0.7\text{–}1.0\) from eye-tracking. The separation is what makes a skip at rank 9 weak evidence of dislike — the user probably never looked.
Connects to. Off-policy evaluation, simulator design.
Production. In a simulator this single parameter dominates every conclusion: with strong position bias, any algorithm that puts something plausible in slot 1 looks good. Sweep it, and report which conclusions survive.
Off-policy evaluation
What. Estimating how a new policy would perform using logs generated by a different policy.
Why you cannot avoid it. Your logs record what the old system showed. Items never shown have no positives and score as failures, so any new algorithm surfacing them is penalised for it. No metric fixes this — the information is not in the data.
How. Inverse propensity scoring reweights each logged event by \(1/P(\text{shown})\), which is unbiased but high-variance (hence capping) and requires knowing the logging policy's probabilities.
Connects to. This is the admission that motivates P09: a simulator generates the counterfactual, trading a biased measurement for a model-dependent one.
NDCG
What. Discounted cumulative gain normalised by the ideal ordering: \(\text{DCG} = \sum_i g_i / \log_2(i+2)\), divided by the DCG of the best possible ranking.
Why normalise. Raw DCG is not comparable across queries with different numbers of relevant items.
The assumption nobody states. The \(\log_2(i+2)\) discount is a model of user examination probability. If your product is an infinite-scroll feed rather than ten blue links, that model is wrong — and you should say so before quoting the number.
Connects to. Position bias is the same idea from the data side.
Try it — computed by hand:
import math
def dcg(g): return sum(v/math.log2(i+2) for i,v in enumerate(g))
gains={7:3.0, 3:2.0, 11:2.0}
ranked=[7,42,3,88,11]
got=[gains.get(d,0.0) for d in ranked]
ideal=sorted(gains.values(), reverse=True)
print(f"DCG {dcg(got):.4f} IDCG {dcg(ideal):.4f} NDCG@5 {dcg(got)/dcg(ideal):.4f}")
print("the log2(i+2) discount MODELS user examination -- state k, and state the model")
DCG 4.7737 IDCG 5.2619 NDCG@5 0.9072
the log2(i+2) discount MODELS user examination -- state k, and state the model
Coverage, novelty, Gini
What. Catalogue-level metrics. Coverage = fraction of the catalogue shown to anyone. Novelty = mean self-information \(-\log_2 p(\text{item})\), in bits. Gini = concentration of exposure.
Why they are mandatory. Almost every accuracy metric can be improved by recommending popular items, because popular items are popular. At Zipf \(\alpha = 1.0\), the top 1% of items captures 53% of engagement — so a bestseller list beats a mediocre personalised model on NDCG while covering 1% of the catalogue.
How they interact. Measured: a bestseller list has coverage 0.005 and Gini 0.000 — Gini alone misses the failure entirely because the five items share exposure evenly. You need coverage and concentration.
Connects to. Calibration, diversity.
Production. Report the full suite for every configuration. A table with only NDCG is a rejected result in this track.
Try it — why the bestseller list is hard to beat:
n=10000
for a in (0.5, 1.0, 1.2):
w=[1/r**a for r in range(1,n+1)]; t=sum(w)
print(f"Zipf({a}): top 1% of items carry {sum(w[:n//100])/t:>5.1%} of engagement")
print("a bestseller list is a strong ACCURACY baseline. Report coverage too.")
Zipf(0.5): top 1% of items carry 9.4% of engagement
Zipf(1.0): top 1% of items carry 53.0% of engagement
Zipf(1.2): top 1% of items carry 75.1% of engagement
a bestseller list is a strong ACCURACY baseline. Report coverage too.
Maximal marginal relevance
What. Greedy diversity re-ranking: \(\arg\max_i [\lambda\,\text{rel}(i) - (1-\lambda)\max_{j\in S}\text{sim}(i,j)]\).
Why. Accuracy metrics cannot see "ten articles about the same story" — all ten are genuinely relevant. Diversity is invisible to relevance.
How. One parameter, one line, and it traces the whole accuracy/diversity frontier as \(\lambda\) sweeps.
Connects to. Intra-list diversity is the metric; MMR is the mechanism.
Calibration
What. Whether the topic mix of recommendations matches the topic mix of a user's history.
Why. Accuracy-optimal recommendations are systematically miscalibrated: if a user reads 70% sport and 30% politics, the accuracy-maximising list is 100% sport, because that is the higher-probability category. The user experiences this as narrowing.
Connects to. Coverage/novelty, filter bubbles.
Sample-ratio mismatch
What. The observed traffic split differing from the intended one by more than chance.
Why it invalidates rather than warns. It means assignment, logging, or filtering differs between arms — so the two populations are not comparable and no analysis of the metric is valid.
How to detect. χ² with 1 d.f.; alarm at χ² > 10.83 (p < 0.001). A 0.5% imbalance on 400k users gives χ² = 40 — a five-sigma event.
Connects to. Peeking, statistical power.
Production. Make it a gate: the analysis pipeline should refuse to report the primary metric when SRM fires. Enforce in code, not in policy.
Try it — the gate:
def chi2(obs, ratio=(0.5,0.5)):
n=sum(obs); exp=[n*r/sum(ratio) for r in ratio]
return sum((o-e)**2/e for o,e in zip(obs,exp))
for obs in ([200000,200000],[201000,199000],[202000,198000]):
c=chi2(obs)
print(f"{obs} -> chi2 {c:7.2f} {'ALARM: arms not comparable' if c>10.83 else 'ok'}")
[200000, 200000] -> chi2 0.00 ok
[201000, 199000] -> chi2 10.00 ok
[202000, 198000] -> chi2 40.00 ALARM: arms not comparable
Statistical power
What. The probability of detecting an effect of a given size if it exists.
Why it comes first. \(n = 2(z_{1-\alpha/2}+z_{\text{power}})^2\sigma^2/\delta^2\), so halving the minimum detectable effect quadruples the sample. At σ=0.5: MDE 0.05 needs 1,570/arm, 0.025 needs 6,280, 0.0125 needs 25,117.
Production. Do this before building the variant. If your realistic effect is 0.5% and you get 20,000 users/week, the experiment needs a year and should not be run. This calculation kills more proposals than any other number in the track.
Try it — the calculation that kills experiments:
import math
za, zb = 1.959963984540054, 0.8416212335729143
n = lambda sd, mde: math.ceil(2*(za+zb)**2*sd**2/mde**2)
for mde in (0.05, 0.025, 0.0125):
print(f"MDE {mde:<7} -> {n(0.5,mde):>7,} per arm")
print("halving the effect quadruples the sample. Compute this BEFORE building.")
MDE 0.05 -> 1,570 per arm
MDE 0.025 -> 6,280 per arm
MDE 0.0125 -> 25,117 per arm
halving the effect quadruples the sample. Compute this BEFORE building.
Peeking
What. Repeatedly testing significance as data accumulates and stopping at the first significant result.
Why it is so damaging. Each look is another chance to cross the threshold by chance. Simulated A/A tests: 5.12% false positives at 1 look, 14.47% at 5, 19.30% at 10, 32.80% at 50. Checking a dashboard daily for a fortnight turns a 5% error rate into ~25%.
How to fix it properly. Either fix the sample size in advance (pre-registration), or use a method designed for continuous monitoring — always-valid p-values, mSPRT, or alpha-spending — and pay the sample-size premium those require.
Connects to. Statistical power.
Production. The largest single source of false results in industrial experimentation. Build tooling that makes peeking impossible, not merely discouraged.
CUPED
What. Using pre-experiment data as a covariate to reduce metric variance.
Why. Variance, not effect size, usually decides how long an experiment runs. Removing predictable variance shortens it without changing the treatment.
How. \(Y_{\text{adj}} = Y - \theta(X - \bar X)\) where \(X\) is the same metric measured before the experiment and \(\theta\) is chosen to minimise variance.
Connects to. Statistical power, common random numbers — same goal, different setting.
Production. Typically 20–50% variance reduction, which translates directly into experiment-days saved.
Common random numbers
What. Using identical random seeds across experimental arms, varying only the treatment.
Why. A difference between arms then cannot be caused by a different population or a different content sample — the noise is shared and cancels.
How. Use independent per-component Generator streams derived from one root seed, so
that adding a parameter to one component does not shift every subsequent draw in the others.
Connects to. CUPED, bootstrap.
Production. Routinely an order-of-magnitude variance reduction in simulation — the difference between needing 1,000 and 100,000 simulated users.
Languages and Runtimes
Tree-walking interpreter
What. Executing a program by recursively traversing its AST, dispatching on node type.
Why start here. It is the most direct mapping from grammar to semantics: each node type
has an eval and the structure of the interpreter mirrors the structure of the language.
How it costs. Per node: a type dispatch, a pointer chase to children (a dependent-load chain), and a recursive call. The AST is scattered across the heap, so locality is poor.
Connects to. Bytecode VM is the standard next step — but see the measurement below, because the improvement is not automatic.
Bytecode VM
What. Compiling the AST to a flat instruction sequence executed by a dispatch loop.
Why it should be faster. Instructions are contiguous (good locality), dispatch is a switch on a small integer rather than a type test, and operand access is stack- or register-indexed rather than a pointer chase.
The measurement that complicates it. Both interpreters written in Python, identical semantics, verified identical results to fifteen significant figures: tree-walk 543.6 ns/statement, bytecode 821.3 ns/statement — the bytecode VM is 1.5× slower.
Why. Each source statement compiles to ~7 bytecode instructions, and in a host-interpreted VM each one costs a full host dispatch. The tree-walk pays one type check per AST node — fewer, larger steps. Bytecode's advantage was never "fewer operations"; it is "cheaper operations", and that only materialises when the dispatch loop compiles to machine code, where a switch becomes a computed goto of a few nanoseconds.
Connects to. Dispatch overhead — the identical lesson in a tensor framework. Threaded dispatch is the next optimisation.
Production. This is why P11 Phase II is written in Rust, and the reason is a measurement rather than an assumption.
Threaded dispatch
What. Replacing a switch in the interpreter loop with a computed goto that jumps
directly from the end of one instruction's handler to the next.
Why. A single switch is one indirect branch that the predictor sees for every opcode, so it is essentially unpredictable. With direct threading each handler has its own branch site, and the predictor can learn opcode-pair correlations.
Connects to. Bytecode VM, branch prediction.
Production. CPython uses computed gotos where the compiler supports them; it is worth several percent to tens of percent depending on workload.
Closure
What. A function together with the environment it captured.
Why it needs care. The captured variable must outlive the scope that created it, and two closures over the same variable must see each other's writes.
How to test it. The counter test: makeCounter() returning an incrementing function.
Two independent counters must yield 1,2 and 1. If the second returns 3, they share an
environment they should not; if the first returns 1 twice, you copied instead of captured.
Eight lines that distinguish three different implementations.
How it is implemented. In a tree-walk interpreter, an environment chain with parent
pointers — which in Rust means Rc<RefCell<Environment>>, and understanding why (shared
mutable ownership with runtime-checked borrowing is exactly what a mutable scope chain is)
is the highest-value day of Rust learning in this journey. In a VM, upvalues: pointers
into the stack while the frame lives, "closed" onto the heap when it exits.
Try it — the counter test:
def make_counter():
i = 0
def count():
nonlocal i
i += 1
return i
return count
c1, c2 = make_counter(), make_counter()
print(c1(), c1(), c2()) # 1 2 1
print("1 2 1 = correct. '1 2 3' means they share an environment they should not.")
print("'1 1 1' means you copied the value instead of capturing the variable.")
1 2 1
1 2 1 = correct. '1 2 3' means they share an environment they should not.
'1 1 1' means you copied the value instead of capturing the variable.
Mark-sweep garbage collection
What. Tracing from a root set, marking reachable objects, then sweeping unmarked ones.
Why tracing rather than reference counting. Reference counting cannot collect cycles, and cycles are common (a parent holding children that hold the parent).
How, and where the bugs are. The root set is everything reachable without going through another object: the VM stack, call frames, upvalues, globals, and any temporary live during a native call. Miss one root and you free a live object — a memory-corruption bug that manifests arbitrarily far from its cause. The defence is a stress mode that collects at every allocation, which converts it into an immediate, reproducible failure.
Connects to. Generational GC. The root-set traversal is structurally the same problem as a kernel's page reclamation.
Production. Measure the pause distribution — p50/p99/max against heap size. That plot is why your JVM or Go service has a p99 problem, and generating it yourself makes a whole class of production mystery legible.
Generational hypothesis
What. Most objects die young.
Why it enables an optimisation. If true, collecting only recently-allocated objects reclaims most garbage for a small fraction of the tracing work.
How. A nursery collected frequently, with survivors promoted to an older generation collected rarely. The complication is a write barrier to record old→young pointers, since a minor collection cannot trace the whole old generation.
Connects to. Mark-sweep.
Production. The hypothesis is testable: measure your own object lifetime distribution before assuming it holds for your workload.
Inline caching
What. Caching, at each call or property-access site, the type seen last time and the resolved target — so a repeat with the same type skips the lookup.
Why it works. Sites are overwhelmingly monomorphic in practice: a given line of code usually sees one type.
Connects to. The foundation of every fast dynamic-language runtime, and the gateway to JIT compilation.
Production. From Smalltalk-80 through V8. The highest-value real optimisation in a dynamic-language interpreter.
Pratt parsing
What. Top-down operator-precedence parsing: each token has a binding power, and the parser consumes operators while their power exceeds the current level.
Why. It handles precedence and associativity without a separate grammar rule per level, and extends to new operators trivially.
Connects to. Recursive descent for statements plus Pratt for expressions is the standard pairing, and nine pages of Pratt's 1973 paper is all you need.
Operating Systems
System call
What. A controlled transition from user mode to kernel mode to request a privileged service.
Why it costs. Mode switch, register save, argument validation, dispatch, and the cache/TLB effects afterwards.
How much. Measured 127.59 ns for a real trap (close(-1)) — ~140 L1 hits.
Beware the trap: getpid() measures 1.23 ns because libc caches the pid, and
clock_gettime measures 18 ns because it is served from a shared page. Both are commonly
and wrongly cited as syscall costs
(numbers).
Connects to. Context switch, vDSO.
Production. ~140 L1 hits per call is why unbuffered I/O is catastrophic and why
io_uring, sendmmsg and vectored I/O exist. Reading about io_uring before measuring a
syscall is reading a solution to a problem you have not felt.
vDSO / commpage
What. A page of kernel-provided code and data mapped read-only into every process, so some "system calls" execute entirely in user mode.
Why. clock_gettime is called often enough that a 128 ns trap would be a visible cost;
at 18 ns it is merely annoying.
Connects to. System call. Also the reason a naive syscall benchmark
using clock_gettime measures nothing.
Context switch
What. Saving one execution context and restoring another.
Why it costs far more than the register save. The direct cost — registers, stack pointer, page-table base — is small. The dominant cost is cache and TLB pollution: the incoming process evicts the outgoing one's working set, and the refill is paid later, off the switch's books.
How much. Measured ~1,676 ns best case (derived from a pipe round trip: \((3864 - 4\times128)/2\)) — ~13 syscalls, ~1,842 L1 hits. But a switch that evicts a 1 MB working set costs \(16{,}384 \times 121\,\text{ns} \approx 2\,\text{ms}\) of refill in the worst case — three orders of magnitude more, and entirely invisible to a ping-pong benchmark.
Connects to. Cache line, thread-pool sizing, why goroutines beat threads.
Production. This gap is why P12 sweeps working-set size rather than quoting a single number.
Virtual memory
What. Per-process address spaces mapped to physical frames through page tables, translated by hardware and cached in the TLB.
Why, and it is three reasons not one. (1) Isolation — a process cannot name another's memory. (2) Relocation — programs need not know where they physically live, enabling demand paging, copy-on-write, and shared libraries. (3) Overcommit — using more address space than physical RAM. Most explanations give only the third.
How. Multi-level page tables; a miss triggers a page fault, which the kernel services by allocating, loading, or killing. The TLB caches translations; a miss costs a page-table walk.
Connects to. Page replacement, PagedAttention is the same idea applied to a KV cache.
Page replacement
What. Choosing which page to evict when memory is full.
Why the choice matters. FIFO, LRU, and clock differ substantially in fault rate, and one of them has a genuinely counterintuitive property.
Bélády's anomaly. With FIFO, giving the system more memory can increase the fault rate. It is fully reproducible with a hand-constructed reference string, and it is the clearest possible demonstration that "more resources is better" is an assumption rather than a law. LRU is a stack algorithm and provably cannot exhibit it — showing both in one harness makes the point twice.
Connects to. Working set.
Production. P12's best single experiment.
Try it — Bélády's anomaly, in fifteen lines:
def faults(refs, frames, policy):
mem, q, n = set(), [], 0
for r in refs:
if r in mem:
if policy=="lru": q.remove(r); q.append(r)
continue
n += 1
if len(mem) == frames:
victim = q.pop(0); mem.discard(victim)
mem.add(r); q.append(r)
return n
refs = [1,2,3,4,1,2,5,1,2,3,4,5]
for policy in ("fifo","lru"):
row = [faults(refs, f, policy) for f in (3,4)]
tag = " <-- ANOMALY: more memory, MORE faults" if row[1] > row[0] else ""
print(f"{policy.upper():5} 3 frames: {row[0]} faults 4 frames: {row[1]} faults{tag}")
FIFO 3 frames: 9 faults 4 frames: 10 faults <-- ANOMALY: more memory, MORE faults
LRU 3 frames: 10 faults 4 frames: 8 faults
Working set
What. The set of pages a process has referenced in a recent time window.
Why it is the useful abstraction. It explains the knee in the fault-rate curve: while the working set fits in memory, faults are rare; once it does not, they explode. Scheduling and admission control both depend on it.
Connects to. Page replacement, context switch cost, cache line.
Copy-on-write
What. Sharing pages between parent and child after fork, marking them read-only, and
copying only on first write.
Why. fork followed by exec would otherwise copy an entire address space that is
immediately discarded.
Connects to. Virtual memory, PagedAttention prefix sharing is the same trick.
Priority inversion
What. A high-priority task blocked on a lock held by a low-priority task, which is itself preempted by a medium-priority task — so the high-priority task waits on the medium one indefinitely.
Why it is not a rare curiosity. It is a structural consequence of combining priorities with blocking locks, and it famously nearly ended the Mars Pathfinder mission.
How to fix. Priority inheritance (the holder temporarily inherits the waiter's priority) or priority ceilings.
Connects to. Scheduling, atomics.
Atomic operation
What. A read-modify-write that cannot be interleaved with another core's access to the same location.
Why memory ordering is separate. Atomicity guarantees the operation happens as a unit; ordering guarantees how it is visible relative to other operations. They are independent, and conflating them costs performance.
How much. Measured: relaxed atomic add 2.01 ns, seq_cst 3.97 ns — exactly 2×. The difference is a barrier preventing lazy store-buffer draining. An uncontended mutex is 6.38 ns, only 3.2× a relaxed atomic.
Connects to. False sharing is what makes contended atomics catastrophic.
Production. "Locks are slow" is wrong as stated — contended locks are slow. Once a waiter sleeps you pay a context switch (~1,676 ns), 260× the uncontended lock. And a statistics counter should be relaxed: you want the count, not an ordering guarantee.
References
- Drepper, U. What Every Programmer Should Know About Memory. Red Hat, 2007.
- Hennessy, J. L., Patterson, D. A. Computer Architecture: A Quantitative Approach, 6th ed. Morgan Kaufmann, 2017.
- Kleppmann, M. Designing Data-Intensive Applications. O'Reilly, 2017.
- Arpaci-Dusseau, R. H. & A. C. Operating Systems: Three Easy Pieces. 2018.
- Nystrom, R. Crafting Interpreters. Genever Benning, 2021.
- Akidau, T. et al. The Dataflow Model. VLDB 8(12), 2015.
- Ongaro, D., Ousterhout, J. In Search of an Understandable Consensus Algorithm. USENIX ATC 2014.
- Herlihy, M. P., Wing, J. M. Linearizability. ACM TOPLAS 12(3), 1990.
- Malkov, Y. A., Yashunin, D. A. HNSW. IEEE TPAMI 42(4), 2020.
- O'Neil, P. et al. The Log-Structured Merge-Tree. Acta Informatica 33, 1996.
- Athanassoulis, M. et al. The RUM Conjecture. EDBT 2016.
- Dayan, N., Athanassoulis, M., Idreos, S. Monkey. SIGMOD 2017.
- Vaswani, A. et al. Attention Is All You Need. NeurIPS 2017.
- Su, J. et al. RoFormer. arXiv:2104.09864, 2021.
- Dao, T. et al. FlashAttention. NeurIPS 2022.
- Baydin, A. G. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 18, 2018.
- Jouppi, N. P. et al. In-Datacenter Performance Analysis of a TPU. ISCA 2017.
- Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023.
- Kohavi, R., Tang, D., Xu, Y. Trustworthy Online Controlled Experiments. Cambridge, 2020.
- Chuklin, A., Markov, I., de Rijke, M. Click Models for Web Search. 2015.
- Jones, R., Hosking, A., Moss, E. The Garbage Collection Handbook, 2nd ed. CRC, 2023.
- Bélády, L. A., Nelson, R. A., Shedler, G. S. An anomaly in space-time characteristics of certain programs running in a paging machine. CACM 12(6), 1969.
The Project Scaffold
Fifteen projects over 34 months, each starting from an empty directory, is fifteen chances to lay the repository out differently and to forget the notebook, the AI log, or the raw-sample discipline. This generates the structure every project page assumes.
cd scaffold
./new-project.sh <slug> <python|rust|go> [target-dir]
Verified for all three languages: the generated project builds, its tests run, its
benchmark emits raw samples, and git is initialised with a first commit.
Table of Contents
- What It Generates
- The Five Make Targets
- Design Decisions
- Why the Placeholder Test Fails
- Language Notes
- What It Deliberately Does Not Do
- Verified Output
What It Generates
<slug>/
README.md problem statement, reproduce block, headline result
RESUME.md where you stopped + the next 60-second action
EXIT-CRITERIA.md what "done" means — filled in BEFORE coding
REPORT.md the deliverable, from templates/report.md
AI-LOG.md where an assistant materially changed a design
Makefile setup / test / bench / check / exit
src/ (or internal/) the mechanism. Hand-written
tests/ correctness. One placeholder that FAILS on purpose
bench/ (or cmd/) benchmark driver, importing the shared harness
tools/ bench.py, metrics.py, roofline.py — copied, not re-invented
notebook/ 000-TEMPLATE.md, 000-EXPERIMENT.md ready to copy
results/ raw samples. Committed, not ignored
Three placeholders are substituted: __SLUG__, __UNDER__ (underscored, for Python
packages and Rust crates), __DATE__. Three and no more — a scaffold with twenty
template variables becomes a thing you maintain instead of use.
The Five Make Targets
Identical across all three languages, so muscle memory transfers between projects even when the language does not.
| Target | Does | Notable |
|---|---|---|
make setup | install deps | Python writes requirements.lock from pip freeze — versions pinned from the first commit, because in eighteen months a version drift will explain a discrepancy you would otherwise spend a day on |
make test | correctness | Red on a fresh scaffold, on purpose |
make bench | benchmark → results/ | Depends on test. You cannot benchmark a broken build; that is a rule of this track, enforced in the Makefile rather than in your discipline |
make check | test + lint + report guard | Fails if REPORT.md still contains ⟨placeholders⟩ — a mechanical check that the deliverable was actually written |
make exit | exit-criteria tick state | Prints N/M ticked and lists what remains |
Go additionally has make race, and make check includes it — the race detector finds
real bugs in P05–P07 and should never be optional there.
Design Decisions
bench depends on test. The single most consequential line in the Makefile. One of
this track's operating rules is no performance work while a correctness test is red,
and a rule enforced by a build graph is worth more than a rule written on a page you read
once.
results/ is committed, not gitignored. You cannot recover a distribution from a p50.
Six months later you will want to re-percentile, check for bimodality, or bootstrap a
different statistic, and only the raw samples let you. The generated .gitignore says so
explicitly so you do not "helpfully" add it later.
tools/ is copied, never edited in place. All three benchmark drivers import the same
harness so that p50/p95/p99 with a bootstrap interval is the default and not a decision.
Edits belong upstream in the track's tools/, then re-copied — otherwise fifteen
divergent copies of bench.py make fifteen projects incomparable.
RESUME.md ships pre-filled with its own instructions. It is the highest-leverage
habit in the program (why) and the easiest
to skip on day one, so the file exists before you have a reason to create it.
EXIT-CRITERIA.md includes the eight-item completion gate verbatim, separate from the
project-specific criteria, because the gate is not negotiable by scoring well elsewhere.
It also has a scope-cuts table — cuts are recorded when taken, since a cut rationalised
at the end is a story rather than a decision.
Why the Placeholder Test Fails
Every generated project is red on make test until you delete one test.
A scaffold that is green on creation teaches you that green means nothing. Making it red forces you to open the test file as your first act, which is where the comment lives that says: write a property test, before the code it covers.
$ make test
FAILED tests/test_placeholder.py::test_replace_me - Failed: SCAFFOLD: replace...
1 failed, 1 passed
The one that passes asserts the mechanism is still unimplemented — it documents the state rather than testing behaviour. Both go when you write the first real test.
This was not the first design. The initial version used pytest.mark.xfail(strict=True),
Rust's #[should_panic], and Go's t.Skip — and all three reported green, which is
precisely the lesson the placeholder is supposed to prevent. Caught by running it.
Language Notes
Python
tests/conftest.py puts src/ and the project root on the path, so there is no packaging
step between you and a failing test. This is a research repo, not a distribution.
bench/run.py ships with workload_baseline and workload_candidate that are
identical, so a fresh scaffold prints:
-> OVERLAPPING CIs — no measurable difference
That is the harness demonstrating the behaviour you want from it before you have written anything: two identical functions produce an honest verdict rather than a manufactured 1% win.
Rust
Cargo.toml sets debug = true under [profile.release] so a profiler keeps symbols,
and make bench uses --release because a debug-build benchmark is meaningless —
bounds checks and absent inlining can cost 10–50×.
The bench driver uses std::hint::black_box, with a comment explaining why. Without
it, --release constant-folds the example workload to a literal and the benchmark reports
p50=0.0000ms — the "compiler deleted my benchmark" failure from
numbers §14. The first version of
this template had exactly that bug; it was found by running it, and the fix is now part of
the teaching material. With black_box the same workload measures 0.3 µs.
Go
make race exists as its own target and is included in make check. The race detector
is the highest-value tool in Stage 3 and costs ~10× runtime, which is why it is separate
from the fast make test loop but mandatory before declaring anything done.
Percentiles use nearest-rank in all three languages, so the reported p99 is always an observation that actually occurred rather than an interpolation between two.
What It Deliberately Does Not Do
- It does not scaffold the mechanism.
src/contains one function that raisesNotImplementedError. The mechanism is the project; generating it would delete the point. - No CI configuration. A local
make checkis sufficient at this scale, and CI for a solo research repo is not yet. - No Docker, no Nix. Pinned versions and a documented command are what reproducibility means here. Reach for more only after a real reproducibility failure.
- No logging or config framework. Both are peripheral concerns that expand to fill the time available.
Verified Output
Every claim above was executed. Generating all three and running the full cycle:
| Python | Rust | Go | |
|---|---|---|---|
make test on fresh scaffold | 1 failed, 1 passed | 1 passed, 1 failed | FAIL |
| after deleting placeholder | passed | ok | ok |
make race | — | — | ok (1.17 s) |
make bench p50 | 0.0076 ms | 0.0003 ms | 0.0012 ms |
| raw samples written | 1 file, 2000 samples/arm, env recorded | 1 file | 1 file |
make exit | 0/10 ticked | 0/10 ticked | 0/10 ticked |
make check with unfilled report | fails as designed | — | — |
git log | scaffold p02-ann-index (python) | scaffold p04-lsm (rust) | scaffold p05-distkv (go) |
NN — ⟨experiment title⟩
Project: ⟨Pnn⟩ · Milestone: ⟨n⟩ · Date started: ⟨YYYY-MM-DD⟩
Sections 1–8 are written and committed BEFORE the experiment runs. Field-by-field guidance: notebook-template.md. A completed example: example-filled.md.
1. Problem
⟨What breaks in the world if this does not exist? State it with a quantity. Do not name a technology.⟩
2. Constraints
Hard (physics, hardware, the data I have):
Chosen (design decisions I could revisit):
Assumed (believed but not verified — the interesting ones):
3. Existing Approach
⟨How is this normally solved? State exactly how much you have read.⟩
I stopped reading at: ⟨specific point, deliberately, so section 4 is mine⟩
4. My Naive Design
⟨Components, data structures, algorithm. Written before reading further.⟩
My reasoning: ⟨why each choice — this is what section 11 will diagnose⟩
5. Predictions
| # | Prediction | Confidence |
|---|---|---|
| P1 | high / med / low | |
| P2 | ||
| P3 |
⟨Each must be specific enough to be marked confirmed or falsified without argument.⟩
6. Hypothesis
H:
Falsifier:
7. Experimental Setup
- Hardware: ⟨model, cores, RAM, load average at run time⟩
- Software: ⟨language and version, key library versions⟩
- Data: ⟨source or generator, size, distribution — and how you verified it⟩
- Parameters: ⟨swept, and held fixed⟩
- Seed:
- Command:
Setup validation: ⟨how you confirmed the independent variable actually varies⟩
8. Baseline
⟨What am I comparing against, and why is it the fair comparison? Prefer a degenerate configuration of my own system.⟩
9. Results
⟨Tables and plots. Units, n, uncertainty. No interpretation.⟩
Verdict on each prediction
| # | Prediction | Outcome |
|---|---|---|
| P1 | confirmed / falsified / inconclusive |
10. Surprises
⟨What contradicted section 5, and by how much. Quantify each gap.⟩
11. Failure Analysis
⟨Which specific design decision caused it, and the causal chain. Not a category — a mechanism, specific enough to be wrong.⟩
12. Next Experiment
E1 — ⟨the smallest experiment that resolves the most uncertainty⟩ Cost: ⟨hours⟩ · Prediction: ⟨...⟩ If confirmed: ⟨...⟩ · If not: ⟨...⟩
Then, in order:
- E2 —
- E3 —
13. Generalization
Should hold when:
Should not hold when:
Predicts about systems I have not built: ⟨a falsifiable claim⟩
14. Reproducibility
commit :
command :
seed :
runtime :
output :
expect : ⟨headline number ± tolerance⟩
⟨System Name⟩ — Technical Report
Project ⟨Pnn⟩ · ⟨weeks⟩ · ⟨date⟩ · repository: ⟨url⟩
1,500–4,000 words for a project report. 6,000–10,000 for P15's paper. Sections marked required are scored by the scorecard and may not be empty.
Abstract
⟨150 words. What you built, what you measured, what you found — including the number. An abstract without a number is a description.⟩
1. Problem and Motivation
⟨What problem does this system solve? Why does the naive approach fail? Quantify the failure — at what scale, by how much.⟩
2. Background and Related Systems
⟨What already exists, and how your design relates. Short. This is not a survey.⟩
3. My Initial Design — required
⟨The design you wrote before reading the literature, and its reasoning. Then: where it was wrong, and what the canonical design does differently and why.
This section is what makes the report yours rather than a re-description. Keep it even when — especially when — your design was wrong.⟩
4. Architecture
⟨Components, data flow, key data structures. One diagram. State the design decisions that were genuinely decisions, with the alternative you rejected and why.⟩
5. Implementation Notes
⟨What was harder than expected. Which bug took longest and what it taught you. Language and library choices with reasons. Lines of code by component, if informative.⟩
6. Correctness
⟨How you know it works. Property tests, invariants, model-based testing, fuzzing. What each class of test caught. Known limitations and unverified assumptions.⟩
7. Methodology — required
⟨Hardware, software versions, workload generation, measurement method, statistics. Enough that the numbers in section 8 can be interpreted and challenged.
State explicitly: how many runs, how variance was estimated, what warmup was done, and what the machine was doing at the time.⟩
8. Baselines
⟨What you compare against and why it is fair. Include a degenerate configuration of your own system wherever possible.⟩
9. Results
⟨Tables and figures. Every latency as p50/p95/p99. Every claim with its uncertainty. Units on everything. Sample size on everything.⟩
10. Analysis
⟨Where does the time go? What is the bottleneck, and is it computational, algorithmic, architectural, or operational? Decompose ratios into their factors rather than reporting them whole.⟩
11. Hypothesis and Experiment — required
⟨The falsifiable claim, its stated falsifier, the controlled experiment, and the outcome. Whether it survived or not.⟩
12. Ablations
⟨What happens when each component is removed. If removing a component changes nothing, say so — it means the component is not part of the mechanism.⟩
13. What I Expected And Did Not Get — required
⟨Every prediction that was wrong, the size of the gap, and the mechanism behind it. Negative results, failed approaches, and things that turned out not to matter.
This section may not be empty. If it is, the predictions were too safe or they were written after the results.⟩
14. Failure Behaviour
⟨What breaks it. What it does under fault injection, overload, skew, and adversarial input. What degrades gracefully and what does not.⟩
15. Limitations and Threats to Validity — required
⟨What this work does not show. Where the measurements might mislead. Which conclusions depend on an assumption you did not verify. Scale you did not test.⟩
16. Future Work
⟨The extensions you scoped out, and the one experiment you most want to run next.⟩
17. Reproducibility — required
repository :
commit :
setup : <one command>
test : <one command>
benchmark : <one command>
runtime :
hardware :
expect : <headline number ± tolerance>
⟨Verify this by following it yourself from a clean clone before publishing.⟩
References
⟨Primary sources. Papers, books, official documentation, source code you read.⟩
Self-Assessment
⟨Scorecard, twelve categories, 1–5, with the evidence for each score named. Score down when unsure.⟩
| Category | Score | Evidence |
|---|---|---|
| First-principles understanding | ||
| Correctness | ||
| Implementation depth | ||
| Code quality | ||
| Systems reasoning | ||
| Experimental rigor | ||
| Benchmark quality | ||
| Failure analysis | ||
| Originality of hypotheses | ||
| Communication | ||
| Reproducibility | ||
| Completion discipline |
Experiment — ⟨EN: short name⟩
Project ⟨Pnn⟩ · notebook entry ⟨NNN⟩ · ⟨date⟩
A lightweight companion to
notebook.mdfor a single sweep. Use this when an experiment is one variable and one afternoon; use the full notebook when it carries a hypothesis.
Question
⟨One sentence. What am I trying to find out?⟩
Independent variable
| Variable | |
| Values | |
| Verified to vary? | ⟨how you confirmed the sweep actually changes the thing you think it changes⟩ |
Held fixed
⟨Everything else. Being explicit here is what makes it a controlled experiment.⟩
Prediction
⟨Written before running. Specific enough to be wrong.⟩
| Value | Predicted outcome |
|---|---|
Shape I expect: ⟨linear / concave / a knee at X / no effect⟩
What would surprise me: ⟨...⟩
Setup
hardware :
software :
data :
seed :
trials :
command :
Measurement resolution: ⟨the smallest effect this experiment can distinguish from noise. If you are measuring a rate p, you need ~100/p trials for a 10% relative standard error.⟩
Results
| Value | Metric 1 | Metric 2 | p50 | p95 | p99 | n |
|---|---|---|---|---|---|---|
Verdict
| Prediction | confirmed / falsified / inconclusive |
| Gap | ⟨quantified⟩ |
| Shape | ⟨as predicted, or not⟩ |
Why
⟨The mechanism. If the prediction was wrong, what specifically was wrong in my model? Name a decision or a quantity, not a category.⟩
Secondary metric that explains it
⟨Which additional measurement closes the argument? If there isn't one, that is a finding: you are measuring the outcome but not the mechanism.⟩
What this changes
⟨A decision in the project, or a prediction for a later one. If nothing changes, say so — an experiment that changes nothing was worth running only if it ruled something out, and you should name what.⟩
Raw data
file :
commit :
Programming-Language Strategy
Four languages across 34 months, each assigned to projects by which mechanism it makes visible — not by preference, and not revisited on a whim.
Table of Contents
- The Principle
- The Assignment
- Why Each Language, Specifically
- The Rust Learning Cost, Budgeted
- Rewrites: When They Are Allowed
- Cross-Language Boundaries
- What Is Not On This List, And Why
- References
The Principle
Choose the language that makes the mechanism under study impossible to ignore.
A language is a lens. Python hides memory, so it is the wrong choice for a garbage collector and the right choice for an algorithm you want to see clearly. Rust makes lifetimes explicit, which is unbearable overhead for a numerical experiment and exactly right for a storage engine. Go makes concurrency cheap, so you build the real topology instead of a simplified one.
The corollary is a rule the plan enforces: no rewrites for aesthetic reasons. Every hour spent reimplementing a working component in a nicer language is an hour not spent on a mechanism, and "constantly changing programming languages" is one of the fourteen named failure modes.
The Assignment
| Language | Projects | Hours | Share |
|---|---|---|---|
| Python | P01, P13, P08, P09, P10, parts of P02/P03 | ~470 | 33% |
| Rust | P04, P11, P12, P02's hot loop, P03's storage layer | ~430 | 30% |
| Go | P05, P06, P07 | ~319 | 22% |
| C / CUDA / Metal | P14, optionally P12 | ~77 | 5% |
| Mixed | P15 | ~143 | 10% |
You already know Python and Go professionally. Rust is the single real learning cost in the plan, and it is paid deliberately in the easiest project.
Why Each Language, Specifically
Python — P01, P13, P08, P09, P10
What it exposes: the algorithm, by making everything else uniform.
In P01 you want to see attention, not memory management. In P08 and P09 you want to change a ranking policy in ten minutes and rerun. Python's ecosystem — numpy, matplotlib, scipy — means the experiment harness is not itself a project.
And its slowness is a feature here. Python makes constant factors impossible to ignore, which produces two of this journey's best results:
- P02's two-factor decomposition: the graph index does 6.9× fewer distance computations and is 10× slower, because each one costs 899 ns instead of 13.3 ns. In C++ that gap would have been invisible and the lesson unlearned.
- P11's dispatch measurement: a bytecode VM written in Python is 1.5× slower than a tree-walk interpreter written in Python, which is the clearest possible demonstration that bytecode's advantage is cheap operations rather than fewer operations.
Neither of those results is available in a fast language. Python's overhead is a magnifying glass.
Where it is the wrong choice: anything where GC pauses, memory layout, or true parallelism are the subject. That is the rest of the list.
Rust — P04, P11, P12
What it exposes: lifetime, ownership, and aliasing — made explicit by the compiler.
Three reasons, in order of importance:
- The borrow checker forces the questions the projects are about. A storage engine is a set of decisions about who owns a buffer and how long it lives. Rust will not let you defer those decisions. In C you can defer them until a Tuesday-night segfault.
- No GC, so your latency measurements are yours. In P11 you are building a garbage collector and measuring its pauses. A runtime with its own GC would confound every number. In P12, an allocator in the runtime is simply not available.
- It is the language of the domain now. Storage engines, runtimes, and kernels are being written in Rust, and the transferability is real.
The honest cost: Rust is slower to write than C for the first three months. The plan absorbs that by scheduling P11 Phase I — the conceptually easiest project — as the on-ramp.
Why not C? C would work for all three. Rust is chosen because the compiler teaches — every borrow-check error is a question about lifetime that you would otherwise have answered wrong and found out later. That feedback loop is worth the friction in a learning program, and it is exactly the wrong trade in a deadline-driven one.
Go — P05, P06, P07
What it exposes: concurrency, cheaply enough that you build the real topology.
- Goroutines make the real design affordable. A 5-node Raft cluster with per-peer RPC loops, heartbeat timers, and an apply loop is natural in Go and painful in Rust's async ecosystem. You end up building the system you designed rather than the one your concurrency model permitted.
- The tooling is genuinely good for this.
go test -racefinds real data races.pprofis excellent.contextgives you cancellation and deadlines without ceremony — and deadline propagation is a distributed-systems concept, not a language feature. - Deterministic simulation is straightforward. P05's in-process simulated network with a seeded scheduler — the thing that makes distributed bugs replayable — is a comfortable Go program.
- You already know it, so the language costs nothing while distributed systems cost everything. That is the right allocation of difficulty for the hardest stage.
Where Go's GC could confound you: P05's tail-latency measurements will include GC
pauses. That is realistic — every production distributed system in a managed language has
this — but you must measure and report the GC contribution rather than let it hide in
your p99. GODEBUG=gctrace=1 and a note in the report.
C, CUDA, Metal — P14
What it exposes: nothing. And that is the point.
For hardware-aware work you need the memory hierarchy, vectorisation, and kernel launch
to be explicit and unmediated. No borrow checker, no bounds checks, no runtime. The
compiler flag table on
P14's page
— where blocking is worse than loop reordering at -O2 and 2.1× better at -O3 -mcpu=native — is only legible when you can read the generated assembly and change one
flag at a time.
CUDA if you have an NVIDIA GPU; Metal on Apple Silicon; otherwise the simulator carries the learning objective.
The Rust Learning Cost, Budgeted
Rust is the only genuinely new language, and pretending it is free is how plans slip.
| When | Project | What Rust costs you | Why it is affordable there |
|---|---|---|---|
| W16–20 | P11-I (Small, 55 h) | ~15 h of pure language friction | The project is conceptually easy: a lexer, a parser, a tree walk. The language is the only hard thing, so all the difficulty is in one place |
| W35–43 | P04 (Medium, 99 h) | ~8 h | You now have Rust basics; the difficulty moves to storage |
| W44–50 | P11-II | ~4 h | Fluent enough that lifetimes are a tool, not an obstacle |
| W100–110 | P12 (Large) | ~0 h | Two years in. no_std is new; the language is not |
Total budgeted language-learning overhead: ~27 hours, front-loaded into the smallest project. Already included in the effort estimates.
The specific thing to expect in P11-I: an environment chain with parent pointers,
captured by closures, requires Rc<RefCell<Environment>>. You will fight this for a day
and then understand why — shared mutable ownership with runtime-checked borrowing is
exactly what a mutable scope chain is. Do not cargo-cult it from a tutorial; derive it
from the error messages. That day is the highest-value day of Rust learning in the plan.
Rewrites: When They Are Allowed
Default: never. A working component stays in the language it was written in.
Three exceptions, each requiring a written justification in the project's report:
| Exception | Example | Justification required |
|---|---|---|
| The measurement demands it | P02's inner loop in Rust via PyO3, after Python established the two-factor model | The rewrite is the experiment (E11: does the constant factor collapse as predicted?) |
| A later project needs a different boundary | P03's storage layer in Rust while the index stays Python | Named which later project, and why the boundary must move |
| The original language made a mechanism invisible | Discovering mid-project that GC pauses confound your measurements | State what became invisible and why you did not foresee it |
Note that the first exception is a rewrite of a hot loop, not of a project. Rewriting 1,000 lines to prove a point is not the same as rewriting a 5,000-line index because Rust is nicer.
What is never a justification: "it would be cleaner", "I want more Rust practice", "the types would be better". Those are real preferences and they are not worth a week that belongs to P12.
Cross-Language Boundaries
P15 integrates four languages. Two ways across, and the choice matters more than it looks.
| Approach | Use when | Cost |
|---|---|---|
| Process boundary — separate processes, a simple protocol (JSON or length-prefixed binary over a Unix socket or stdin/stdout) | The default. Almost always right | One IPC round trip per call. Measure it and put it in the latency budget as a line item |
| FFI — PyO3 (Rust↔Python), cgo (Go↔C) | Only when the call is in an inner loop and IPC would dominate | Build complexity, memory-safety issues at the boundary, and debugging that spans two runtimes |
Prefer process boundaries in P15. A subprocess call is a known, measurable cost you can budget for; a broken FFI binding is an unbounded cost you cannot. The only place FFI is clearly correct in this plan is P02's distance kernel, where the whole point is to remove a per-distance overhead of 899 ns — an IPC boundary there would be absurd.
One rule for either approach: the boundary is a place where errors must be translated,
not propagated. A Rust Result and a Python exception are not the same object, and
deciding what a failure means at the seam is a design decision to make explicitly.
What Is Not On This List, And Why
| Language | Why not |
|---|---|
| C++ | Overlaps Rust's role without teaching anything additional here, and its complexity budget is better spent on domains. Read C++ (LevelDB, hnswlib, FAISS); do not write it |
| Zig | Genuinely appealing for P12, and the ecosystem and stability are not worth the risk in a 34-month plan. Revisit after |
| Java / Kotlin / Scala | You know the JVM professionally. It hides exactly the things P04, P11 and P12 study |
| JavaScript / TypeScript | No mechanism in this journey is best exposed by it |
| Haskell / OCaml | OCaml is an excellent language for P11 specifically, and adding a fifth paradigm costs more than P11 gains |
| Assembly | Read it in P14 (-S, and the vectoriser reports). Do not write it |
| Verilog / VHDL | Only if you take P14's FPGA extension. Not required |
The list is short on purpose. Four languages over 34 months is already a lot of context switching; six would mean never becoming fluent in any of the new ones.
References
- Klabnik, S., Nichols, C. The Rust Programming Language, 2nd ed. No Starch Press, 2023. Chapters 4, 10, 15 (ownership, lifetimes, smart pointers) before P11-I.
- Blandy, J., Orendorff, J., Tindall, L. Programming Rust, 2nd ed. O'Reilly, 2021. Better than the book above for a systems programmer who already knows C-family languages.
- Donovan, A. A. A., Kernighan, B. W. The Go Programming Language. Addison-Wesley, 2015. Chapters 8–9 on concurrency.
- Cox, R. Go Data Structures: Interfaces and the Go blog's memory-model posts. Relevant to P05's correctness reasoning.
- Kernighan, B. W., Ritchie, D. M. The C Programming Language, 2nd ed. Prentice-Hall, 1988.
- NVIDIA. CUDA C++ Programming Guide. For P14 if you have the hardware.
- Sapin, S. et al. The Rustonomicon. For P12's
no_stdand unsafe work. - Van Roy, P., Haridi, S. Concepts, Techniques, and Models of Computer Programming. MIT Press, 2004. The argument that languages are lenses rather than tools — the intellectual basis for this page's principle.
Mathematical Preparation
Just in time, never just in case. Every topic below is here because a specific project needs it, is scheduled in the week before that project needs it, and has a stated minimum depth beyond which you should stop.
Total budget: ~60 hours across 34 months — about 4% of the journey. If you find yourself doing more than that, you have started a mathematics curriculum, which is a different and worthy project that this one is not.
Table of Contents
- The Rule
- Schedule
- Linear Algebra
- Softmax, Cross-Entropy, and Information
- Chain Rule and Jacobians
- Concentration of Measure in High Dimensions
- Probability Distributions
- Hypothesis Testing
- Power and Sample Size
- Confidence Intervals and the Bootstrap
- Ranking Metrics
- Implicit Feedback and Bias
- Graph Theory
- Queueing Theory
- Numerical Computing
- Distributed-Systems Models
- Optimization
- What To Skip
- References
The Rule
For every topic, four things must be true before you spend an hour on it:
- Why it is needed — the concrete thing you cannot do without it
- Which project uses it — by number
- What practical problem it solves — an actual decision it changes
- The minimum depth — and an explicit "stop here"
If a topic cannot satisfy all four, it goes on What To Skip.
The failure mode this prevents is real and specific: spending three weeks on measure theory because probability "seems foundational", and arriving at P09 no better equipped than someone who spent two hours on the bootstrap.
Schedule
| Week | Topic | Hours | For |
|---|---|---|---|
| W1 | Linear algebra | 2 | P01 |
| W1 | Softmax and cross-entropy | 1 | P01 |
| W9 | Concentration of measure | 2 | P02 |
| W15 | Graph theory | 2 | P02 |
| W21 | Chain rule and Jacobians | 3 | P13-I |
| W25 | Numerical computing | 3 | P13-I |
| W35 | Probability: Zipf, Poisson, heavy tails | 3 | P04 |
| W55 | Distributed-systems models | 4 | P05 |
| W60 | Queueing theory | 4 | P05, P07 |
| W84 | Ranking metrics | 2 | P08 |
| W85 | Implicit feedback and bias | 2 | P08 |
| W90 | Confidence intervals and the bootstrap | 3 | P09 |
| W96 | Hypothesis testing | 3 | P10 |
| W97 | Power and sample size | 3 | P10 |
| various | Optimization | 3 | P01, P13 |
| W111 | Arithmetic intensity (no new maths; see roofline.py) | 0 | P14 |
| total | 40 |
The remaining ~20 hours of the budget are unallocated slack for the topic that turns out to be harder than planned. Historically that is distributed-systems models.
Linear Algebra
Why: attention is three matrix multiplications and a softmax. You cannot reason about its cost or its shape without fluency in matrix dimensions.
Project: P01, and everything downstream.
Solves: knowing that \(QK^\top\) is \(T \times T\) — and therefore quadratic in sequence length — before you write it. Reading a shape error and knowing which transpose is wrong.
Minimum depth: matrix multiplication and its dimension rules; transpose; the fact that \(AB\) composes linear maps; dot product as similarity; L2 norm; the cosine/L2 equivalence on unit vectors: \(\|a-b\|^2 = \|a\|^2 + \|b\|^2 - 2\langle a,b\rangle = 2 - 2\langle a,b\rangle\).
Stop here. You do not need eigenvalues, SVD, determinants, or matrix decompositions for any project in this journey. If you later take P08's two-tower extension, revisit.
Source: Strang, Introduction to Linear Algebra, chapters 1–2. Or 3Blue1Brown's Essence of Linear Algebra, episodes 1–4, which is 45 minutes and sufficient.
Softmax, Cross-Entropy, and Information
Why: the loss function, and the reason the entropy floor is where it is.
Project: P01, P08 (novelty in bits), P04 (Bloom filter optimality).
Solves: three things. Knowing that a model trained on random labels converges to exactly \(\ln V\) nats, so you can tell a broken model from a hard problem. Converting loss to perplexity by \(e^{\text{loss}}\). Understanding why the optimal Bloom filter is exactly half full.
Minimum depth: softmax and its max-subtraction stabilisation; cross-entropy as negative log-likelihood; entropy \(H = -\sum p\log p\) and why uniform maximises it; self-information \(-\log_2 p\) as "bits of surprise"; the softmax Jacobian \(\partial p_i/\partial z_j = p_i(\delta_{ij} - p_j)\) and the fused softmax + cross-entropy gradient \(p - y\) — derive that one, it is four lines and it is used in P13.
Stop here. No KL divergence beyond the definition, no mutual information, no rate– distortion theory.
Source: Goodfellow, Bengio, Courville, Deep Learning, §3.13 and §6.2. Cover & Thomas ch. 2 if you want the information theory properly.
Chain Rule and Jacobians
Why: this is the mathematical content of P13, and it is genuinely load-bearing — the only topic on this list where insufficient depth will actually stop you.
Project: P13-I.
Solves: deriving each backward rule yourself instead of copying it; understanding why reverse mode costs one pass and forward mode costs \(n\); getting broadcast backward right.
Minimum depth:
- Multivariate chain rule, stated with Jacobians: for \(z = f(y)\), \(y = g(x)\), \(J_{z/x} = J_{z/y} J_{y/x}\)
- Vector-Jacobian products — why you never materialise \(J\), and why an adjoint \(\bar{y}^\top J\) is what actually propagates
- Why associativity choice decides forward vs reverse: right-to-left gives you one column per pass, left-to-right one row
- Derive matmul backward: for \(C = AB\), \(\bar{A} = \bar{C}B^\top\) and \(\bar{B} = A^\top\bar{C}\). Write out \(C_{ij} = \sum_k A_{ik}B_{kj}\) and turn the crank
- The broadcast backward rule: sum over broadcast axes, keeping dims
Stop here. No differential geometry, no manifolds, no higher-order derivatives (unless you take P13's forward-mode extension).
Source: Baydin et al., Automatic Differentiation in Machine Learning: a Survey, §2–3. Then Griewank & Walther chapter 3 if you want rigour.
Do this one properly. Three hours here saves a week of debugging in P13.
Concentration of Measure in High Dimensions
Why: it turns "the curse of dimensionality" from folklore into a number you can measure and predict.
Project: P02, and P03's filtering study.
Solves: knowing before benchmarking that uniform data at \(d=512\) will defeat any index; choosing a test dataset whose difficulty you can state.
Minimum depth: as \(d\) grows, pairwise distances between i.i.d. points concentrate — the ratio of the furthest to the nearest neighbour tends to 1 (Beyer et al. 1999). Relative contrast \(\mathrm{RC} = d_{\text{mean}}/d_1\) as the operational measure. The intrinsic-vs-ambient dimension distinction, and why real embeddings are far easier than their \(d\) suggests.
One concrete fact worth carrying: a Gaussian perturbation with per-axis \(\sigma\) in \(d\) dimensions has expected norm \(\sigma\sqrt{d}\). This is why a "clustered" dataset with \(\sigma\sqrt{d} > 1\) around unit-norm centres is indistinguishable from uniform — measured at \(d=64\): \(\sigma=0.25\) gives RC 1.393 against uniform's 1.356.
Stop here. No measure-theoretic concentration inequalities. Chernoff and Hoeffding by name only.
Source: Beyer et al., When Is "Nearest Neighbor" Meaningful?, ICDT 1999. He, Kumar & Chang, ICML 2012.
Probability Distributions
Why: real workloads are not uniform, and the shape of the distribution decides the system's behaviour more than its mean does.
Project: P04 (Zipfian keys), P06 (stragglers), P07 (event delay), P09 (user models).
Solves: generating a realistic workload; knowing that a Zipf(1.0) catalogue puts 53% of engagement mass in the top 1% of items, so a bestseller list is a hard baseline; knowing why a mean latency is a lie when the distribution is heavy-tailed.
Minimum depth: uniform, normal, exponential, Poisson, log-normal, Zipf/power law. For each: what generates it, what its tail looks like, and how to sample from it. Heavy tails and why the mean can be dominated by rare events. Order statistics enough to know that \(\mathbb{E}[\max \text{ of } n]\) grows with \(n\) — which is why job completion time is a maximum and why stragglers dominate.
Stop here. No measure-theoretic probability. No characteristic functions. Extreme value theory by name only.
Source: Mitzenmacher & Upfal, Probability and Computing, chapters 2–3. Clauset, Shalizi & Newman, Power-Law Distributions in Empirical Data, SIAM Review 51(4), 2009, for how routinely power laws are mis-fitted.
Hypothesis Testing
Why: P10 is a statistics project wearing a systems costume.
Project: P10, and every A/B claim thereafter.
Solves: knowing what a p-value is and is not; why Welch's t is the default rather than Student's; why an SRM invalidates rather than warns.
Minimum depth: null and alternative hypotheses; type I and II error; the p-value's actual definition (the probability of data this extreme given the null, which is not the probability the null is true); t-tests including Welch's for unequal variances and why that is the right default; chi-square for categorical splits; multiple comparisons and the difference between family-wise error and false discovery rate.
Stop here. No Bayesian inference (interesting, and not needed); no ANOVA; no non-parametric tests beyond knowing they exist.
The one thing to internalise: the peeking result. Checking significance repeatedly inflates the false-positive rate from 5% to 19% at 10 looks and 33% at 50. That single table changes how you run experiments forever.
Source: Kohavi, Tang & Xu, Trustworthy Online Controlled Experiments, ch. 17.
Power and Sample Size
Why: it tells you whether an experiment is worth running before you build the variant.
Project: P10, P09.
Solves: the most useful single calculation in applied experimentation.
Minimum depth: statistical power; minimum detectable effect; the formula
\[ n_{\text{per arm}} = \frac{2(z_{1-\alpha/2} + z_{\text{power}})^2\sigma^2}{\delta^2} \]
and — more importantly — its consequence: halving the MDE quadruples \(n\).
Verified in tools/metrics.py: at \(\sigma=0.5\), MDEs of 0.05 /
0.025 / 0.0125 require 1,570 / 6,280 / 25,117 per arm. Exactly 4× per halving.
Stop here. No sequential analysis (that is P10's extension), no Bayesian sample sizing.
Source: Cohen, J. Statistical Power Analysis for the Behavioral Sciences, 2nd ed., chapter 1. Or just derive it from the two-sample z-test — twenty minutes and you own it.
Confidence Intervals and the Bootstrap
Why: every performance and quality claim in this journey needs an uncertainty estimate, and latency distributions are not normal.
Project: P09, P10, and every benchmark from week 1.
Solves: saying "no measurable difference" honestly rather than reporting a 3% improvement from overlapping intervals — the single most common benchmarking lie.
Minimum depth: sampling distribution; standard error; why a t-interval on the mean
is wrong for right-skewed, multi-modal latency data; the non-parametric bootstrap —
resample with replacement, recompute the statistic, take empirical quantiles. Implemented
in tools/bench.py in fifteen lines.
Also: common random numbers as a variance-reduction technique. Using the same seeds across experimental arms and varying only the treatment routinely cuts required sample size by an order of magnitude. It is the highest-value technique in P09 and it is one paragraph of theory.
Stop here. No BCa bootstrap, no jackknife, no bootstrap consistency theory.
Source: Efron & Tibshirani, An Introduction to the Bootstrap, chapters 6 and 13. Law, Simulation Modeling and Analysis, for common random numbers.
Ranking Metrics
Why: you will report these constantly, and each encodes an assumption most people using it cannot state.
Project: P08, P09, P10, and P02's recall.
Solves: knowing that NDCG's \(\log_2(i+2)\) discount models a user's examination probability, and is therefore wrong for an infinite-scroll feed; knowing why raw DCG is not comparable across queries.
Minimum depth: precision, recall, and their k-dependence; DCG, IDCG, NDCG and why
normalisation matters; MRR and why it only suits one-right-answer tasks; MAP. Then the
beyond-accuracy family: coverage, Gini, novelty as mean self-information, intra-list
diversity, calibration. All implemented in tools/metrics.py with
derivations in the docstrings.
Stop here. No learning-to-rank loss functions (LambdaRank, ListNet) unless you take P08's reranker extension.
Source: Järvelin & Kekäläinen, Cumulated gain-based evaluation of IR techniques, ACM TOIS 20(4), 2002.
Implicit Feedback and Bias
Why: clicks are not ratings, and every offline evaluation in P08 is biased by the policy that produced the logs.
Project: P08, P09.
Solves: stating the bias in your report rather than discovering it when the online test disagrees.
Minimum depth: implicit vs explicit feedback; position bias and the examination hypothesis \(P(\text{click}) = P(\text{examine}) \cdot P(\text{attract})\); why absence of a click is not a negative; selection bias from the logging policy; inverse propensity scoring as the standard correction, and why it has high variance (hence capping).
Stop here. No causal graphs, no doubly-robust estimators beyond knowing the name, unless you take P08's off-policy extension.
Source: Joachims, Swaminathan & Schnabel, Unbiased Learning-to-Rank with Biased Feedback, WSDM 2017. Chuklin et al., Click Models for Web Search, ch. 3.
Graph Theory
Why: HNSW is a graph, and its navigability is a graph-theoretic property.
Project: P02, P03.
Solves: understanding why greedy routing works — and predicting when it will not.
Minimum depth: degree, path length, connectivity, connected components. Small-world graphs: high clustering plus short paths. Kleinberg's navigability result — greedy routing achieves \(O(\log^2 n)\) hops only when long-range links follow a specific distance distribution, and fails otherwise. Greedy routing and local minima.
Why this earns its two hours: it is what lets you diagnose P02's clustered-data recall ceiling as a connectivity failure rather than a tuning problem. Without the graph-theoretic framing, the symptom looks like "recall plateaus" and the fix looks like "raise efSearch", which does not work.
Stop here. No spectral graph theory, no flows, no matchings.
Source: Kleinberg, Navigation in a Small World, Nature 406, 2000 — two pages. Watts & Strogatz, Nature 393, 1998.
Queueing Theory
Why: it explains why latency explodes near saturation, which is the single most useful predictive model in systems work.
Project: P05, P07, P15.
Solves: knowing that at 80% utilisation your queue is 4× its low-load length, and at 95% it is 19× — so capacity planning to 90% is not a 10% safety margin, it is a cliff.
Minimum depth:
- Little's Law: \(L = \lambda W\). Deceptively simple, applies to any stable system with no assumptions about the arrival distribution, and lets you convert between queue length, arrival rate, and latency. Use it constantly
- M/M/1 utilisation law: mean queue length \(\propto \rho/(1-\rho)\). The \(1/(1-\rho)\) term is the whole lesson — it is why the last 10% of capacity costs more than the first 90%
- Why variability makes it worse: the same utilisation with bursty arrivals queues far more
- Coordinated omission — the measurement bug where a load generator that waits for a response stops sending during a stall, and therefore fails to record the latencies its own stall caused. This makes almost every naive latency benchmark wrong at the tail
Stop here. No M/G/1, no Jackson networks, no matrix-geometric methods.
Source: Gunther, Guerrilla Capacity Planning, ch. 2. Tene, G. How NOT to Measure Latency (talk) for coordinated omission.
Numerical Computing
Why: your gradients will disagree with PyTorch's, and you need to know whether that is a bug or floating-point arithmetic.
Project: P13, P01, P14.
Solves: deciding whether a 1e-6 discrepancy is a bug (it usually is not) or a 1e-3 one is (it usually is); choosing a gradient-check tolerance you can defend; knowing why fp16 needs loss scaling.
Minimum depth: IEEE 754 single and double precision — sign, exponent, mantissa, and the ~7 significant decimal digits of fp32. Machine epsilon. Catastrophic cancellation. Why floating-point addition is not associative, and therefore why a different summation order legitimately gives a different answer. Log-sum-exp for stability. Central differences and the \(h \approx \sqrt{\epsilon}\) choice for gradient checking. bf16 vs fp16: same 16 bits, different exponent/mantissa split, and why bf16's wider range won for training.
Stop here. No numerical linear algebra, no conditioning theory beyond the concept, no error analysis of algorithms.
Source: Goldberg, D. What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys 23(1), 1991. Long, and the definitive answer.
Distributed-Systems Models
Why: P05's correctness arguments are formal, and hand-waving them produces a system that is wrong in a way tests do not catch.
Project: P05, P06, P07.
Solves: knowing what your system guarantees, precisely enough to write it down and have someone attack it.
Minimum depth:
- The asynchronous model and why it is the right default
- Happens-before, Lamport clocks, vector clocks, causality
- FLP impossibility: no deterministic async consensus with one crash failure. The statement and the intuition; the proof is optional
- Quorum intersection: two quorums of size \(Q\) from \(N\) intersect iff \(2Q > N\). Derive it — it is one line and it is the reason for every majority in every consensus protocol
- Linearizability as a formal property, precisely enough to check a history
- CAP as a theorem, and PACELC as the more useful framing
- Failure detectors: completeness and accuracy as separate properties
Budget 4 hours, and expect to need 6. This is the topic most likely to overrun, and it is worth the overrun.
Source: Lamport 1978; FLP 1985; Herlihy & Wing 1990; Gilbert & Lynch 2002. Cachin, Guerraoui & Rodrigues, Introduction to Reliable and Secure Distributed Programming, ch. 2, for the model definitions in one place.
Optimization
Why: you train models in P01 and implement optimizers in P13.
Project: P01, P13.
Solves: knowing why Adam needs warmup; why weight decay and L2 differ under adaptive methods; what a learning-rate schedule is for.
Minimum depth: gradient descent; the role of the learning rate; momentum as an exponential moving average of gradients; Adam's first and second moment estimates and their bias correction; why AdamW's decoupled decay differs from L2 (under Adam, an L2 term is divided by the second-moment estimate, so it is not uniform decay); warmup as a remedy for unreliable second-moment estimates early in training; cosine decay.
Stop here. No convex analysis, no convergence proofs, no second-order methods.
Source: Loshchilov & Hutter, Decoupled Weight Decay Regularization, ICLR 2019. Ruder, S. An overview of gradient descent optimization algorithms, arXiv:1609.04747.
What To Skip
Actively refuse these until a project needs them. Each is genuinely interesting, and each would cost weeks that belong to implementation. See also Not Yet.
| Topic | Why not | Unlocks if |
|---|---|---|
| Measure-theoretic probability | Nothing here needs it | Never, for this journey |
| Real analysis | Same | Never |
| Convex optimization theory | You use optimizers; you do not prove convergence | You take P13's second-order extension |
| Category theory | No | Never |
| Information geometry | No | Never |
| Spectral graph theory | P02 needs navigability, not spectra | You do spectral clustering in P08 |
| Statistical learning theory (VC, PAC) | Explains generalisation; you are not studying generalisation | Never, here |
| Bayesian inference | P10 uses frequentist methods throughout | You take P10's Bayesian extension |
| Causal inference beyond randomisation | P10 randomises, which is the easy case | You work with observational data |
| Formal methods / TLA+ | Genuinely valuable for P05, and a multi-week detour | After P05, as a separate project — see Research Directions |
| Numerical linear algebra | You call BLAS; you do not implement QR | Never, here |
| Coding and information theory beyond entropy | Bloom filters need one entropy argument | Never, here |
TLA+ deserves a note. Specifying Raft in TLA+ and model-checking it would genuinely improve P05 and is what a serious distributed-systems engineer does. It is excluded because it is a 40-hour skill acquisition inside a 143-hour project, and the plan already has P05 as its highest overrun risk. Do it after P05, as a separate two-week piece of work, and compare what the model checker finds against what your fault injector found. That comparison is itself a good blog post.
References
- Strang, G. Introduction to Linear Algebra, 5th ed. Wellesley-Cambridge, 2016.
- Goodfellow, I., Bengio, Y., Courville, A. Deep Learning. MIT Press, 2016.
- Baydin, A. G. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 18, 2018.
- Griewank, A., Walther, A. Evaluating Derivatives, 2nd ed. SIAM, 2008.
- Beyer, K. et al. When Is "Nearest Neighbor" Meaningful? ICDT 1999.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012.
- Mitzenmacher, M., Upfal, E. Probability and Computing, 2nd ed. Cambridge, 2017.
- Clauset, A., Shalizi, C. R., Newman, M. E. J. Power-Law Distributions in Empirical Data. SIAM Review 51(4), 2009.
- Efron, B., Tibshirani, R. An Introduction to the Bootstrap. Chapman & Hall, 1993.
- Cohen, J. Statistical Power Analysis for the Behavioral Sciences, 2nd ed. Lawrence Erlbaum, 1988.
- Kohavi, R., Tang, D., Xu, Y. Trustworthy Online Controlled Experiments. Cambridge, 2020.
- Järvelin, K., Kekäläinen, J. Cumulated gain-based evaluation of IR techniques. ACM TOIS 20(4), 2002.
- Kleinberg, J. Navigation in a Small World. Nature 406, 2000.
- Watts, D. J., Strogatz, S. H. Collective dynamics of 'small-world' networks. Nature 393, 1998.
- Gunther, N. J. Guerrilla Capacity Planning. Springer, 2007.
- Goldberg, D. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 1991.
- Cachin, C., Guerraoui, R., Rodrigues, L. Introduction to Reliable and Secure Distributed Programming, 2nd ed. Springer, 2011.
- Loshchilov, I., Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019.
Primary-Source Readings
Every reading in the journey, by project, with the reason and the hour budget.
Total: ~183 hours across 130 weeks — about 13% of the 1,430-hour budget, close to the 15% allocation. The remainder of that allocation goes to reading source code, which is not listed here and is often more valuable than the papers.
Table of Contents
- Three Rules
- The Method Canon
- P01 — Transformer
- P02 — ANN Index
- P03 — Vector Database
- P04 — LSM Engine
- P05 — Distributed KV
- P06 — MapReduce
- P07 — Streaming
- P08 — Recommender
- P09 — Simulator
- P10 — A/B Testing
- P11 — Language
- P12 — Kernel
- P13 — Tensor Framework
- P14 — Hardware-Aware
- P15 — Integration and Writing
- Source Code Worth Reading
- The Ten That Matter Most
Three Rules
1. Read after designing, not before. Every project's reading is scheduled after the milestone where you write your own version. Reading Malkov before designing your own graph index destroys the reconstruction exercise permanently, and it is the exercise the whole journey is built around.
2. Read the section, not the paper. Most entries below name a section. Raft §5.4.2, Vaswani §3, Dataflow's model section. Reading a paper end to end is usually a worse use of ninety minutes than reading its key section twice.
3. Reading never completes anything. It is an input to a milestone, never a deliverable. A week whose output is "read the LSM paper" is a failed week.
The Method Canon
Six pieces about how to work, not about any system. Read the first two in month 1; the rest as noted.
| Reading | When | Hours |
|---|---|---|
| Hamming, R. W. You and Your Research. Bell Labs, 1986 | Week 1 | 1 |
| Feynman, R. P. Cargo Cult Science. Caltech, 1974 | Week 1 | 0.5 |
| Lampson, B. W. Hints for Computer System Design. SOSP 1983 | Month 2 | 1.5 |
| Dean, J., Barroso, L. A. The Tail at Scale. CACM 56(2), 2013 | Before P05 | 1 |
| Ousterhout, J. Always Measure One Level Deeper. CACM 61(7), 2018 | Before P05 | 0.5 |
| Mytkowicz, T. et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009 | Before your first speedup claim | 1 |
Mytkowicz is the one people skip. It shows that changing the link order of a program, or the size of an environment variable, shifts measured performance by enough to invert published conclusions. Read it before you trust your first 5% improvement.
P01 — Transformer
13 hours. Full context: P01.
| Reading | Why | When | h |
|---|---|---|---|
| Vaswani, A. et al. Attention Is All You Need. NeurIPS 2017 | §3 closely. Note it is post-norm — later reversed | Week 2, after your naive design | 3 |
| Su, J. et al. RoFormer. arXiv:2104.09864, 2021 | §3.2 derives the relative-position property | Week 6 | 2 |
| Xiong, R. et al. On Layer Normalization in the Transformer Architecture. ICML 2020 | Why pre-norm won, via gradient magnitude at init | Week 7, before E6 | 2 |
| Dao, T. et al. FlashAttention. NeurIPS 2022 | §2–3 only. IO-awareness — the same lesson as P14 | Week 7 | 2 |
| Loshchilov, I., Hutter, F. Decoupled Weight Decay Regularization. ICLR 2019 | Why L2 ≠ weight decay under Adam | Week 5 | 1.5 |
| Radford, A. et al. Language Models are Unsupervised Multitask Learners. 2019 | The GPT-2 architecture table | Week 4 | 1 |
| Sennrich, R. et al. Neural Machine Translation of Rare Words with Subword Units. ACL 2016 | BPE | Week 8 | 1 |
| Ba, J. L. et al. Layer Normalization. arXiv:1607.06450, 2016 | Skim | Week 4 | 0.5 |
Also worth reading, after milestone 7: Elhage, N. et al. A Mathematical Framework for Transformer Circuits, Anthropic 2021 — the residual-stream-as-a-bus view, which changes how you read every subsequent architecture.
P02 — ANN Index
11 hours. P02.
| Reading | Why | When | h |
|---|---|---|---|
| Malkov & Yashunin. HNSW. IEEE TPAMI 42(4), 2020 | The source. Algorithm 4 is the part that matters most and is skipped most | Week 12, after your NSW | 3 |
| Malkov et al. NSW. Information Systems 45, 2014 | The single-layer version you will have reinvented | Week 11 | 1.5 |
| He, Kumar & Chang. On the Difficulty of Nearest Neighbor Search. ICML 2012 | Relative contrast | Week 9, before generating data | 1.5 |
| Beyer, K. et al. When Is "Nearest Neighbor" Meaningful? ICDT 1999 | The concentration result underneath | Week 9 | 1.5 |
| Aumüller et al. ANN-Benchmarks. Information Systems 87, 2020 | The evaluation protocol to imitate exactly | Week 10 | 1.5 |
| Jégou, Douze & Schmid. Product Quantization. IEEE TPAMI 33(1), 2011 | The other major family | Week 14 | 2 |
P03 — Vector Database
11 hours. P03.
| Reading | Why | h |
|---|---|---|
| Subramanya, S. J. et al. DiskANN. NeurIPS 2019 | The disk-resident answer; read before designing your segments | 2 |
| Mohan, C. et al. ARIES. ACM TODS 17(1), 1992 | §1–3 only. WAL, LSN, redo/undo | 2.5 |
| Crotty, Leis & Pavlo. Are You Sure You Want to Use MMAP…? CIDR 2022 | Read after your mmap milestone, then re-examine honestly | 1.5 |
| Gollapudi, S. et al. Filtered-DiskANN. WWW 2023 | After your own filtering experiment | 1.5 |
| Wang, J. et al. Milvus. SIGMOD 2021 | A real system's segment architecture | 1.5 |
| Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014 | What your fsync discipline actually guarantees | 1 |
| Kleppmann, M. DDIA, ch. 3 | The clearest storage-engine overview | 1 |
P04 — LSM Engine
15 hours — the largest budget, because this literature is unusually good. P04.
| Reading | Why | h |
|---|---|---|
| O'Neil, P. et al. The Log-Structured Merge-Tree. Acta Informatica 33, 1996 | The origin; §3's cost model is the amplification derivation | 3 |
| Dayan, Athanassoulis & Idreos. Monkey. SIGMOD 2017 | Bloom bits should not be uniform across levels. Genuinely surprising, and your best hypothesis source here | 2 |
| Rosenblum & Ousterhout. Log-Structured File System. SOSP 1991 | The cleaning-cost analysis is the compaction analysis | 2 |
| Dong, S. et al. Optimizing Space Amplification in RocksDB. CIDR 2017 | Production numbers for your trade | 2 |
| Ghemawat & Dean. LevelDB source and implementation notes | Read after milestone 5 | 2 |
| Athanassoulis, M. et al. The RUM Conjecture. EDBT 2016 | The framing that makes the project one idea | 1.5 |
| Chang, F. et al. Bigtable. OSDI 2006 | SSTables in context | 1.5 |
| Bloom, B. H. Space/time trade-offs in hash coding… CACM 13(7), 1970 | Three pages. Read the original | 0.5 |
| Pillai, T. S. et al. All File Systems Are Not Created Equal. OSDI 2014 | Re-read | 0.5 |
P05 — Distributed KV
20 hours — the largest in the journey. P05.
| Reading | Why | h |
|---|---|---|
| Ongaro & Ousterhout. In Search of an Understandable Consensus Algorithm. ATC 2014 | The extended version. §5.4.2 twice | 5 |
| Lamport, L. Time, Clocks, and the Ordering of Events. CACM 21(7), 1978 | Happens-before | 2 |
| Fischer, Lynch & Paterson. Impossibility of Distributed Consensus… JACM 32(2), 1985 | Theorem and intuition; proof optional | 2 |
| Herlihy & Wing. Linearizability. ACM TOPLAS 12(3), 1990 | The definition your checker implements | 2 |
| DeCandia, G. et al. Dynamo. SOSP 2007 | The AP design point | 2.5 |
| Corbett, J. C. et al. Spanner. OSDI 2012 | What a bounded clock buys | 2 |
| Gilbert & Lynch. Brewer's Conjecture… SIGACT News 33(2), 2002 | CAP as a theorem | 1.5 |
| Hayashibara, N. et al. The φ Accrual Failure Detector. SRDS 2004 | For E4 | 1.5 |
| Kingsbury, K. Jepsen analyses — pick three real systems | What violations look like in shipped software | 1.5 |
P06 — MapReduce
11 hours. P06.
| Reading | Why | h |
|---|---|---|
| Dean & Ghemawat. MapReduce. OSDI 2004 | §3.6 on backup tasks is what E4 tests | 2.5 |
| Ghemawat, Gobioff & Leung. The Google File System. SOSP 2003 | The storage assumptions underneath | 2 |
| Zaharia, M. et al. Resilient Distributed Datasets. NSDI 2012 | Why lineage beats re-execution | 2 |
| Zaharia, M. et al. Improving MapReduce Performance in Heterogeneous Environments. OSDI 2008 | LATE — naive speculation actively harms | 1.5 |
| Dean & Barroso. The Tail at Scale. CACM 56(2), 2013 | Re-read with a straggler in front of you | 1.5 |
| Isard, M. et al. Dryad. EuroSys 2007 | The general-DAG generalisation | 1 |
| Verma, A. et al. Borg. EuroSys 2015 | Where tasks actually run | 0.5 |
P07 — Streaming
12 hours. P07.
| Reading | Why | h |
|---|---|---|
| Akidau, T. et al. The Dataflow Model. VLDB 2015 | The most important paper in this project. What/where/when/how | 3 |
| Akidau, T. Streaming 101 / 102. O'Reilly, 2015 | The clearest explanation of watermarks in print | 2 |
| Carbone, P. et al. Lightweight Asynchronous Snapshots. arXiv:1506.08603, 2015 | Flink's barrier snapshotting | 2 |
| Chandy & Lamport. Distributed Snapshots. ACM TOCS 3(1), 1985 | The algorithm underneath it | 1.5 |
| Zaharia, M. et al. Discretized Streams. SOSP 2013 | The micro-batch alternative, honestly | 1.5 |
| Kreps, J. The Log. LinkedIn, 2013 | Changes how you see storage generally | 1 |
| Kreps, Narkhede & Rao. Kafka. NetDB 2011 | The log as a primitive | 1 |
P08 — Recommender
10 hours. P08.
| Reading | Why | h |
|---|---|---|
| Chaney, Stewart & Engelhardt. How Algorithmic Confounding… RecSys 2018 | The feedback loop, simulated. Sets up P09 | 2 |
| Covington, Adams & Sargin. Deep Neural Networks for YouTube Recommendations. RecSys 2016 | Two-stage architecture; "example age" is the freshness lesson | 1.5 |
| Steck, H. Calibrated Recommendations. RecSys 2018 | Why accuracy-optimal recommendations are miscalibrated | 1.5 |
| Cañamares & Castells. Should I Follow the Crowd? SIGIR 2018 | Why popularity baselines are so hard to beat | 1.5 |
| Hu, Koren & Volinsky. Collaborative Filtering for Implicit Feedback Datasets. ICDM 2008 | The implicit-feedback formulation | 1.5 |
| Wu, F. et al. MIND. ACL 2020 | News-specific evaluation and its pitfalls | 1.5 |
| Carbonell & Goldstein. The Use of MMR… SIGIR 1998 | Four pages | 0.5 |
P09 — Simulator
9 hours. P09.
| Reading | Why | h |
|---|---|---|
| Chuklin, Markov & de Rijke. Click Models for Web Search. 2015 | Chapters 3–4. The definitive treatment | 2.5 |
| Chaney et al. How Algorithmic Confounding… RecSys 2018 | Re-read; closest published work to this project | 2 |
| Ie, E. et al. RecSim. arXiv:1909.04847, 2019 | Read the design decisions, then make your own | 1.5 |
| Craswell, N. et al. An Experimental Comparison of Click Position-Bias Models. WSDM 2008 | Where the position-bias exponent comes from | 1 |
| Rohde, D. et al. RecoGym. arXiv:1808.00720, 2018 | A second design to compare against | 1 |
| Jeunen, O. Revisiting Offline Evaluation… RecSys 2019 | Why offline evaluation fails | 1 |
P10 — A/B Testing
9 hours. P10.
| Reading | Why | h |
|---|---|---|
| Kohavi, Tang & Xu. Trustworthy Online Controlled Experiments. Cambridge, 2020 | Ch. 1–3, 17–19. The book on this | 3 |
| Kohavi, R. et al. Online Controlled Experiments at Large Scale. KDD 2013 | SRM, Twyman's law, the real failure modes | 1.5 |
| Deng, A. et al. Improving the Sensitivity… (CUPED). WSDM 2013 | Variance reduction | 1.5 |
| Johari, R. et al. Peeking at A/B Tests. KDD 2017 | The principled fix for peeking | 1.5 |
| Kohavi & Longbotham. Unexpected Results in Online Controlled Experiments. 2010 | Case studies where intuition lost | 1 |
| Gupta, S. et al. Top Challenges… SIGKDD Explorations 21(1), 2019 | What the industry finds hard | 0.5 |
P11 — Language
16 hours across both phases. P11.
| Reading | Phase | Why | h |
|---|---|---|---|
| Nystrom, R. Crafting Interpreters, Part II | I | Tree-walk. After your milestone 5 | 4 |
| Nystrom, R. Crafting Interpreters, Part III | II | Bytecode, VM, GC. After milestone 10 | 5 |
| Jones, Hosking & Moss. The Garbage Collection Handbook, 2nd ed. | II | Ch. 2–3 and 9 | 3 |
| Wilson, P. R. Uniprocessor Garbage Collection Techniques. IWMM 1992 | II | The best survey | 1.5 |
| Ertl & Gregg. The Structure and Performance of Efficient Interpreters. JILP 5, 2003 | II | Dispatch techniques, measured | 1.5 |
| Pratt, V. Top Down Operator Precedence. POPL 1973 | I | Nine pages | 1 |
P12 — Kernel
17 hours. P12.
| Reading | Why | h |
|---|---|---|
| Arpaci-Dusseau & Arpaci-Dusseau. Operating Systems: Three Easy Pieces | Virtualization + concurrency. Read alongside your current milestone | 6 |
| Cox, Kaashoek & Morris. xv6 (RISC-V edition) | The book and the source. ~9,000 readable lines | 4 |
| Lampson, B. W. Hints for Computer System Design. SOSP 1983 | Re-read; written by an OS designer about OS design | 1.5 |
| Denning, P. J. The Working Set Model for Program Behavior. CACM 11(5), 1968 | Why locality makes any of this work | 1 |
| Ousterhout, J. Why Aren't Operating Systems Getting Faster…? USENIX 1990 | Still true | 1 |
| Ritchie & Thompson. The UNIX Time-Sharing System. CACM 17(7), 1974 | Design taste in eleven pages | 1 |
| Anderson, T. E. et al. Scheduler Activations. SOSP 1991 | The user/kernel threading boundary | 1 |
| Bélády, Nelson & Shedler. An anomaly in space-time characteristics… CACM 12(6), 1969 | The anomaly you will reproduce | 0.5 |
| RISC-V Privileged Architecture Specification | Trap and paging chapters | 1 |
P13 — Tensor Framework
12 hours. P13.
| Reading | Why | h |
|---|---|---|
| Baydin, A. G. et al. Automatic Differentiation in ML: a Survey. JMLR 18, 2018 | The clearest treatment of modes and their costs | 3 |
| Paszke, A. et al. PyTorch. NeurIPS 2019 | Design decisions of the thing you are reimplementing | 2 |
| Abadi, M. et al. TensorFlow. OSDI 2016 | The static-graph alternative and its rationale | 2 |
| Griewank & Walther. Evaluating Derivatives, 2nd ed. | Ch. 3–4, for rigour | 2 |
| Chen, T. et al. Training Deep Nets with Sublinear Memory Cost. 2016 | Gradient checkpointing | 1.5 |
| Chen, T. et al. TVM. OSDI 2018 | Fusion as a compiler problem | 1.5 |
P14 — Hardware-Aware
13 hours. P14.
| Reading | Why | h |
|---|---|---|
| Jouppi, N. P. et al. In-Datacenter Performance Analysis of a TPU. ISCA 2017 | Read after designing your own accelerator | 3 |
| Goto & van de Geijn. Anatomy of High-Performance Matrix Multiplication. ACM TOMS 34(3), 2008 | Why BLAS is fast | 2.5 |
| Chen, Emer & Sze. Eyeriss. ISCA 2016 | Dataflow taxonomy; the energy argument | 2 |
| Drepper, U. What Every Programmer Should Know About Memory. 2007 | Long; the best treatment of cache behaviour | 2 |
| Williams, Waterman & Patterson. Roofline. CACM 52(4), 2009 | The model | 1.5 |
| Micikevicius, P. et al. Mixed Precision Training. ICLR 2018 | Why fp16 needs loss scaling | 1 |
| Dettmers, T. et al. LLM.int8(). NeurIPS 2022 | Where naive int8 breaks | 1 |
P15 — Integration and Writing
~10 hours, plus question-specific literature. P15, final-system.
| Reading | Why | h |
|---|---|---|
| Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth. ACM TOPLAS 38(4), 2016 | The best checklist for a systems evaluation section | 2 |
| Hoefler & Belli. Scientific Benchmarking of Parallel Computing Systems. SC 2015 | Twelve rules; apply all twelve | 1.5 |
| Peyton Jones, S. How to Write a Great Research Paper. MSR 2004 | Write the paper first | 1 |
| Zobel, J. Writing for Computer Science, 3rd ed. | The report format | 2 |
| Collberg & Proebsting. Repeatability in Computer Systems Research. CACM 59(3), 2016 | Before the reproducibility appendix | 1 |
| Bailey, D. H. Twelve Ways to Fool the Masses… 1991 | A list of things not to do | 0.5 |
| Shewchuk, J. R. Three Sins of Authors in Computer Science and Math. 1997 | 0.5 h, permanently useful | 0.5 |
| Question-specific literature | Varies | 1.5+ |
Source Code Worth Reading
Often more valuable per hour than the papers, and not counted in the budget above. Always read after your own implementation, never before.
| Codebase | After | Why |
|---|---|---|
micrograd (Karpathy, ~150 lines) | P13 milestone 1 | You will have independently invented most of it |
nanoGPT (Karpathy) | P01 milestone 7 | A check on your choices, not a source for them |
LevelDB — db_impl.cc, version_set.cc | P04 milestone 9 | The clearest small LSM in existence |
| xv6 (~9,000 lines) | P12, continuously | Small enough to hold entirely in your head |
hnswlib — hnswalg.h | P02 milestone 7 | ~1,000 lines; the neighbour heuristic in practice |
| etcd/raft | P05 milestone 8 | A production Raft with a readable state machine |
Redis — t_string.c, ae.c | any time | Exemplary C |
| SQLite — the source and the documentation | P03/P04 | Possibly the best-documented codebase in existence |
CPython — ceval.c | P11 phase II | A real dispatch loop |
| Lua 5.x (~20,000 lines) | P11 phase II | A register VM; small, complete, elegant |
The Ten That Matter Most
If a quarter goes badly and you must triage, protect these.
| # | Reading | Why it survives the cut |
|---|---|---|
| 1 | Ongaro & Ousterhout, Raft (extended) | The only way to get P05 right, and §5.4.2 is a bug you will otherwise ship |
| 2 | Akidau et al., The Dataflow Model | Reframes "correctness" for unbounded input; nothing else does this |
| 3 | Jouppi et al., TPU | Makes the entire hardware/software boundary legible |
| 4 | O'Neil et al., LSM-Tree | The cost model that generalises to every storage decision |
| 5 | Baydin et al., AD Survey | The forward-vs-reverse argument, cleanly |
| 6 | Kohavi, Tang & Xu, Trustworthy Experiments | The only book here that will change what you do at work next week |
| 7 | Malkov & Yashunin, HNSW | Algorithm 4, which everyone skips and which is load-bearing |
| 8 | Dean & Barroso, The Tail at Scale | Six pages; permanently changes how you read a latency number |
| 9 | Lampson, Hints for Computer System Design | The closest thing to transferable design judgement in print |
| 10 | Hamming, You and Your Research | About whether you finish, which is the binding constraint |
Nine of the ten are freely available. The exception is Kohavi et al., which is worth buying.
Datasets, Hardware and Money
What the journey physically needs, what it costs, what to do when you cannot get something, and the two places where a missing resource actually blocks a project rather than merely inconveniencing it.
Headline: the whole journey runs on one laptop for well under $200 total, and only two projects have a genuine external dependency.
Table of Contents
- The Short Version
- Hardware
- The Two Real Dependencies
- Datasets, Project by Project
- Synthetic Data Done Properly
- Money
- Storage and Housekeeping
- Acquisition Timeline
- References
The Short Version
| Resource | Needed? | Cost | Blocks what if absent |
|---|---|---|---|
| A laptop with 16 GB RAM | Yes | owned | Everything |
| ~120 GB free disk | Yes | owned | P04, P15 |
| A C toolchain, Rust, Go, Python | Yes | free | Respective projects |
| QEMU | Yes (P12) | free | P12 |
| An NVIDIA GPU | No | — | Nothing. P14 has a simulator path |
| Cloud GPU hours | Optional | ~$20–60 total | Only P14's optional CUDA extension |
| A real interaction dataset (MIND) | Effectively yes for P09/P10 | free, registration | Research direction D4, and part of P09's validation |
| Real article text | Yes, but many sources | free | P08, P09, P15 |
| ANN benchmark vectors (SIFT/GIST) | Recommended | free | P02's external comparison is weaker without |
| Books | Optional | ~$120 | Nothing — all but one are free online |
Hardware
What the reference machine was
Everything measured in numbers.md came from a 12-core arm64 laptop with macOS. Nothing in the journey requires that machine or anything like it.
Minimum
| Component | Minimum | Why |
|---|---|---|
| RAM | 16 GB | P02 at n=10⁶ × d=128 fp32 is 512 MB of vectors plus a graph plus build overhead (2–3× peak). P09 with 10⁵ personas. P04 wants a working set exceeding RAM, which is easier with less |
| Disk | 120 GB free | P04 needs a working set several times RAM to measure honestly; P15 accumulates segments |
| Cores | 4+ | P05–P07 run multi-process; P12's scheduler experiments want ≥2 |
| CPU | Anything from the last ~8 years | Ratios matter, absolute speed does not |
8 GB is workable with reduced scales — n=10⁵ in P02, fewer personas in P09 — and you should scale the experiments down rather than skip them. Say so in the reports.
Where less RAM is an advantage
Counterintuitively, P04 is easier to do honestly on a smaller machine. The single most common LSM benchmarking error is a working set that fits in the page cache, which measures memory and reports it as disk. On 16 GB you reach "exceeds RAM" at 20 GB of data; on 128 GB you need 200 GB and most people never bother.
Architecture notes
- arm64 (Apple Silicon): everything works.
perfcounters are limited, so derive DRAM traffic analytically in P14 where you cannot measure it, and say which you did. The 128 KB L1 is unusual — see numbers §1. - x86-64 Linux: the best case.
perfworks fully,O_DIRECTexists (so you can measure device reads honestly, unlike TRAP 4), and QEMU/RISC-V tooling is smoothest. - Windows: use WSL2. Note that WSL2's filesystem and timer behaviour will distort P04 and P12 measurements; prefer a native Linux partition or a VM for those two.
QEMU for P12
brew install qemu # macOS
apt install qemu-system-misc gdb-multiarch # Debian/Ubuntu
Plus a RISC-V cross-toolchain (riscv64-elf-gcc via Homebrew, or the riscv64-unknown-elf
prebuilt from SiFive). Install and verify these in week 1 of P12, not week 3 — the
week-3 decision rule exists because
toolchain time is pure waste, and the way to spend none is to hit the problem early with
a fallback ready.
GPUs
No project requires one. P14 has three paths:
- NVIDIA GPU available → CUDA, milestone 9 as written.
- Apple Silicon → Metal compute shaders. Same learning objective, different API, and the roofline analysis is identical.
- Neither → extend the systolic simulator to model a multi-core vector machine and compare three architectures in simulation. This is the documented substitution and it preserves the objective; you lose the experience of writing a real kernel.
If you want option 1 without owning hardware: a spot A10 or T4 is roughly $0.20–0.60 per hour. P14's milestone 9 is ~8 hours of work but only ~2–3 hours of GPU time if you develop the harness locally first. Budget $20–40, and develop against a CPU stub so you are not debugging syntax on a metered clock.
The Two Real Dependencies
Everything else has a synthetic fallback. These two do not, and both should be resolved early rather than discovered late.
1. Real interaction data — for P09's validation and research direction D4
P09's honest limitation is that a simulator's conclusions are conditional on a user model you wrote. The only way to escape that is to check the simulator's rankings against rankings derived from real behaviour.
MIND (Microsoft News Dataset) is the right corpus: news, which matches your domain; ~160k articles, 1M users, 24M clicks in the large variant; impression logs with both clicks and non-clicks, which is what makes off-policy evaluation possible at all.
- Free, registration required, research-use licence — read it and check it permits what you intend, particularly if you later publish.
- MIND-small (~50k users) is enough for everything in P09/P10 and much faster to iterate on.
- Get it in month 20, not month 23. If the licence or the download blocks you, you want to know while there is time to substitute.
Fallbacks, in order: MovieLens-25M (well-understood, has real ratings, but is not news and has no impression logs — you lose the non-click signal); Amazon Reviews; RecSys Challenge datasets; or your own consumption logs from a feed reader, which is small but genuinely yours.
If none works: P09 and P10 still run fully on simulated users. What you lose is D4 as a research direction and the strongest form of P09's validity claim. Write that limitation explicitly rather than quietly.
2. Article text with timestamps — for P08, P09, P15
You need a corpus with publication times, because freshness is a first-class metric in a news recommender and synthetic timestamps make every freshness result circular.
| Source | Size | Notes |
|---|---|---|
| MIND | 160k | Best: real articles, real timestamps, real behaviour |
| Wikipedia dumps | unbounded | Free, timestamped by revision. Not news-shaped |
| RSS harvest of public feeds | grows daily | Start this in month 1. By month 20 you have 20 months of real, timestamped, domain-relevant articles at zero cost |
| Common Crawl news | large | Heavy; only if you need scale |
| arXiv metadata | 2M+ | Free API, timestamps, abstracts. Excellent and underused |
The RSS harvest is the highest-value action on this page, and it costs 30 minutes in month 1. A cron job appending a few dozen feeds to a JSONL file gives you, by the time you reach P08, a corpus that is real, timestamped, in your domain, and unencumbered. Nobody does this and everybody wishes they had.
month 1 : write a 40-line RSS poller, run it hourly, append JSONL
month 20: ~50k-200k timestamped articles ready for P08/P09/P15
cost : 30 minutes and a few hundred MB
Datasets, Project by Project
| Project | Needs | Source | If unavailable |
|---|---|---|---|
| P01 | 1–5 MB plain text | Project Gutenberg, your own writing, any docs dump | Trivially substitutable. Prefer text you know well — you will read generated samples for eight weeks |
| P02 | Vectors, ideally with realistic contrast | SIFT1M / GIST1M (free, standard); your P01 embeddings; synthetic | Synthetic is fine for most experiments provided you report RC — see below |
| P03 | P02's vectors + metadata to filter on | Derive metadata from the corpus (publisher, date, length, category) | Synthetic metadata with a controlled selectivity distribution is arguably better for E3 |
| P04 | Key-value workload | Generate: uniform / Zipfian / sequential | No external dependency. Working set must exceed RAM |
| P05–P07 | Operations and events | Generated by your own harness | None |
| P08 | Articles + interactions | MIND, or RSS harvest + P09 simulated interactions | Simulated interactions are acceptable; state it |
| P09 | Content pool + (for validation) real logs | Above | Runs fully synthetic; the validation claim weakens |
| P10 | P09's population | — | None |
| P11–P12 | Programs / workloads | Write them | None |
| P13 | P01's model and gradients | Your own | None |
| P14 | Matrices | Generated | None |
| P15 | Whatever the question needs | Above | Depends on the question — a reason to prefer Q2 or Q5 |
Nine of fifteen projects have no external data dependency at all. The plan is deliberately laptop-and-generator shaped.
SIFT1M / GIST1M for P02
The standard ANN benchmark vectors (1M × 128 SIFT, 1M × 960 GIST), free from the TEXMEX corpus and used by ANN-Benchmarks. Worth having because it makes your recall/QPS curve directly comparable to published ones — which is the external reference point you otherwise lack. ~500 MB.
Synthetic Data Done Properly
Most of this journey runs on generated data, so generating it badly corrupts a lot of results. Three rules, each learned the hard way during construction.
1. Measure the property you think you are varying. The clustered-vs-uniform generator in P02 initially produced two datasets that were statistically identical (RC 1.393 vs uniform's 1.356) because a Gaussian perturbation with σ√d = 2.0 swamped the unit-norm cluster centres. Ten minutes of measurement saved a worthless run.
2. Report the difficulty parameter alongside the result. For vectors that is relative contrast; for key-value workloads it is the Zipf exponent; for streams it is the lateness distribution. A recall number without RC, or a throughput number without the key distribution, is not comparable to anything — including your own result from last month.
3. Include an adversarial case. Uniform-random data is not the easy case, it is usually the hard case for retrieval and the easy case for storage. Generate both the realistic distribution and the pathological one, and report both.
Money
| Item | Cost | Necessary? |
|---|---|---|
| MIND, MovieLens, SIFT, GIST, Gutenberg, arXiv | $0 | — |
| All toolchains, QEMU, every tool in this track | $0 | — |
| Cloud GPU for P14's optional CUDA path | $20–40 | No |
| Domain + hosting for the blog | $0–15/yr | No — GitHub Pages is free |
| arXiv preprint | $0 | — |
| Trustworthy Online Controlled Experiments (Kohavi et al.) | ~$45 | The one book worth buying. Everything else on the reading list is free online |
| The Garbage Collection Handbook | ~$70 | No — chapters 2–3 and 9 are what you need; library or excerpt |
| Conference attendance | $500–2,000 | No. And often employer-funded — see Using Your Job |
Realistic total: $45 for one book, plus perhaps $40 of GPU time if you take P14's extension. Under $100.
The genuine cost of this journey is 1,430 hours. Everything else is rounding.
Storage and Housekeeping
Fifteen repositories over 34 months accumulate more than you expect.
| What | Size | Policy |
|---|---|---|
| Source, notebooks, reports | < 100 MB | Git, forever |
| Raw benchmark samples | ~1–5 GB total | Git, forever. You cannot recover a distribution from a p50 |
| P04 test data (>RAM working sets) | 20–100 GB | Regenerate; never commit |
| P02 indexes at n=10⁶ | ~1–3 GB | Regenerate; never commit |
| MIND / SIFT | ~2 GB | Outside git, one copy, shared across projects |
| P15 integration state | 10–50 GB | Regenerate |
Commit the raw samples and nothing else large. They are small, they are the evidence, and they are the one artifact that cannot be recreated once the machine changes.
Guard against toolchain rot. Pin versions from the first commit (the
scaffold does this via requirements.lock, Cargo.lock,
go.mod). In month 30 you will want to re-run a month-3 benchmark, and an unpinned
dependency makes that a research project of its own.
Acquisition Timeline
Nothing here is urgent, and two items are worth doing early because their value compounds or their failure modes are slow.
| Month | Action | Why then |
|---|---|---|
| M1 | Start the RSS harvest | Compounds. 30 minutes now, 20 months of real timestamped articles by P08 |
| M1 | Verify toolchains: cc, rust, go, python, make | Week-1 friction is the worst kind |
| M3 | Download SIFT1M | Before P02 in week 9 |
| M9 | Verify ≥120 GB free; check QEMU installs | Before P04 |
| M20 | Register for and download MIND | Before P09 in week 90, with slack for a licence problem |
| M26 | Install and verify the RISC-V toolchain and QEMU end to end | Before P12 in week 100. Boot something now |
| M28 | Decide the P14 GPU path; if cloud, create the account and run one job | Before P14 in week 111 |
| M31 | Decide the P15 question; acquire anything it specifically needs | Question locks in week 118 |
Two items in bold. The RSS harvest because it is the only thing on this page that gets better by starting early, and MIND because it is the only external dependency whose absence changes what research you can do.
References
- Wu, F. et al. MIND: A Large-scale Dataset for News Recommendation. ACL 2020. msnews.github.io — registration and licence terms.
- Jégou, H., Douze, M., Schmid, C. Product Quantization for Nearest Neighbor Search. IEEE TPAMI 33(1), 2011. Source of the SIFT1M/GIST1M benchmark corpora (corpus-texmex.irisa.fr).
- Aumüller, M., Bernhardsson, E., Faithfull, A. ANN-Benchmarks. Information Systems 87, 2020. The evaluation protocol and dataset conventions worth imitating.
- Harper, F. M., Konstan, J. A. The MovieLens Datasets: History and Context. ACM TiiS 5(4), 2015. The main fallback dataset, and an honest account of its biases.
- Clauset, A., Shalizi, C. R., Newman, M. E. J. Power-Law Distributions in Empirical Data. SIAM Review 51(4), 2009. How routinely synthetic power-law data is generated and fitted wrongly — relevant to every Zipfian workload you will generate.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012. Relative contrast, the difficulty parameter to report with synthetic vectors.
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth. ACM TOPLAS 38(4), 2016. On workload selection as a source of misleading evaluation.
What Not To Study Yet
A list of things to actively refuse, each with the condition that unlocks it.
This page exists because of a specific thing you said: "I frequently study advanced topics, but I tend to spread my attention across too many subjects and remain in knowledge-consumption mode." Every item below is genuinely interesting, and every one would feel like progress while being the opposite.
Refusing is a skill. The mechanism is: write the interest on the parking list, one line, and do not touch it. At each stage review, look at the list. Most entries will have stopped being interesting, which tells you what they were.
Table of Contents
- How To Use This List
- Not Yet — Machine Learning
- Not Yet — Systems
- Not Yet — Mathematics
- Not Yet — Tools and Infrastructure
- Not Ever, For This Journey
- The Meta-Rule
- References
How To Use This List
Three tests, applied when something new catches your attention:
- Does a project on the roadmap need it in the next 8 weeks? If no → parking list.
- Would learning it change what I build this month? If no → parking list.
- Am I drawn to it because it is interesting, or because the current milestone is hard? Be honest. The second is the most common reason to pick up a new topic, and it is the mechanism by which projects get abandoned.
Test 3 is the important one. Interest in a new subject spikes reliably around the point where the current project stops being fun — typically weeks 4–6 of a Medium project, which is exactly the debugging phase. That spike is not curiosity; it is avoidance wearing curiosity's clothes.
Not Yet — Machine Learning
| Topic | Why not yet | Unlocks |
|---|---|---|
| Scaling laws (Chinchilla, IsoFLOPs) | Meaningless until you can measure a training run's cost yourself | After P13-II, when you can compute FLOPs from your own graph |
| Mixture of Experts | An optimisation of a thing you have not built | After P01 and P13 both ship |
| RLHF, DPO, alignment training | Orthogonal to every mechanism here | Never, in this journey. A separate track |
| Quantization for LLMs | You will do quantization properly in P14 with roofline grounding | P14 (W111). Refuse until then |
| Distributed training (FSDP, tensor/pipeline parallel) | Requires P05's understanding and P13's framework | After P13-II and P05. A strong post-journey project |
| Diffusion models, VLMs, multimodal | Different architecture family; no mechanism here needs them | Never, here |
| Fine-tuning, LoRA, PEFT | You already use these professionally. Nothing new is exposed | Never, here |
| Prompt engineering, agents, RAG | Your day job. Zero marginal learning | Never, here |
| Neural architecture search | Requires a training budget you do not have | Never, here |
| Interpretability / mech interp | Genuinely fascinating; entirely orthogonal | After P01, as a separate track if you want it |
| Graph neural networks | P02 uses graphs for search, not for learning | Never, here |
| Reinforcement learning | P09 simulates users; it does not train agents | Only if P08's bandit extension |
The hardest refusal on this list is scaling laws, because it is the most intellectually satisfying ML topic and it is adjacent to everything. Refuse it until P13-II. The reason is specific: scaling-law work is entirely about the relationship between compute, data, and loss, and until you have built the thing that consumes the compute you are memorising other people's fitted constants. After P13-II you can compute \(C \approx 6ND\) from your own computation graph and check it, at which point the literature reads completely differently.
Not Yet — Systems
| Topic | Why not yet | Unlocks |
|---|---|---|
| Kubernetes internals, service meshes | Operations, not mechanism. You already operate these | Never, here |
| eBPF | A superb observability tool; a distraction from building your own instrumentation | After P12, when you know what it is instrumenting |
| io_uring | Its value is obvious only once you have measured syscall cost yourself | After P12's syscall measurement (W104) |
| Databases: query optimisation, SQL execution | P03 and P04 are storage engines, deliberately below the query layer | After P04. A legitimate next journey |
| Column stores, vectorised execution | Adjacent to P04 and genuinely tempting | After P04 ships. Parking list until then |
| Consensus beyond Raft (Paxos variants, EPaxos, Byzantine) | Learn one properly first | After P05. Then read Paxos and appreciate the difference |
| CRDTs and conflict-free replication | A different consistency model to the one P05 builds | After P05 |
| Formal methods, TLA+ | Would genuinely improve P05; a 40-hour skill inside its riskiest project | After P05, as a separate 2-week piece. See math.md |
| Network stacks, TCP internals, DPDK | No project needs packet-level work | Never, here |
| Compilers beyond P11 (SSA, register allocation, LLVM) | P11 stops at a bytecode VM on purpose | After P11-II. A strong follow-on |
| JIT compilation | The logical next step after P11-II, and a project in itself | After P11-II, as its own project |
| SMP kernel work | Multiplies P12's difficulty; P12 is single-core by design | After P12 |
| Security: sandboxing, capabilities, isolation | P12 does basic isolation only | After P12 |
| WebAssembly runtimes | Interesting, adjacent to P11, not needed | Never, here |
Two of these are scheduled rather than refused, and it is worth noting the difference. eBPF and io_uring both become dramatically more meaningful after P12, because both are answers to costs you will have measured. Reading about io_uring before measuring a 128 ns syscall is reading a solution to a problem you have not felt.
Not Yet — Mathematics
Covered in detail in math.md. The summary: measure theory, real analysis, convex optimization theory, category theory, information geometry, spectral graph theory, statistical learning theory, and Bayesian inference are all excluded, each because no project on the roadmap needs it.
The general principle: mathematics in this journey is a tool acquired at the moment of use. Sixty hours across 34 months, scheduled per topic, with a stated stopping point. A mathematics curriculum run in parallel would be a second journey, and running two is the failure mode.
Not Yet — Tools and Infrastructure
| Topic | Why not yet | Unlocks |
|---|---|---|
| A new editor / IDE / dotfiles overhaul | The most seductive form of procrastination in software | Never. Use what you use |
| A note-taking system (Obsidian, Zettelkasten, etc.) | notebook/ is markdown in git, plus the review queue. That is the system | Never |
| Nix, Bazel, containerised dev environments | Reproducibility here means pinned versions and a documented command | Only if a real reproducibility failure occurs |
| CI/CD for personal projects | A local make test is sufficient at this scale | If a project gains external contributors |
| A personal website or blog platform | Write in markdown; publish later. See portfolio.md | W20, when you have a first result worth publishing |
| Cloud infrastructure for experiments | Every project is designed to run on one laptop | Only P14, only if you need a GPU you do not have |
| Learning a fifth language | Four is already a lot of context switching | Never, here. See languages.md |
| Rewriting an earlier project in a nicer language | Explicitly forbidden | See the rewrite exceptions |
Dotfiles and tooling deserve their own warning. There is no faster way to convert a week of implementation into a week of nothing than deciding your setup needs improving first. If your editor works, it is good enough. If it does not, fix the one thing that is broken and stop.
Not Ever, For This Journey
Not because they are unworthy — because they belong to a different journey, and mixing them in would be the breadth failure this whole structure exists to prevent.
- Frontend, UI, UX work beyond a plot and a five-minute demo video
- Product management, market analysis, startup strategy
- Certifications of any kind
- Conference talks before you have a result — the portfolio plan has a sequence, and it starts with the artifact
- Open-source contribution to large projects — worthy, and a different time commitment. Your own repositories are the portfolio
- Competitive programming — a different skill with genuinely poor transfer to systems design
- Reading a systems paper a day — the single most seductive item on this page. Reading is capped at 15% and tied to milestones for a reason. A paper-a-day habit produces the feeling of expertise and none of the ability, and it is precisely the consumption mode you asked to escape
The Meta-Rule
Anything that makes you feel productive without producing a running artifact is a distraction, however sophisticated it looks.
Three specific patterns that pass as work and are not:
- Reading a paper about a system you are not building. Feels like learning; produces recognition rather than ability. You have done this for years and it is why you are reading this plan.
- Improving tooling for work you are not doing. A better benchmark harness for a benchmark you have not run.
- Planning the next project while the current one is unfinished. The most convincing form, because planning genuinely is work — just not this week's.
The test for all three: at the end of this session, will something exist that did not exist before, and does it run?
References
- Newport, C. So Good They Can't Ignore You. Business Plus, 2012. The craftsman mindset and the argument against following interest wherever it leads.
- Hamming, R. W. You and Your Research. Bell Communications Research, 1986. On the discipline of working on important problems rather than merely interesting ones, and the observation that the difference is mostly about what you decline.
- Ericsson, K. A. et al. The Role of Deliberate Practice in the Acquisition of Expert Performance. Psychological Review 100(3), 1993. Why targeted practice on a known weakness beats broad exposure.
- Bjork, R. A., Bjork, E. L. Desirable Difficulties in Theory and Practice. JARMAC 9(4), 2020. Why the easy, pleasant activity (reading) produces worse retention than the hard, unpleasant one (retrieval and construction).
- Sweller, J. Cognitive Load Theory. Springer, 2011. The capacity argument for one project at a time.
Portfolio and Publication Strategy
Fifteen projects produce fifteen repositories. That is a list, not a portfolio.
A portfolio is a small number of artifacts that are independently useful to someone who does not care about your learning journey, plus a narrative that connects them.
Table of Contents
- The Standard
- The Nine Artifact Types
- The Standalone Tools
- Publication Timeline
- How To Write the Posts
- The Comparison Matrices
- The Postmortems
- The Final Paper
- The Narrative
- What Not To Do
- References
The Standard
Every major project produces at least one artifact that remains useful independently of the journey.
The test: could a stranger find this, use it, and benefit — without knowing or caring that it came from a 34-month self-directed program?
| Fails the test | Passes the test |
|---|---|
| "My HNSW implementation" | "A benchmark harness that plots recall/QPS for any index exposing add/search" |
| "My distributed KV store" | "A deterministic, replayable fault injector for Go distributed systems" |
| "My notes on Bloom filters" | "Bloom filter theory vs measurement, with the code, and why 200k probes cannot resolve a 10⁻⁵ rate" |
The left column is a record of learning. The right column is a contribution. They often come from the same work; the difference is whether you extracted and packaged the part that generalises.
The Nine Artifact Types
| # | Artifact | Per project | Notes |
|---|---|---|---|
| 1 | Source repository | all 15 | One command to build, test, and reproduce the headline benchmark |
| 2 | Design document | all 15 | Written before implementation. STORAGE-FORMAT.md, LANGUAGE.md, USER-MODEL.md are the strongest examples |
| 3 | Architecture diagram | all 15 | Accurate, not aspirational. ASCII in the README is fine and ages better than an image |
| 4 | Benchmark report | all 15 | Raw data committed, not just summaries |
| 5 | Research notebook entries | ~80 total | The record of your reasoning improving |
| 6 | Failure analyses | ≥1 per project | Often the most-read thing you write |
| 7 | Technical articles | ~8 over the journey | See the timeline |
| 8 | Comparison matrices | 4 | Cross-project synthesis |
| 9 | Demonstration videos | 3 | P05, P12, P15 |
Plus, once: the final paper, five postmortems, and the narrative.
The Standalone Tools
The highest-value portfolio items in the whole journey, because they are used rather than read. Extract each into its own repository with its own README when the parent project ships.
| Tool | From | Why someone else wants it |
|---|---|---|
| ANN benchmark harness | P02 | Accepts any index with add/search, emits the standard recall/QPS curve, reports distance counts alongside wall clock. The distance-count column is what most harnesses lack |
| Crash-test harness | P03 | Randomised kill -9 at N points with acknowledged-write verification. Reusable against any storage engine |
| Deterministic fault injector | P05 | Drop, delay, duplicate, reorder, partition (including asymmetric), pause, corrupt — with replayable seeded schedules. The replay is the feature |
| Linearizability checker | P05 | Wing–Gong with pruning, over recorded histories |
| Batch-equivalence test harness | P07 | Verifies a streaming job against a batch oracle. Rare and genuinely useful |
| Systolic-array simulator | P14 | Cycle-accurate, reports MAC utilisation, validated against the TPUv1 TOPS derivation. Nothing small and readable exists here |
| Gradient-check harness | P13 | Finite differences against any autodiff implementation, across broadcast shape pairs |
bench.py, metrics.py, roofline.py | this track | Already standalone. Publish as one small library |
The fault injector and the linearizability checker are the two most likely to be used by strangers. Both solve a problem everyone building distributed systems has and few solve well, and both are small enough to read in an afternoon.
Publication Timeline
Publish early and repeatedly, not once at the end. Three reasons: external feedback catches errors while they are still cheap to fix; writing for an audience forces precision the private report does not; and a two-year silence followed by a paper is a worse strategy than eight posts building an audience.
| Week | Artifact | Type | Why then |
|---|---|---|---|
| W20 | "Bytecode is 1.5× slower than a tree-walk (in Python), and why" | Post | Your first counterintuitive measured result. Small, self-contained, verifiable |
| W26 | "What a Transformer costs: FLOPs and memory by sequence length" | Post | The cost model table with your own numbers. Widely useful, rarely written |
| W34 | "The filtered vector search cliff" | Post | P03's E3. Directly useful to anyone running filtered ANN in production — a large audience |
| W43 | "Read, write, space: measuring the LSM trilemma" | Post + tool | P04's E3 crossover figure. The strongest early systems piece |
| W50 | Extract and publish the standalone tools | Tools | After Stage 2, when several exist |
| W67 | "A replayable fault injector, and the bug it found in my Raft" | Post + tool | P05's real bug with its interleaving diagram. This is the post that establishes credibility |
| W83 | Video: "Building a distributed system that survives its own fault injector" | Video | Stage 3 synthesis |
| W99 | "When offline metrics fail to predict online outcomes" | Post | P10's E12. Your professional domain, a widely-argued and rarely-measured question |
| W110 | "What a syscall, a page fault, and a context switch actually cost" | Post | P12's MEASUREMENTS.md. Perennially useful reference material |
| W117 | "Why specialised hardware wins: 1.9 to 1,679 GFLOP/s on one laptop" | Post | P14's measured progression. The most striking single result in the journey |
| W126 | Preprint of the final paper | arXiv | Before the polish; timestamps the work |
| W130 | Paper, demonstration video, and the narrative | All | Completion |
Start with W20 even though it is a small result. The habit of publishing is what matters, and a small correct post is a better first publication than a large one you never finish. The bytecode result is ideal: counterintuitive, fully reproducible in one file, and it teaches something real.
How To Write the Posts
Systems posts that get read share a structure. Use it.
- Lead with the number. Not the context, not the motivation — the surprising measurement, in the first two sentences. "A bytecode VM I wrote is 1.5× slower than the tree-walk interpreter it replaced. Both produce identical results to fifteen significant figures."
- State the setup precisely enough to be attacked. Hardware, versions, workload, command. A post whose setup cannot be criticised cannot be believed.
- Show the mechanism, not just the effect. The distinguishing feature of a good systems post is that it explains why, with a secondary measurement that closes the argument. Recall and latency alone say "it plateaus"; the distance counter says "6.9× fewer operations at 67.5× the cost each, predicting 0.10× and measuring 0.10×".
- Include the code. A single runnable file beats a repository beats a snippet.
- Say what you got wrong. The most-shared systems posts contain a mistake the author found themselves. It is also the most credible thing you can write.
- Name the limits. One paragraph: what this does not show, and where it would not hold.
Length: 1,200–2,500 words. Longer than that and the measurement gets buried; shorter and there is no room for the mechanism.
Where to publish: your own site, with cross-posts wherever your audience is. Own the canonical URL — platforms disappear and a two-year body of work should not depend on one.
The Comparison Matrices
Four cross-project syntheses. These are the artifacts that demonstrate systems thinking rather than component knowledge, and they are what distinguishes this portfolio from fifteen unrelated repos.
1. The amplification matrix (after P04) Every storage decision in P03 and P04 against read, write, and space amplification, with your measured numbers next to the derived ones.
2. The consistency/availability matrix (after P07) Every consistency choice across P03, P05, P07: what it guarantees, what it costs in latency, what it does during a partition, and which of your systems chose it.
3. The "where the time went" matrix (after P14) The synthesis of the whole journey. One table:
| System | Bottleneck | Class | Ratio | Fix | What the fix cost |
|---|---|---|---|---|---|
| P02 ANN | interpreter dispatch per distance | constant factor | 67.5× | compiled inner loop | build complexity |
| P11 VM | host-language dispatch per opcode | constant factor | 1.5× slower | compile the VM | — |
| P13 framework | op dispatch below the crossover size | constant factor | — | fusion, graph mode | correctness surface |
| P14 matmul | operand delivery / cache | data movement | 18.6× | tiling + vectorisation | portability |
| P14 vs BLAS | general-purpose datapath | architecture | 29× | specialised silicon | inflexibility |
| P12 syscall | mode switch | boundary crossing | 410× vs a loop iteration | batching interfaces | API complexity |
The pattern this table makes visible: across six different systems in four languages, the bottleneck was almost never the algorithm. It was operand delivery, dispatch, or a boundary crossing. Writing that sentence with six of your own measurements behind it is the single most valuable page in the portfolio.
4. The evaluation-methods matrix (after P10) Offline replay, simulation, and A/B testing: what each can and cannot establish, what each costs, and where your own three methods disagreed.
The Postmortems
One per stage, five total, written at the stage reviews. Plus one per abandoned project, if any.
Not self-criticism. A technical document:
# Stage N Postmortem
WHAT I SET OUT TO DO
WHAT I ACTUALLY BUILT (with the gap named)
WHAT TOOK LONGER THAN PLANNED (and the estimate error, as a ratio)
THE BUG THAT COST THE MOST (and how I would catch it earlier)
WHAT I PREDICTED WRONG (with the pattern across predictions, if there is one)
WHAT I AVOIDED BECAUSE IT WAS HARD
WHAT I WOULD DO DIFFERENTLY
WHAT I NOW KNOW THAT I DID NOT
The abandoned-project postmortem matters most, and its existence is the difference between a completed learning experience and a wound. Write it the week you stop, not later.
The Final Paper
6,000–10,000 words, following templates/report.md's long form.
Full requirements on P15.
The three sections that decide whether it is credible:
- Threats to validity — written before you are asked. Every measurement that could mislead, every assumption you did not verify, every scale you did not test.
- Limitations — what the work does not show. Specific, not modest-sounding.
- Reproducibility appendix — verified by someone who is not you.
The abstract must contain a number. An abstract without one is a description of activity.
Publish the preprint at W126, before the polish. It timestamps the work, it is free, and the version-of-record can come later. See Research Directions for venue options — and note that for systems work, a well-measured post with reproducible code often reaches more of the relevant audience than a workshop paper.
The Narrative
The last artifact, written at W130. One page — a README for the whole journey — that answers:
- What was the goal? Not "learn systems". The specific capability.
- What did I build? Fifteen systems, one table, one line each.
- What did I learn that I could not have learned by reading? The three or four results that required building. The bytecode sign-flip. The ANN constant-factor decomposition. Bélády's anomaly reproduced. The 29× gap to a matrix coprocessor.
- What was I wrong about? The prediction-accuracy record across ~80 notebook entries is a genuinely unusual thing to be able to report.
- What would I do differently? From the five postmortems.
- What is next? From Research Directions.
This is the page you link to, and the fifteen repositories hang off it. Without it, a reader sees a list of projects and has to construct the story themselves, which they will not do.
What Not To Do
| Anti-pattern | Why |
|---|---|
| Publishing nothing until the end | Two years of silence, no feedback, no error correction while it is cheap |
| Publishing a repo with no README | Nobody will read the code. The README is the artifact for most readers |
| Claiming a number you cannot reproduce | One unreproducible claim discredits every other number you have published |
| Comparing against a straw-man baseline | Reviewers and readers notice immediately, and it is the fastest way to lose credibility |
| Hiding the results where you lost | P02 losing to hnswlib by 20× is interesting and honest. Concealing it is neither |
| Writing for recruiters | Write for an engineer with the same problem. That readership is more useful and more durable |
| A portfolio site before there is work | See Not Yet. Markdown in a repo is sufficient until W20 |
| Fifteen equally-weighted repos | Three excellent artifacts with a narrative beat fifteen undifferentiated ones |
References
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004.
- Zobel, J. Writing for Computer Science, 3rd ed. Springer, 2014.
- Shewchuk, J. R. Three Sins of Authors in Computer Science and Math. 1997.
- Collberg, C., Proebsting, T. A. Repeatability in Computer Systems Research. CACM 59(3), 2016.
- Hoefler, T., Belli, R. Scientific Benchmarking of Parallel Computing Systems. SC 2015.
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth. ACM TOPLAS 38(4), 2016.
- Bailey, D. H. Twelve Ways to Fool the Masses When Giving Performance Results on Parallel Computers. Supercomputing Review, 1991. Read it as a list of things not to publish.
- Wilson, G. et al. Best Practices for Scientific Computing. PLoS Biology 12(1), 2014.
- Ousterhout, J. Always Measure One Level Deeper. CACM 61(7), 2018. Why the mechanism matters more than the effect — the basis for point 3.
Projects That Can Become Original Research
Seven directions that emerge from this journey's projects and could produce a genuine contribution — a workshop paper, an industry-track submission, a well-cited blog post, or an open-source tool people use.
Calibration first. "Original research" here does not mean a NeurIPS oral. It means: a question nobody has answered, an experiment that answers it, and a write-up honest enough that someone else can build on it. Most of the value of these seven is that they are achievable by one person with a laptop and a well-scoped question — which is exactly what the fifteen projects prepare you to do.
Maintain this page as you go. At each stage review, add anything you saw that might be novel. One line each. Do not chase it then; chase it in Stage 6 or after.
Table of Contents
- How To Tell If Something Is Novel
- D1 — Adaptive ANN Search Policy
- D2 — Connectivity-Preserving Graph Indexes for Clustered Data
- D3 — Adaptive Compaction
- D4 — Simulator Validity for Recommender Evaluation
- D5 — Freshness as a First-Class Systems Metric
- D6 — Watermark-Delay Auto-Tuning
- D7 — Adaptive Task Sizing Instead of Speculation
- Ranking Them
- Where To Publish
- References
How To Tell If Something Is Novel
Before investing, run this four-step check. It takes an afternoon and it saves months.
- Search properly. Google Scholar, DBLP, arXiv, and — critically — the related work sections of the three most recent papers in the area. If it is obvious, it has been done; your job is to find out how and what they missed.
- Search for the negative result too. Many good ideas have been tried and did not work, and that is often unpublished. Check the issue trackers and mailing lists of the relevant open-source projects. A closed issue saying "we tried this, it did not help, here is why" is worth more than a paper.
- Ask what would make it not-novel. If a well-known system already does this internally (and it often does), your contribution is the measurement, not the idea. That is still publishable — as an evaluation paper — but it is a different paper.
- Write the abstract first. If you cannot write a 150-word abstract with a number in it, the question is not sharp enough yet.
A note on the honest case. The most likely outcome for each of these is: someone has explored it, your version adds a careful measurement in a specific regime, and the result is a good blog post rather than a paper. That is a fine outcome and it is worth doing. The failure is not "it turned out not to be novel" — it is spending six months before checking.
D1 — Adaptive ANN Search Policy
From: P02 extension, P15 candidate question Q2.
The question. efSearch is fixed per index. But queries differ enormously in
difficulty — some land in a dense, well-connected region and converge in 200 distance
computations; others land near a boundary and need 4,000. Can you predict per-query
difficulty from the first few hops and set the beam width per query, achieving the same
mean recall at lower mean latency?
Why it is plausible. Your own P02 data shows the raw material:
recall is strongly concave in efSearch, and
per-query recall variance (E10) is large. If 80% of queries reach target recall at
ef=32 and 20% need ef=256, a fixed ef=128 overspends on most and underspends on the rest.
The signal to use. Candidate features available after ~20 hops: the distance to the best result so far, the rate of improvement over the last k hops, the variance of distances in the current beam, and the number of times the beam's worst element has been replaced. Cheap, all of them.
The experiment. Fixed ef sweep as the baseline curve. Then adaptive policy with a budget matched to each fixed point. The claim is Pareto dominance: at equal mean recall, lower mean and p99 latency. Report p99 specifically — an adaptive policy that improves the mean by making hard queries much worse is not an improvement.
The falsifier. Adaptive fails to dominate a well-tuned fixed ef on the Pareto frontier. Note that the honest baseline is well-tuned fixed ef, not a badly chosen one.
Prior art to check: learned index tuning, early-termination criteria in ANN search, adaptive query processing in databases (which has decades of literature and is the right place to look for how this goes wrong).
Difficulty: medium. Novelty: medium — early termination exists; per-query budget prediction with a p99 constraint is less explored.
D2 — Connectivity-Preserving Graph Indexes for Clustered Data
From: P02's E7 and the worked notebook entry.
The question. The measured result: on clustered data (RC 3.36), single-layer NSW hits a recall ceiling of 0.967 at ef=256 while uniform data reaches 0.993 — and the clustered search evaluates only 840 distances versus 4,059, because it runs out of reachable candidates. The mechanism is that a distance-based degree cap deterministically deletes every inter-cluster bridge (intra-cluster distance 0.521, inter-cluster 1.413).
HNSW's neighbour-selection heuristic addresses this. How completely? And is there a better rule for strongly-modal data specifically?
Why it matters practically. Real embedding corpora are strongly modal — multilingual
spaces, category-structured catalogues, near-duplicate clusters. The failure mode is
specific and nasty: recall looks fine on a uniform synthetic benchmark and degrades on
the real corpus, in a way that increasing efSearch does not fix. That is a falsifiable
claim about deployed systems.
The experiment. Sweep modality (number of clusters × separation, using RC as the axis rather than \(d\)). For each, compare: naive M-nearest pruning, HNSW's Algorithm 4, and a candidate rule that explicitly reserves a fraction of each node's degree budget for edges crossing a detected boundary. Measure the recall ceiling, the inter-cluster edge fraction, and the distance count at saturation.
The instrument that makes it credible: inter-cluster edge fraction before and after pruning. That is the mechanism metric, and it is what turns "recall is lower" into "the bridges were deleted".
The falsifier. Algorithm 4 already preserves enough connectivity that the reserved- budget rule adds nothing across the whole RC range.
Prior art: Filtered-DiskANN, and the HNSW paper's own §4 discussion. Check whether the ANN-Benchmarks datasets span enough modality to have surfaced this.
Difficulty: medium. Novelty: medium-high — the characterisation by RC is the part most likely to be new, since most ANN evaluation reports \(d\) and dataset name rather than a difficulty measure.
D3 — Adaptive Compaction
From: P04 extension.
The question. Leveled and size-tiered compaction sit at opposite corners of the read/write/space trade — at T=10 and 64 GB, leveled costs W/R/S of 31/4/1.10 and size-tiered 4/30/2.11. Real workloads shift: bulk ingest, then read-heavy serving, then a re-index. Can an engine observe its own read/write ratio and switch strategy, beating either fixed choice on a realistic shifting workload?
Why it is plausible. The two strategies differ by an order of magnitude in opposite directions, so the potential win is large. RocksDB exposes both and lets an operator choose; it does not choose for you.
The hard part, which is the actual research content. Switching is not free — it requires rewriting data into the new layout, so a policy that switches too eagerly pays migration cost repeatedly. The question becomes a hysteresis problem: how much evidence of a workload shift justifies a migration whose cost you can estimate? That framing is more interesting than the switching itself.
The experiment. A workload generator with phase transitions (ingest → serve → ingest) of varying frequency. Compare: fixed leveled, fixed size-tiered, an oracle that switches with perfect foresight, and your online policy. The oracle is the key baseline — it bounds the achievable gain and tells you whether the online problem is even worth solving.
The falsifier. The oracle's gain over the better fixed strategy is small, in which case no online policy can matter.
Prior art: Dostoevsky and the LSM-tuning literature (Dayan & Idreos); RocksDB's compaction-style options; adaptive indexing / database cracking.
Difficulty: medium-high. Novelty: medium — tuning is well studied; online switching with migration cost less so.
D4 — Simulator Validity for Recommender Evaluation
From: P09 + P10. Also P15 candidate question Q4.
The question. Recommender simulators are widely used (RecSim, RecoGym) and rarely validated. Under what conditions does a simulated user population correctly rank real algorithms? Not "is the simulator realistic" — that is unanswerable — but the useful version: which properties must the user model have for its ordinal conclusions to transfer?
Why this is the most scientifically valuable direction here. It is a methodological result. If you can show that ordinal fidelity depends on a small number of user-model properties (say, position bias and topic fatigue) and is insensitive to the rest, that is a genuinely useful finding for everyone who builds these simulators. And if you show it does not transfer, that is arguably more valuable and much less comfortable.
The experiment. You already have the machinery: P08's offline rankings, P09's simulated rankings, P10's rank correlation (E12), and P09's sensitivity analysis (E11). The research version adds a third leg — real interaction data, even a public dataset — and asks which of the three rankings agree.
The falsifier. Ordinal agreement is unstable across user-model parameters, with no identifiable subset of properties controlling it. This is the most likely outcome, and it is publishable as a negative result — "simulator-based recommender evaluation does not produce stable rankings under plausible model variation" is a useful thing for the field to know.
The honest risk: without real data you can compare P08-offline to P09-simulated, and neither is ground truth, so you get a disagreement without an arbiter. Securing a real validation set is the gating step. MIND (Microsoft News) is the obvious candidate and is in your domain.
Difficulty: medium. Novelty: high. Data risk: high.
D5 — Freshness as a First-Class Systems Metric
From: P03 + P04 + P07 + P08. P15 candidate question Q5.
The question. In news recommendation, an article's value decays in hours. Every storage and indexing decision — segment size, compaction schedule, index rebuild cadence, watermark delay — adds latency between publication and recommendability. Nobody measures that end-to-end, and nobody optimises for it. What is the publication-to-recommendable latency distribution of a realistic stack, where does it come from, and what is the quality cost of each contribution?
Why it is a real gap. Storage papers report throughput and amplification. Recommender papers report NDCG. The composition — how much recommendation quality is lost to indexing latency — falls between the two literatures, which is exactly where under-studied questions live.
The experiment. Instrument the full P15 pipeline for freshness. Decompose the publication-to-recommendable latency by stage: ingestion, embedding, index insert, segment flush, index visibility. Then sweep the parameters that trade freshness against efficiency (segment size, flush interval, batch size) and measure recommendation quality at each point. The output is a freshness/efficiency Pareto frontier with quality contours — a figure that does not currently exist.
The falsifier. Quality is insensitive to freshness in the range achievable by parameter tuning, i.e. everything is fast enough already and the question is moot.
What makes it credible: the decomposition. A single end-to-end number is not a result; "62% of publication-to-recommendable latency is index segment flush, and halving the segment size cuts it by X at Y cost in query latency" is.
Difficulty: high (needs the most of the stack). Novelty: high — genuinely cross-cutting.
D6 — Watermark-Delay Auto-Tuning
From: P07 extension.
The question. Watermark delay \(\delta\) is a fixed constant chosen by an operator, trading completeness against latency. The lateness distribution is observable at runtime. Can \(\delta\) be tuned online to hold a stated completeness SLO — "99% of events included in their correct window" — at minimum latency?
Why it is plausible. The lateness distribution is directly measurable and usually stable over hours but variable across days (mobile-sync patterns, batch uploads, regional traffic). A fixed \(\delta\) must be set for the worst case, so it over-delays most of the time.
The hard part. Watermarks must be monotonic, so you can lengthen the delay freely but shortening it is constrained — you cannot un-emit a watermark. That asymmetry makes the control problem interesting rather than trivial, and it is where the contribution is.
The experiment. Replay realistic lateness distributions (including regime changes). Compare fixed \(\delta\) at several values, an oracle with perfect foresight, and the adaptive policy. Metrics: completeness achieved vs SLO, mean and p99 emission latency, SLO violation rate during regime change.
The falsifier. The adaptive policy cannot beat a fixed \(\delta\) set at the observed 99th percentile of lateness — which is a strong and simple baseline.
Prior art: Dataflow's watermark discussion; Flink's watermark strategies; check whether the streaming-systems literature already has adaptive proposals (it has some).
Difficulty: medium. Novelty: medium.
D7 — Adaptive Task Sizing Instead of Speculation
From: P06 extension.
The question. MapReduce handles stragglers by duplicating slow tasks, which wastes the work already done and consumes capacity. An alternative: detect a slow task and split its remaining work among idle workers. Under what conditions does splitting beat duplication?
Why it is plausible. Duplication wastes up to 2× the task's work by construction. Your own simulation shows the stakes: 1% of tasks at 50× slowness inflates job time 4.48×, and backup tasks recover it to 1.09×. If splitting recovers the same and costs half the wasted CPU, that is a real result on cluster efficiency rather than latency.
The hard part. Splitting requires tasks to be resumable — the remaining input must be identifiable and the partial output combinable. That is a stronger contract than MapReduce requires, and characterising exactly what contract is needed is part of the contribution. It also connects directly to the project's own thesis about restriction buying automation.
The experiment. Straggler injection matrix (frequency × severity × cause: slow CPU vs slow disk vs contention). Compare: no mitigation, duplication, splitting, and both. Metrics: job completion time and wasted CPU-seconds, because the claim is about the second.
The falsifier. Split overhead exceeds the saving except in a narrow regime.
Prior art: SkewTune (which does something close for skew rather than stragglers), the LATE scheduler, Dryad's dynamic refinement. Check SkewTune carefully — this may already be it, in which case the contribution is the comparison against duplication under straggler (not skew) conditions.
Difficulty: medium-high. Novelty: low-medium — check prior art first, seriously.
Ranking Them
If you pursue one after the journey, this is the order.
| Rank | Direction | Reason |
|---|---|---|
| 1 | D2 — connectivity-preserving graph indexes | You already have the measured anomaly, the mechanism, and the instrument. Lowest distance from where you will be standing |
| 2 | D5 — freshness as a systems metric | Genuinely cross-cutting, genuinely under-studied, and squarely your professional domain |
| 3 | D1 — adaptive ANN search | Clean, self-contained, immediately useful. Also the best P15 question |
| 4 | D4 — simulator validity | Highest scientific value, highest data risk |
| 5 | D3 — adaptive compaction | Solid; the oracle baseline may reveal the ceiling is low |
| 6 | D6 — watermark auto-tuning | Neat, narrow, probably partially done |
| 7 | D7 — adaptive task sizing | Check SkewTune before anything else |
D2 is first for a reason worth stating. It is not the most important question on the list — D5 probably is. It is first because you will finish P02 with the anomaly already measured, the mechanism already diagnosed, and the next experiment already written down in your notebook. The gap between "interesting idea" and "running experiment" is where research dies, and D2 has no gap.
Where To Publish
Ordered by effort, not by prestige. Each step is a legitimate destination, not merely a stepping stone.
| Venue | Effort | Fit |
|---|---|---|
| A technical blog post with reproducible code | Low | Every one of these. Do this first, always. It forces the writing and gets feedback |
| Workshop papers (VLDB workshops, MLSys workshops, RecSys LBR) | Medium | D1, D2, D6 — small, sharp, measured results |
| Industry tracks (VLDB Industrial, SIGMOD Industrial, RecSys Industry) | Medium-high | D3, D5 — practical systems results with real measurements |
| arXiv preprint | Low-medium | Anything. Timestamps the work; costs a weekend |
| Full conference (SIGMOD, VLDB, NSDI, MLSys, RecSys) | High | Realistically only D4 or D5, and only with a strong result and probably a collaborator |
| An open-source tool people use | Medium | The fault injector, the linearizability checker, the ANN benchmark harness, the systolic simulator. Often more impactful than a paper |
Do not skip the blog post. It is the cheapest way to discover whether the result survives contact with readers, it produces the writing you would need anyway, and for systems work a well-measured post with reproducible code frequently reaches more of the relevant audience than a workshop paper does. See portfolio.md for the publication sequence.
References
- Hamming, R. W. You and Your Research. Bell Communications Research, 1986. On choosing important problems — the framing for ranking them.
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004. Write the abstract first; the check in step 4.
- Malkov, Y. A., Yashunin, D. A. HNSW. IEEE TPAMI 42(4), 2020. — D1, D2
- Gollapudi, S. et al. Filtered-DiskANN. WWW 2023. — D2
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012. — D2's RC axis
- Dayan, N., Idreos, S. Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores. SIGMOD 2018. — D3
- Idreos, S., Kersten, M. L., Manegold, S. Database Cracking. CIDR 2007. — D3's adaptive-indexing precedent
- Chaney, A. J. B., Stewart, B. M., Engelhardt, B. E. How Algorithmic Confounding in Recommendation Systems Increases Homogeneity and Decreases Utility. RecSys 2018. — D4
- Ie, E. et al. RecSim. arXiv:1909.04847, 2019. — D4
- Wu, F. et al. MIND: A Large-scale Dataset for News Recommendation. ACL 2020. — D4's validation data
- Akidau, T. et al. The Dataflow Model. VLDB 2015. — D6
- Kwon, Y. et al. SkewTune: Mitigating Skew in MapReduce Applications. SIGMOD 2012. — D7. Read this before starting D7
- Zaharia, M. et al. Improving MapReduce Performance in Heterogeneous Environments. OSDI 2008. — D7
The Integrated Final System
Project 15's architecture, its six candidate research questions with full evaluation designs, and the rule that governs all of them.
Project specification, schedule and milestones: P15.
Table of Contents
- The Governing Rule
- The Full Architecture
- What Each Component Contributes
- The Six Candidate Questions
- Choosing Between Them
- The Evaluation Standard
- Integration Hazards
- References
The Governing Rule
The integrated system must not merely connect components. It must answer a specific research or engineering question.
The distinction is not rhetorical, and it has a mechanical consequence:
A system built to demonstrate includes every component you built, because leaving one out looks like a gap. It is assessed on whether it works.
A system built to answer a question includes only the components the question needs, because the others are latency, complexity, and risk with no evidentiary value. It is assessed on whether the answer is credible.
Those two systems look different, and you must decide which you are building in week 118 — before the integration code exists, because after that the sunk cost decides for you.
The practical test: for each component, ask "if I removed this, would the answer to my question change?" If no, it is not in the system. Write that list in milestone 2 and treat additions as requiring a written justification.
The Full Architecture
The complete pipeline. You will build a subset. Components are labelled with the project that produced them.
┌─────────────────────────────────────────────────────────────────────────┐
│ INGESTION │
│ article source ──► partitioned log ──► watermarks ──► checkpoints │
│ [P07 streaming] │
└───────────────────────────────┬─────────────────────────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌────────────────┐
│ item │ │ interaction │ │ embedding │
│ store │ │ log │ │ service │
│ [P04 LSM]│ │ [P05/P07 log]│ │ [P01 model on │
│ │ │ │ │ P13 framework,│
│ │ │ │ │ P14 batching] │
└────┬─────┘ └──────┬───────┘ └───────┬────────┘
│ │ │
│ │ ▼
│ │ ┌────────────────────────┐
│ │ │ index writer ──► ANN │
│ │ │ index [P02] inside │
│ │ │ vector DB [P03]: │
│ │ │ segments · filters · │
│ │ │ snapshots · compaction │
│ │ └───────────┬────────────┘
│ │ │
└─────────────────────────┴──────────────────────────┘
│
▼
┌──────────────────────────────┐
request ──► │ recommendation service [P08] │
│ retrieve → filter → rank │
│ → diversify → dedupe │
└───────────────┬──────────────┘
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌────────────────────┐
│ simulated │ │ A/B assignment│ │ observability │
│ users [P09] │ │ + analysis │ │ tracing · metrics │
│ │ │ [P10] │ │ fault injection │
│ │ │ │ │ [P05 injector] │
└─────────────┘ └───────────────┘ └────────────────────┘
What Each Component Contributes
| Component | From | What it gives P15 | Which questions need it |
|---|---|---|---|
| Streaming ingestion | P07 | Controllable ingestion latency; watermarks; the ability to inject staleness | Q1, Q5 |
| LSM item store | P04 | Durable item storage; compaction as a tunable freshness cost | Q5 |
| Interaction log | P05/P07 | Replayable feedback; the substrate for profile updates | Q1, Q4 |
| Embedding service | P01+P13+P14 | Dynamic vs precomputed embeddings; a batching policy to vary | Q3, Q6 |
| ANN index | P02 | Retrieval, with efSearch as the adaptive knob | Q1, Q2, Q3 |
| Vector DB | P03 | Segments, filters, snapshots — where index staleness physically lives | Q2, Q3, Q5 |
| Recommendation service | P08 | The ranking pipeline under test | all |
| Simulated users | P09 | Ground truth, controllable drift, counterfactual evaluation | Q1, Q4 |
| A/B platform | P10 | Assignment, CIs, guardrails, the offline/online comparison | Q1, Q4 |
| Observability | P05 | Per-stage tracing — the thing that makes any latency claim credible | all |
Observability is on every row. A P15 without distributed tracing produces end-to-end numbers you cannot decompose, and an undecomposed number is a benchmark result rather than a finding. Build it at milestone 4, before hardening anything.
The Six Candidate Questions
Each is stated as a claim with a falsifier, because a question whose answer cannot be "no" is not a research question.
Q1 — How should a recency-sensitive recommender adapt when user interests drift?
Claim. There is a relationship between measured drift rate \(\eta\) and the optimal EMA decay \(\alpha\), and a recommender that estimates \(\eta\) per user and sets \(\alpha\) accordingly beats any fixed \(\alpha\) on a population with heterogeneous drift.
Falsifier. A single well-chosen fixed \(\alpha\) matches the adaptive policy within confidence intervals across the whole drift distribution.
Needs: P02, P03, P08, P09, P10.
Design. P09 generates a population with known per-user \(\eta\), drawn from a realistic heterogeneous distribution. Arms: fixed \(\alpha\) at five values; oracle \(\alpha\) (using true \(\eta\), an upper bound); adaptive \(\alpha\) estimated from observed profile displacement. The oracle arm is essential — it tells you the ceiling, and if the oracle barely beats the best fixed \(\alpha\), the adaptive question is dead and you have saved yourself six weeks.
Metrics. NDCG and simulated engagement per drift decile; adaptation lag after an induced interest change; stability.
Risk. The answer may be "α≈0.1 is fine for everyone", which is a real answer and a dull paper. The oracle arm surfaces this in week 1 of the experiment rather than week 10.
Q2 — Can an adaptive ANN search policy reduce latency while preserving recall?
Claim. A policy that predicts per-query difficulty from early search signals and sets
efSearch per query achieves Pareto dominance over fixed efSearch: equal mean
recall@10 at lower mean and p99 latency.
Falsifier. The adaptive policy fails to dominate a well-tuned fixed efSearch
anywhere on the frontier — or improves the mean while degrading p99, which is not a win.
Needs: P02, P03, P08. The smallest component set of any question here.
Design. Signals available after ~20 hops: current best distance, improvement rate
over the last k hops, beam distance variance, beam-replacement count. Train a small
predictor (a decision tree is fine and is more interpretable than anything larger) on a
held-out query set. Baseline: the full fixed-efSearch sweep, which is your P02 curve.
Ablate each signal.
Metrics. recall@10, mean and p99 latency, distance computations per query, predictor overhead — which must be counted against the saving.
Why it is a strong choice. Self-contained, cleanly measurable, needs no external data, and it is research direction D1 so the work has a life after the journey.
Q3 — When does dynamic embedding generation outperform precomputed embeddings?
Claim. There is a crossover in catalogue-turnover rate above which generating embeddings on demand beats precomputing them, and the crossover is predictable from model cost, cache hit rate, and turnover rate.
Falsifier. Precomputation dominates at every realistic turnover rate.
Needs: P01, P13, P14, P02, P03, P08. The most components — which is a risk, not a virtue.
Design. Sweep turnover rate and query distribution skew. Arms: full precompute; on-demand with an LRU cache; hybrid (precompute the head, generate the tail). Cost accounting must be scrupulous — compute, storage, and latency for each arm, or the comparison is meaningless.
Metrics. Freshness, p99 latency, compute cost per recommendation, storage, cache hit rate.
Risk. The comparison is only as good as the cost model. Two arms with different resource profiles need an explicit exchange rate between compute and storage, and choosing it is a judgement call you must defend in the paper.
Q4 — Can simulated users predict the relative performance of ranking algorithms?
Claim. Simulated-user evaluation produces the same ordinal ranking of algorithms as real-data offline evaluation, and the agreement depends on a small identifiable set of user-model properties.
Falsifier. Ordinal agreement is unstable under plausible user-model variation, with no identifiable subset of properties controlling it.
Needs: P08, P09, P10, plus a real interaction dataset (MIND is the obvious candidate and is in your domain).
Design. Rank ≥15 algorithm variants three ways: offline replay on real logs, simulated-user evaluation, and — ideally — real online data if you can obtain any. Rank- correlate all pairs. Then run P09's sensitivity analysis to find which user-model parameters change the correlation.
Metrics. Spearman and Kendall correlation between rankings; per-variant disagreement; sensitivity of the correlation to each parameter.
Why it is the best science here. It is a methodological result about a widely used and rarely validated technique, and the negative outcome is as publishable as the positive one — "simulator rankings are unstable under plausible model variation" is something the field would benefit from knowing.
Risk. Highest data dependency of the six. Confirm dataset access in week 118, not week 125.
Q5 — How do storage and indexing choices affect recommendation freshness?
Claim. Publication-to-recommendable latency is dominated by a small number of identifiable stages, and there is a freshness/efficiency frontier along which recommendation quality varies measurably.
Falsifier. Quality is insensitive to freshness across the whole range achievable by parameter tuning.
Needs: P03, P04, P05, P07, P08. Heaviest on the systems stack, lightest on ML.
Design. Instrument every stage from publication to recommendable. Sweep the parameters that trade freshness against efficiency: segment size, flush interval, compaction trigger, index rebuild cadence, watermark delay, embedding batch size. At each point, measure both the freshness distribution and recommendation quality.
Metrics. Publication-to-recommendable latency decomposed by stage (this is the result); recommendation quality vs freshness; throughput and storage cost at each point.
Why it is strong. It is research direction D5, it sits in a genuine gap between the storage and recommender literatures, and it is directly your professional domain. The deliverable figure — a freshness/efficiency frontier with quality contours — does not currently exist anywhere.
Q6 — Can hardware-aware batching substantially reduce end-to-end embedding latency?
Claim. A batching policy informed by the roofline model (batch until the arithmetic-intensity ridge, subject to a latency budget) beats both fixed-size batching and no batching on the throughput/p99 frontier.
Falsifier. A simple fixed batch size, tuned once, matches the adaptive policy.
Needs: P01, P13, P14, P07.
Design. The theory is already in tools/roofline.py: decode
arithmetic intensity equals batch size, and on an H100 at bf16 the ridge is at batch 295.
Arms: no batching; fixed batch at several sizes; adaptive batching with a latency
deadline; the roofline-informed policy. Sweep arrival rate and burstiness.
Metrics. Throughput, p50/p99 latency, achieved MFU, queue depth.
Why it is the weakest of the six as research: it is closest to known engineering — continuous batching is what every serving system already does. Why it is still a good choice: it is the most likely to produce a clean, correct, well-measured result, and if what you want from P15 is a defensible finished artifact rather than a novel one, this is the safe pick.
Choosing Between Them
| Q1 drift | Q2 adaptive ANN | Q3 dynamic embed | Q4 simulator | Q5 freshness | Q6 batching | |
|---|---|---|---|---|---|---|
| Components needed | 5 | 3 | 6 | 3 + data | 5 | 4 |
| Integration risk | med | low | high | low | high | med |
| External data needed | no | no | no | yes | no | no |
| Novelty | med | med-high | med | high | high | low |
| Likelihood of a clean result | med | high | med | low | med | high |
| Post-journey life | D— | D1 | — | D4 | D5 | — |
| Relevance to your work | high | high | high | high | highest | med |
The recommendation: Q2 or Q5.
Q2 if you want the highest probability of a clean, complete, defensible result. Three components, no external data, a well-defined baseline curve you already have from P02, and a claim that is easy to state and hard to fudge. It is the low-variance choice and there is nothing wrong with that after 117 weeks.
Q5 if you want the most interesting question and can accept more risk. It uses the systems stack you spent Stage 2 and 3 building, it sits in a real gap in the literature, and it is the one most likely to change how you think about your own production systems. The risk is integration surface — five components, and freshness instrumentation across all of them.
Q4 is the best science and the worst project-management risk. Take it only if you have confirmed dataset access before week 119, and accept that the likely finding is negative.
Do not take Q3. Six components is too many for thirteen weeks with no project after it to absorb a slip.
The Evaluation Standard
Whatever the question, the evaluation must have all eight. These are the difference between a result and a demo.
- ≥2 baselines, one of which is a degenerate configuration of your own system — the intervention switched off. This controls for implementation quality, which an external baseline does not.
- Ablations removing each component the claim depends on, separately. If removing one changes nothing, it is not part of the mechanism and the claim should not mention it.
- ≥5 seeds per configuration with bootstrap confidence intervals.
- A negative control — a configuration where you predict no effect. If it shows one, your harness is measuring itself, and you need to know that before you write the paper rather than after a reviewer asks.
- Sensitivity analysis over the two parameters most likely to be doing the work.
- The claim re-tested under fault injection. The interesting question is not whether the system survives; it is whether the effect survives.
- Full cost accounting — latency, memory, storage, compute for every arm. An improvement that costs 10× the compute is a different claim.
- Latency decomposed by stage. A single end-to-end number is not actionable and is usually hiding the interesting part.
Items 1 and 4 are the ones reviewers ask about first, and they are the two most often missing.
Integration Hazards
Named in advance because each has cost someone a project.
| Hazard | What it looks like | Mitigation |
|---|---|---|
| Building the demo instead of the experiment | Every component wired in because leaving one out feels incomplete | The removal test in The Governing Rule; the component list frozen in milestone 2 |
| Late integration | Components perfected separately, joined in week 128 | The hard week-121 deadline for a thin end-to-end path |
| Rewriting upstream components | "P02 would be so much better if I…" | Fix only what the question needs; everything else is future work |
| FFI quicksand | Two days lost to a PyO3 build across four languages | Process boundaries by default; budget the IPC cost as a line item |
| Latency budget discovered too late | Five stages, each "fast enough", summing to 400 ms | Budget per stage in milestone 2, before integration code |
| Error semantics that do not compose | A Rust Result, a Go error, and a Python exception meeting at a seam | Decide at each boundary what a failure means, explicitly |
| The question turning out to be dull | Measurable, and the answer is "no effect, and no interesting reason" | Change it before week 122, with the reason recorded. After 124, finish the boring version — a documented null result is a completed project |
| Writing left to the end | 17 hours of paper starting week 129 | Write methods during milestone 8, while doing the thing it describes |
References
Question-specific literature is on the individual project pages. For the integration and evaluation itself:
- Blackburn, S. M. et al. The Truth, The Whole Truth, and Nothing But the Truth: A Pragmatic Guide to Assessing Empirical Evaluations. ACM TOPLAS 38(4), 2016. The source of most of the evaluation standard.
- Hoefler, T., Belli, R. Scientific Benchmarking of Parallel Computing Systems. SC 2015. Twelve rules for reporting performance; apply all of them.
- Collberg, C., Proebsting, T. A. Repeatability in Computer Systems Research. CACM 59(3), 2016. Read before writing the reproducibility appendix.
- Peyton Jones, S. How to Write a Great Research Paper. Microsoft Research, 2004.
- Zobel, J. Writing for Computer Science, 3rd ed. Springer, 2014.
- Bailey, D. H. Twelve Ways to Fool the Masses When Giving Performance Results on Parallel Computers. Supercomputing Review, 1991. A checklist of things not to do, still entirely current.
- Ousterhout, J. Always Measure One Level Deeper. CACM 61(7), 2018. The argument for evaluation standard item 8 — decomposition, not end-to-end numbers.
Sustaining This While Working Full-Time
Thirty-four months at 11 hours a week is 130 active weeks. The plan does not fail because a project is too hard. It fails because week 61 is the week of a production incident, week 62 is the week you are too tired to start again, and week 63 never happens.
This page is about weeks 61 to 63.
Table of Contents
- The Actual Failure Mode
- The Resumption Problem
- Designing Your Week
- The Three Modes
- The Two-Week Stall Rule
- Planned Breaks
- When Work Takes a Quarter
- Motivation Is Not the Mechanism
- Using Your Job
- The Quarterly Honest Check
- References
The Actual Failure Mode
It is not difficulty. It is not time. It is the cost of restarting after a gap, which compounds:
week 60 normal
week 61 incident at work. 0 hours.
week 62 tired. 0 hours.
week 63 open the repo. No idea where you were. Read code for 40 minutes.
Feel bad. Close the repo.
week 64 avoid it, because opening it now carries the feeling from week 63
week 65+ the project is over, and nobody decided that
Nothing in that sequence involves the work being too hard. The mechanism is:
- A gap creates context loss — an hour of re-reading before any progress.
- Context loss creates a bad session — an hour spent, nothing produced.
- A bad session creates avoidance — the repo now predicts an unpleasant feeling.
- Avoidance creates a longer gap, and the loop tightens.
Every mechanism below attacks one of those four steps. Note that step 1 is the only one that is about the work; steps 2–4 are about how the work feels, which is why "just be disciplined" does not fix it.
The Resumption Problem
The single highest-leverage habit in this entire program: never end a session without writing down where you are.
At the end of every session, five minutes, in notebook/RESUME.md (overwritten each
time — it is a pointer, not a log):
# RESUME — last touched 2027-03-14
PROJECT : P05, milestone 7 (log replication)
STATE : matchIndex updates on AppendEntries success. Commit rule not done.
NEXT : implement the commit rule in raft.go:214. Careful — §5.4.2, a leader
may NOT commit a previous-term entry by counting replicas.
BLOCKED : TestLeaderCompleteness fails when a node restarts mid-election.
Suspect persistence ordering: I think we persist votedFor after
replying. Check that first.
COMMAND : go test -race -run TestLeaderComplete ./raft/
CONFIDENCE: medium. The failure is deterministic with seed 7734.
NEXT must be small enough to start in 60 seconds. "Implement the commit rule in raft.go:214" is startable. "Continue Raft" is not — it requires a decision, and a decision after a three-week gap costs more energy than the work does.
This turns a 40-minute re-orientation into a 3-minute one, which is the difference between a bad session and a good one, which is the difference between a gap and an ending.
Also, deliberately: stop mid-task, not at a clean boundary. A half-written function with a failing test is far easier to resume than a green build and an open question. This is uncomfortable and it works — the Zeigarnik effect is real, and an unfinished task stays partly loaded.
Designing Your Week
Three principles that matter more than the specific schedule.
1. Fixed slots beat available time. "I will work on it when I have time" produces zero hours in a bad month. Two fixed weekday evenings and one weekend block produce 11 hours in a bad month and a good one, because they are already spent.
2. Protect the weekend block above all. It is the only session long enough for deep work — debugging a distributed system, deriving a backward rule, writing a report. If something must be cut, cut a weekday session. Two 2-hour weekday sessions and one 3-hour weekend block survives a bad week far better than five 1-hour sessions with no long one.
3. Match the work to the energy, not to the plan. A weekday evening after a hard day is not when you design a consensus protocol. It is when you write tests, refactor, plot results, or write documentation. Save the hard thinking for whenever your good hours actually are — which, for most people, is not Thursday at 9pm.
A shape that works, from the operating model:
| Slot | Hours | Energy required | Content |
|---|---|---|---|
| Weekday A | 2 | medium | Reading + implementation start |
| Weekday B | 2 | medium | Implementation |
| Weekday C | 2 | low | Tests, plots, cleanup, notebook |
| Weekday D | 2 | medium | Experiment (prediction written first) |
| Weekend | 3 | high | Hard debugging, design, writing, weekly review |
The Three Modes
Not every week is a normal week. Declare which mode you are in, in the weekly log, so that a reduced week is a decision rather than a failure.
Full mode — 11 h/week
The default. All six weekly outputs.
Maintenance mode — 3 h/week
For a bad week: an incident, travel, illness, a family obligation, a launch.
- One session. Small, concrete work only: a test, a plot, a paragraph of the report.
RESUME.mdupdated every time. This is what maintenance mode is for.- No new milestones. No design decisions.
- Declared in the log as maintenance, with the reason.
Maintenance mode is not failure. It is the designed worst-case path — the same idea as Lampson's "handle normal and worst case separately", applied to your own schedule. A week at 3 hours with the context preserved costs you 8 hours of progress. A week at 0 hours with the context lost costs you 8 hours plus a re-entry, plus the risk of the avoidance loop.
Cap: 4 consecutive weeks. Beyond that, see the two-week stall rule and when work takes a quarter.
Zero mode — 0 h/week, planned
For a genuine break. See planned breaks. The rule: write RESUME.md
before you start it, not after.
The Two-Week Stall Rule
If a milestone has made no measurable progress in two consecutive full-mode weeks, it stops being a work problem and becomes a decision.
Not "try harder". Not "push through". A written decision, in the log, choosing one of four:
| Option | When | What you write |
|---|---|---|
| Reduce scope | The milestone is bigger than estimated | What you are cutting, and what the project loses |
| Change approach | The design is wrong | The new approach and why the old one failed |
| Ask for help | You are genuinely stuck on something someone knows | What you tried first (45-minute rule) |
| Cut the milestone | It is not load-bearing for the exit criteria | Why the project is still complete without it |
Two weeks, not four. Four weeks is a month, and a month of no progress on a Medium project is a third of it.
And the corollary, which is the important half: if you abandon a project, you write a postmortem. Not a paragraph of self-criticism — a technical document: what you were trying to do, how far you got, what specifically stopped you, what you would do differently, and what you learned anyway.
A project with a postmortem is a completed learning experience. A project abandoned in silence is a wound, and the wound is what makes the next project harder to start.
Planned Breaks
Unplanned breaks are dangerous. Planned breaks are restorative, and the difference is
entirely whether RESUME.md was written first.
Build in:
- One week off between stages (after M7, M15, M22, M26, M31). Five breaks. This is already inside the 46-productive-weeks-per-year assumption in the duration derivation.
- Two weeks off per year for actual holiday.
- The week after a work crunch — deliberately, in maintenance mode, not full mode.
Before any break:
- Get to a green build. Do not leave a break with a failing test; it becomes the thing you dread.
- Write
RESUME.mdwith a NEXT that is startable in 60 seconds. - Commit and push. Write the commit message for a stranger.
- Write down the return date. A break with an end date is a break; one without is a drift.
The first session back is deliberately trivial. Re-read RESUME.md, run the tests,
fix one small thing, update RESUME.md. Do not schedule a hard milestone for the
returning session — the goal is to re-establish that opening the repo is a neutral act.
When Work Takes a Quarter
It will, at least twice in 34 months. A promotion, a re-org, a migration, an outage that becomes a project. Plan for it now, while it is hypothetical.
The protocol:
- Declare it. Write in the log: "Q3 2027 is a work quarter. Maintenance mode until ⟨date⟩." A declared reduction is a plan; an undeclared one is a collapse.
- Maintenance mode, not zero. 3 hours a week, one session,
RESUME.mdevery time. Three hours preserves the thread; zero severs it. - Finish what you are in the middle of, at reduced scope, rather than pausing mid-milestone. A completed project at MVI scope is worth more than a standard-scope project frozen at 70%.
- Do not restart the schedule when you return. Recompute it. Add the lost weeks to the calendar and move the end date. The plan is 34 months of work, not 34 months of calendar — and it already assumes six weeks lost per year, so one bad quarter is roughly one quarter of slip, not a catastrophe.
- Do not compensate by working 20 hours a week afterwards. It does not work, and the crash that follows costs more than the deficit.
What a quarter actually costs: at 3 h/week for 13 weeks you complete 39 hours instead of 143 — a deficit of ~104 hours, or about 9.5 weeks of active work. Two such quarters across the journey adds roughly 5 months to the end date. That is already survivable and it is why the headline is 34 months and not 24.
Motivation Is Not the Mechanism
Motivation is a consequence of progress, not a cause of it. So the plan generates progress mechanically and lets motivation follow.
| Mechanism | Where it lives |
|---|---|
| A concrete deliverable every week | The weekly unit. Something exists that did not on Monday |
| Fast feedback in early projects | P01 produces generated text in week 8. That is not an accident, it is why the Transformer is first |
| Visible accumulation | Fifteen repositories, ~80 notebook entries, ~15 reports. The notebook/ directory is the progress bar |
| A finish line for every project | Explicit exit criteria mean "done" is a lookup, not a feeling |
| Scores that move | The scorecard trend across stages shows improvement you cannot see day to day |
| Publishing at week 20 | The portfolio plan puts external feedback early, on purpose |
When motivation is absent anyway — and there will be months — fall back to the
mechanism: open RESUME.md, do the NEXT item, update RESUME.md, stop. Twenty minutes.
Do not attempt to feel enthusiastic first. Enthusiasm follows a completed small thing far
more reliably than it precedes one.
Using Your Job
Thirty-four months of evenings is a lot. Some of it can overlap with work, honestly.
| Overlap | How | Caution |
|---|---|---|
| Choose adjacent work projects | You work on search and recommendations. P02, P03, P08 are directly relevant. Volunteer for the retrieval-latency work | Keep the codebases separate. Do not put employer code in a public repo |
| Apply the measurement discipline at work | Bring bench.py's p50/p95/p99-plus-CI habit to your team. It will make you visibly better at your job | None. This is pure gain |
| Use work problems as project questions | If your production HNSW has a p99 problem, that is P02's E10 with real data | Do not publish employer data or internal numbers |
| Present internally | A brown-bag on "why our vector index p99 spikes on narrow filters", grounded in P03's E3 | Also rehearses the portfolio talk |
| Justify learning time | Many employers fund conference attendance or study time for work-adjacent skills | Ask. The worst case is no |
The line to hold: work code stays at work, and this journey's repositories are yours. Confusing them creates an IP problem that is entirely avoidable and extremely tedious to resolve. If in doubt, build the general version at home and never copy in either direction.
The Quarterly Honest Check
Every three months, ten minutes, written. Five questions.
- Am I on schedule? Actual hours vs planned. If more than 15% behind, cut scope from the next stage using the cut table and record what you cut. Do not plan to catch up; nobody catches up.
- Am I in maintenance mode more than full mode? If two of the last three months were maintenance, the weekly design is wrong. Change the slots, not your resolve.
- Is
RESUME.mdcurrent? If it has gone stale, that is the earliest detectable signal of the failure loop. Fix it today. - Am I still doing the loop, or just building? Check the last three notebook entries. Are sections 4, 5 and 6 filled in before the results? If not, the discipline has quietly drifted and the journey has become a build log.
- Do I still want this? A real question, and it deserves a real answer. If the answer is no, the correct response is to stop deliberately and write the postmortem — not to continue joylessly for six more months and then stop by attrition. Fifteen projects is a plan, not a contract.
Question 4 is the one that catches the most dangerous drift, because building feels like progress and it is the half of the loop you already knew how to do.
References
- Zeigarnik, B. Über das Behalten von erledigten und unerledigten Handlungen. Psychologische Forschung 9, 1927. Unfinished tasks remain more accessible in memory — the basis for stopping mid-task.
- Mark, G., Gudith, D., Klocke, U. The Cost of Interrupted Work: More Speed and Stress.
CHI 2008. The measured cost of context switching; why
RESUME.mdpays for itself. - Parkinson, C. N. Parkinson's Law. The Economist, 1955. Work expands to fill the time available — the argument for fixed slots over available time.
- Lampson, B. W. Hints for Computer System Design. SOSP 1983. "Handle normal and worst case separately." Maintenance mode is the worst-case path, designed rather than improvised.
- Newport, C. Deep Work. Grand Central, 2016. On protecting the long block.
- Clear, J. Atomic Habits. Avery, 2018. Systems over goals; the two-minute rule, which is what "NEXT must be startable in 60 seconds" implements.
- Boice, R. Professors as Writers. New Forums Press, 1990. The empirical case that regular short sessions outperform binges — the same finding that shapes the writing allocation.
- Brooks, F. P. The Mythical Man-Month, anniversary ed. Addison-Wesley, 1995. How projects fall behind: one day at a time.