Database Learning Path
A self-paced, hands-on curriculum for mastering database internals — with a focus on performance, data structures, and algorithms — built around reading world-class codebases, implementing things from scratch in Rust, and benchmarking everything.
Background: author is a core developer of FalkorDB and falkordb-rs-next-gen, so graph internals are familiar ground; the goal is breadth + depth across all database domains.
Read this online
The whole path is published as a browsable book (sidebar navigation, search, rendered diagrams) at https://aviavni.github.io/database-learning-path/ — or download the entire book as a PDF.
To build locally: cargo install mdbook mdbook-mermaid, then
mdbook-mermaid install . && mdbook serve from the repo root.
Repo layout
README.md ← you are here: how this repo works
PLAN.md ← the full curriculum (32 topics) — the source of truth
PROGRESS.md ← status tracker: what's done, in progress, next
capstone/ ← falkordb-rs-next-gen rebuilt from scratch, one milestone per topic
topics/ ← created lazily — one dir per topic when the deep dive starts
NN-topic-name/
README.md ← expanded study guide for that topic
notes.md ← learnings, insights, surprising things
experiments/ ← standalone Rust experiments + criterion benchmarks
resources/
papers.md ← papers & articles (arXiv, VLDB, SIGMOD, classics)
codebases.md ← reference codebases and what each is best for studying
tools.md ← profiling, benchmarking, fuzzing, testing tools
Workflow (for me and for Claude next session)
flowchart LR
A["PROGRESS.md<br/>where are we?"] --> B["PLAN.md<br/>pick next topic"]
B --> C["topics/NN-name/<br/>expand into study guide"]
C --> D["study · experiments<br/>· benchmarks · notes.md"]
D --> E["capstone/<br/>milestone MNN"]
E --> F["update PROGRESS.md<br/>+ commit"]
F -.->|next topic| B
- Open
PROGRESS.mdto see where we are. - Pick the next topic (or any topic — order is a suggestion, not a rule) from
PLAN.md. - Create
topics/NN-topic-name/and expand the PLAN.md section into a full study guide: concept explanations with examples, guided code-reading of the reference repos, exercises, and benchmarks. - Study, implement experiments, benchmark, take notes in
notes.md. - Implement the topic’s capstone milestone in
capstone/. - Update
PROGRESS.md(status + one-line takeaway) and commit.
Conventions
- Language: Rust for all implementations and benchmarks (criterion + flamegraph).
- Every topic ends with a benchmark — numbers over intuition.
- Code reading is done against pinned clones under
~/repos/(not vendored here). - Notes capture why designs win, trade-offs, and measured results — not summaries.
The Plan — Database Internals Curriculum
32 topics, self-paced, deliberately diverse: storage / in-memory / query / graph /
vector / distributed / hardware topics are interleaved so it stays fun. Each topic has: why it
matters, core concepts, reference code to read, key papers, and a build+bench exercise
that also advances the capstone (capstone/README.md).
Order is a recommendation. Topics 0–6 are the foundation; after that, jump around freely.
The map
flowchart TD
subgraph FOUNDATION["Foundation — do in order"]
direction LR
T0["0 perf<br/>toolbox"] --> T1["1 B-tree<br/>vs LSM"] --> T2["2 in-memory<br/>structures"] --> T3["3 B-tree<br/>internals"] --> T4["4 LSM<br/>deep dive"] --> T5["5 WAL &<br/>recovery"] --> T6["6 buffer<br/>pool"]
end
subgraph CORE["Systems core"]
T7["7 networking"]
T8["8 MVCC"]
T9["9 concurrency"]
end
subgraph QUERY["Query engine"]
T10["10 parse/plan/optimize"] --> T11["11 execution models"] --> T19["19 JIT"]
T12["12 columnar"]
end
subgraph GRAPH["Graph — home turf"]
T13["13 graph engines"] --> T20["20 GraphBLAS internals"] --> T24["24 graph algorithms"] --> T25["25 graph ML"]
end
subgraph SEARCH["Indexes & search"]
T14["14 vector"]
T23["23 full-text"]
T26["26 probabilistic"]
end
subgraph HW["Hardware"]
T17["17 SIMD"] --> T18["18 GPU"]
end
subgraph DIST["Distributed"]
T15["15 replication/Raft"] --> T29["29 distributed txns"]
T28["28 cloud-native"]
T31["31 CRDTs"]
end
subgraph CORRECT["Correctness"]
T16["16 testing"] --> T21["21 formal methods"]
end
subgraph STREAM["Streaming & temporal"]
T27["27 incremental views"]
T30["30 time-series"]
T27 --> T32["32 HTAP"]
end
T22["22 standard benchmarks — the yardstick for everything"]
FOUNDATION --> CORE
FOUNDATION --> QUERY
FOUNDATION --> GRAPH
FOUNDATION --> SEARCH
FOUNDATION --> HW
FOUNDATION --> DIST
FOUNDATION --> CORRECT
FOUNDATION --> STREAM
QUERY --> T22
GRAPH --> T22
0. The Performance Toolbox
Why: You care about performance — so learn to measure before learning to build. Everything after this topic gets benchmarked properly.
- Concepts: microbenchmark pitfalls (warmup, variance, coordinated omission), CPU caches & memory hierarchy, branch prediction, TLB,
perfcounters, flamegraphs, latency percentiles vs throughput, roofline thinking. - Read code:
criterion.rsinternals (how it fights noise), RocksDBdb_bench, redisredis-benchmark.c. - Papers/reading: “Systems Performance” (Gregg) ch. 1–2; “Fair Benchmarking Considered Difficult” (DBTest ’18); “How NOT to Measure Latency” (Tene talk); Drepper §3–4.
- Build & bench: Rust bench harness comparing
Vecscan vsHashMaplookup vsBTreeMapacross sizes; produce flamegraphs; observe cache-line effects (seq vs random access). - Capstone milestone M0: scaffold the
falkordb-scratchworkspace + criterion bench harness + graph workload generator; record baseline numbers from the real falkordb-rs-next-gen to chase.
1. Storage Engine Landscape: B-Tree vs LSM
Why: The single most consequential design decision in a database. Frames everything else.
- Concepts: read/write/space amplification triangle (RUM conjecture), in-place vs out-of-place updates, page-oriented vs log-structured, where each engine family wins.
- Read code: fjall (small, clean Rust LSM), turso (
core/storage/— SQLite-style B-tree in Rust), tidesdb (C LSM), RocksDB high-level layout. - Papers: “The LSM-Tree” (O’Neil ’96), “The Ubiquitous B-Tree” (Comer ’79), “Designing Access Methods: The RUM Conjecture” (2016), “Architecture of a Database System” (Hellerstein/Stonebraker).
- Build & bench: benchmark fjall vs a raw B-tree (e.g.
redb/sled) on write-heavy vs read-heavy vs scan workloads; explain results in terms of amplification. - Capstone M1: define the storage-backend abstraction (compare with the reference’s
graph/src/storage/backend.rsafter designing yours) — in-memory first, persistent backends swap in later.
2. In-Memory Structures: Hash Tables, Skip Lists, Tries
Why: Redis’s dict and FalkorDB’s core structures — the workhorses of every in-memory DB.
- Concepts: open addressing vs chaining, incremental rehashing (redis), SwissTable/SIMD probing (hashbrown), skip lists (why LSM memtables use them), radix trees / ART, cache-conscious layout.
- Read code: redis
dict.c(incremental rehash!) + valkey’s changes, redist_zset.c(skiplist), hashbrown, RocksDBmemtable/(concurrent skiplist), redisrax.c(radix tree). - Papers: “The Adaptive Radix Tree” (Leis ICDE’13), Google SwissTable talk (CppCon 2017).
- Build & bench: implement a skip list and an incremental-rehash hash table in Rust; bench vs
hashbrownandcrossbeam-skiplist; measure rehash latency spikes vs redis-style incremental approach. - Capstone M2: attribute store + string pool + node/edge ID datablocks (hash index + interning) — the reference’s
attribute_store.rs/string_pool.rs, your way.
3. B-Tree Internals & Paged Storage
Why: SQLite/Postgres/LMDB/most-embedded-DBs. Pages are how disks think.
- Concepts: slotted pages, node splits/merges, B+tree vs B-tree, prefix compression, copy-on-write B-trees (LMDB), overflow pages, page checksums, varint encoding.
- Read code: turso
core/storage/btree.rs+ pager (Rust re-implementation of SQLite — ideal), SQLitebtree.c(the classic), LMDBmdb.c(COW). - Papers: “Modern B-Tree Techniques” (Graefe — the survey), SQLite file-format doc.
- Build & bench: implement a slotted-page disk B+tree in Rust (fixed 4KB pages); bench point lookups & range scans vs
redb; try prefix truncation and measure. - Capstone M3: disk-backed B+tree backend for properties + range indexes behind the storage abstraction.
4. LSM-Tree Deep Dive
Why: RocksDB powers half the industry (including graph DBs like TiKV-based ones). Compaction is a fascinating scheduling problem.
- Concepts: memtable→SST lifecycle, leveled vs tiered vs FIFO compaction, bloom filters (and Monkey’s optimal allocation), fractional cascading, compaction debt/write stalls, SST formats & block cache.
- Read code: fjall (read it ALL — it’s small), RocksDB
db/compaction/,table/block_based/. - Papers: “Monkey: Optimal Navigable Key-Value Store” (SIGMOD’17), “Dostoevsky” (SIGMOD’18), RocksDB paper (TODS’21), “Constructing and Analyzing the LSM Compaction Design Space” (VLDB’21).
- Build & bench: implement a mini-LSM (memtable + SSTs + leveled compaction + bloom filters) — optionally follow skyzh/mini-lsm course; measure write amp with different compaction strategies.
- Capstone M4: LSM-backed alternative persistence (graph snapshots as SSTs); benchmark B+tree vs LSM backends on graph mutation + bulk-load workloads.
5. Durability: WAL, fsync, Crash Recovery
Why: The hardest part to get right. Where correctness meets performance.
- Concepts: write-ahead logging, ARIES (redo/undo, LSNs, fuzzy checkpoints), group commit, fsync vs fdatasync vs O_DIRECT, torn pages (full-page writes / double-write buffer), io_uring.
- Read code: postgres
xlog.c(skim, it’s huge), turso WAL, redis AOF (aof.c) vs RDB, RocksDB WAL. - Papers: “ARIES” (Mohan ’92 — read a summary first, then the paper), “Scalability of write-ahead logging on multicore” (Aether, VLDB’10).
- Build & bench: add WAL + crash recovery to your B+tree; write a crash-injection test (kill -9 mid-write, verify recovery); bench fsync-per-commit vs group commit vs O_DIRECT.
- Capstone M5: WAL + crash recovery for graph mutations (contrast with FalkorDB’s reliance on redis RDB/AOF); crash-injection test suite.
6. Buffer Pool & Memory Management
Why: mmap-vs-buffer-pool is one of the great debates; allocation strategy dominates in-memory DB performance.
- Concepts: buffer pool design, eviction (LRU, CLOCK, LRU-K, 2Q), pointer swizzling (LeanStore), why mmap is (usually) wrong for DBs, jemalloc/arena allocation, NUMA.
- Read code: postgres
bufmgr.c+ CLOCK sweep, rediszmalloc.c, DuckDB buffer manager, LeanStore (C++). - Papers: “Are You Sure You Want to Use MMAP in Your DBMS?” (CIDR’22), “LeanStore” (ICDE’18), “Virtual-Memory Assisted Buffer Management” (vmcache, SIGMOD’23).
- Build & bench: build a buffer pool (CLOCK) for the B+tree; bench vs mmap on datasets larger than RAM; reproduce mmap’s write-back unpredictability.
- Capstone M6: buffer pool under the persistent backends — graphs larger than RAM.
7. Networking, Protocols & Event Loops
Why: Redis’s speed is as much about the event loop and RESP as about data structures. You know the module side of FalkorDB; own the server side.
- Concepts: RESP2/RESP3 design (why so parseable), event loops (ae.c) vs thread-per-core vs async, pipelining, io-threads in redis/valkey, pgwire protocol, neo4j’s Bolt protocol (versioned handshake, PackStream binary serialization, explicit result streaming via PULL/DISCARD + cursors) — RESP vs pgwire vs Bolt as three answers to framing/typing/streaming, backpressure.
- Read code: redis
ae.c+networking.c, valkey’s io-threads rework (great perf PRs to study),pgwire(Rust crate), qdrant’s gRPC/tonic setup, FalkorDB’s ownsrc/bolt/(it already speaks Bolt — reread it with server-side eyes). - Papers/reading: “The C10K problem”, valkey blog posts on multithreading perf, Glauber Costa on thread-per-core, Bolt Protocol + PackStream specifications (neo4j docs).
- Build & bench: implement a RESP server in Rust (tokio) speaking GET/SET; bench with
redis-benchmarkandmemtier_benchmarkagainst real redis; find your bottleneck with flamegraphs. - Capstone M7: RESP server exposing
GRAPH.QUERY/GRAPH.RO_QUERY— wire-compatible with existing FalkorDB clients; bench with falkordb-py against the real thing. Stretch: a Bolt listener on a second port so neo4j drivers connect too (PackStream encoding of the graph result types).
8. Transactions & MVCC
Why: The intellectual core of OLTP. Postgres MVCC vs in-memory designs is a masterclass in trade-offs.
- Concepts: ACID, isolation levels & anomalies (read this twice), 2PL vs OCC vs MVCC, snapshot isolation & write skew, SSI, postgres tuple versioning + vacuum, HOT updates, timestamp ordering, Hekaton-style MVCC.
- Read code: postgres
heapam.c+ visibility rules (HeapTupleSatisfiesMVCC), surrealdb transaction layer, RocksDButilities/transactions/. - Papers: “A Critique of ANSI SQL Isolation Levels” (Berenson ’95), “Serializable Snapshot Isolation in PostgreSQL” (VLDB’12), “An Empirical Evaluation of In-Memory MVCC” (Wu/Pavlo VLDB’17), “Hekaton” (SIGMOD’13).
- Build & bench: implement MVCC with snapshot isolation over your KV engine; write tests that demonstrate (and then prevent) write skew; bench txn throughput vs a single global lock.
- Capstone M8: MVCC graph — copy-on-write + versioned reads (design yours, then study the reference’s
mvcc_graph.rs/cow.rs).
9. Concurrency: Latches, Lock-Free & Epochs
Why: Scaling a storage engine across cores is where the hardest bugs and biggest wins live.
- Concepts: latches vs locks, lock coupling / optimistic lock coupling, lock-free structures & memory reclamation (epochs, hazard pointers), Bw-Tree, atomics & memory ordering in Rust, contention profiling.
- Read code: crossbeam-epoch, RocksDB concurrent memtable inserts, memgraph skip-list, postgres lwlock.c.
- Papers: “The Bw-Tree” (ICDE’13) + “Building a Bw-Tree Takes More Than Just Buzz Words” (SIGMOD’18 — the reality check), “Optimistic Lock Coupling” (Leis).
- Build & bench: make your skip list concurrent (epoch reclamation); bench scaling 1→16 threads; compare mutex-sharded vs lock-free; measure with
perf c2cfor false sharing. - Capstone M9: threadpool + concurrent readers with single writer; parallel query execution (compare with the reference’s
threadpool.rsdesign).
10. Query Engines I: Parsing, Planning, Optimization
Why: The optimizer is the database’s brain. Directly relevant to Cypher planning in FalkorDB.
- Concepts: logical vs physical plans, relational algebra rewrites (predicate pushdown, join reordering), cost models & cardinality estimation (where it all goes wrong), dynamic programming join ordering, Cascades framework.
- Read code: DuckDB
src/optimizer/(readable!), postgresoptimizer/(join search), sqlparser-rs, datafusion optimizer, polars lazy-frame optimizer (crates/polars-plan/). - Papers: “Access Path Selection” (Selinger ’79 — the founding paper), “How Good Are Query Optimizers, Really?” (VLDB’15 — humbling), “The Cascades Framework” (Graefe ’95).
- Build & bench: write a mini planner: parse SQL subset → logical plan → apply pushdown + join reordering; verify plans change with table sizes; compare against DuckDB’s
EXPLAIN. - Capstone M10: Cypher-subset parser + binder + logical plan tree + rewrite rules (the reference’s
parser/+planner/— including its optimizer dir — are your after-the-fact mirror).
11. Query Engines II: Execution Models
Why: Volcano vs vectorized vs compiled — the defining performance battle of modern analytics.
- Concepts: iterator (Volcano) model, vectorized execution (X100/DuckDB), query compilation (HyPer), morsel-driven parallelism, hash joins & aggregation internals, SIMD in query processing.
- Read code: DuckDB
src/execution/(vectors, pipelines), polars streaming engine + SIMD compute kernels (crates/polars-compute/), datafusion (Arrow-based), postgresexecutor/(classic Volcano). - Papers: “MonetDB/X100: Hyper-Pipelining Query Execution” (CIDR’05), “Everything You Always Wanted to Know About Compiled and Vectorized Queries” (VLDB’18), “Morsel-Driven Parallelism” (SIGMOD’14).
- Build & bench: implement the same aggregation query (scan+filter+group-by) three ways: tuple-at-a-time, vectorized (1024-row batches), and with SIMD; bench — the gap is the whole lesson.
- Capstone M11: vectorized runtime: batched rows + operator pipeline + expression eval (mirror of
runtime/batch.rs,vectorized.rs,eval.rs).
12. Columnar Storage & Analytics
Why: DuckDB/ClickHouse-style OLAP. Compression IS performance here.
- Concepts: row vs column layout, encodings (RLE, dictionary, bit-packing, delta, FSST for strings), zone maps / min-max pruning, Parquet & Arrow formats, late materialization, columnar-store architectures compared: ClickHouse MergeTree (LSM-flavored parts + sparse primary index, materialized views) vs DuckDB (embedded, single-file) vs real-time OLAP (Pinot/Druid ingest-time indexing).
- Read code: DuckDB
src/storage/compression/, ClickHouseMergeTree/(parts, granules, sparse index — pick narrow slices), polars (Arrow memory layout in practice), arrow-rs, parquet-rs. - Papers: “C-Store” (VLDB’05), “Integrating Compression and Execution in Column-Oriented Database Systems” (SIGMOD’06), “BtrBlocks” (SIGMOD’23), “FSST” (VLDB’20), “ClickHouse: Lightning Fast Analytics for Everyone” (VLDB’24).
- Build & bench: implement RLE + dictionary + bit-packing encoders; bench scan speed on encoded vs raw data (decompression can be faster than reading raw — verify); run ClickBench queries on DuckDB and profile.
- Capstone M12: columnar attribute storage + zone-map pruning for property filters.
13. Graph Engines (Home Turf, Deeper)
Why: Compare FalkorDB’s sparse-matrix approach against the alternatives you compete with — with benchmarks.
- Concepts: adjacency representations (CSR/CSC, adjacency lists, sparse matrices/GraphBLAS), neo4j’s fixed-size record store + pointer chasing, memgraph’s in-memory skip-list store, BFS as SpMV, worst-case optimal joins for pattern matching, LDBC benchmarks; the query-language landscape: Cypher/openCypher vs GQL (ISO/IEC 39075:2024 — the first new ISO database language since SQL) vs SQL/PGQ (property graphs inside SQL) vs SPARQL over RDF vs Gremlin vs Datalog — data models (property graph vs triples: where do edge properties go in RDF? reification/RDF-star), pattern-matching semantics (homomorphism vs isomorphism vs trail — same query, different answers!), path objects & quantified path patterns, composability (can a query’s output feed another query — Cypher’s weakness, Datalog’s strength), and what each language lets the planner push down.
- Read code: SuiteSparse:GraphBLAS internals (you know the API — go deeper into masks/complement handling), neo4j record format (
kernel/impl/store/), memgraphstorage/v2/, kuzu (WCOJ + columnar graph — very relevant), FalkorDB’s Cypher grammar vs the openCypher grammar spec (what’s missing/extra). - Papers: “GraphBLAS: SuiteSparse” (Davis, TOMS), “Kùzu: A Database Management System For ‘Beyond Relational’ Workloads” (CIDR’23), “EmptyHeaded” (worst-case optimal joins on graphs), LDBC SNB spec, “Graph Pattern Matching in GQL and SQL/PGQ” (SIGMOD’22), “G-CORE: A Core for Future Graph Query Languages” (SIGMOD’18), the GQL standard overview (gqlstandards.org / Deutsch et al.).
- Build & bench: implement 2-hop neighborhood query over CSR vs adjacency-list vs GrB sparse matrix; bench on LDBC-scale data; compare with FalkorDB and neo4j on the same query; write the same three queries (filtered 2-hop, shortest path, group-by aggregation) in Cypher, GQL, SPARQL, and Gremlin — note where the language forces a different plan (path semantics, lack of pushdown) rather than just different syntax.
- Capstone M13: first graph core: adjacency-list/CSR node+edge store with basic pattern matching — the deliberately-naive baseline that M20’s sparse-matrix core will replace (and be measured against). Language-wise: target openCypher now, but keep the AST GQL-shaped (quantified path patterns as first-class) so M10’s parser doesn’t need a rewrite when GQL compatibility matters.
14. Vector Search
Why: qdrant/helix-db territory; every DB is adding this. Beautiful algorithms, very benchmarkable.
- Concepts: ANN problem & recall/latency trade-off, HNSW (and its memory hunger), IVF, product quantization, scalar/binary quantization, DiskANN/Vamana for on-disk, filtered search (the hard part — qdrant’s specialty).
- Read code: qdrant
lib/segment/(HNSW + filtering + quantization), helix-db vector side, usearch (compact HNSW). - Papers: “HNSW” (arXiv:1603.09320), “Product Quantization” (Jégou PAMI’11), “DiskANN” (NeurIPS’19), qdrant blog on filtered HNSW.
- Build & bench: implement HNSW in Rust from the paper; measure recall@10 vs QPS curves against qdrant on ann-benchmarks datasets (sift-1m); add scalar quantization, re-measure.
- Capstone M14: vector index on node properties + distance kernels (the reference’s
vec_distance.rsterritory).
15. Replication, Consensus & Distribution
Why: From single node to system. Raft is table stakes; the interesting part is what each DB does differently.
- Concepts: replication topologies (leader/follower, async vs sync), redis/valkey replication + failover, Raft (leader election, log replication, snapshots, membership), consistency models (linearizability → eventual), sharding (hash slots vs ranges).
- Read code: valkey
replication.c+ cluster, qdrant raft-based consensus (consensus/), openraft or tikv/raft-rs, surrealdb+tikv layering. - Papers: “In Search of an Understandable Consensus Algorithm” (Raft, ATC’14), “ZooKeeper” or “Viewstamped Replication Revisited” (for contrast), Kleppmann DDIA ch. 5, 8, 9 (read thoroughly).
- Build & bench: implement Raft leader election + log replication (or work through the raft-rs / talent-plan labs); inject partitions and observe; measure replication-lag impact of fsync policies.
- Capstone M15: ship the WAL to a follower node; then upgrade to Raft.
16. Testing & Correctness Engineering
Why: The topic that separates hobby DBs from production DBs. Turso and FoundationDB made this their identity.
- Concepts: deterministic simulation testing (DST), fault injection, property-based testing (proptest), fuzzing (cargo-fuzz/AFL), metamorphic testing (SQLancer’s pivoted queries / TLP), Jepsen & elle (checking linearizability), model checking with TLA+ (taste of), SMT solvers (Z3): proving query rewrites equivalent (Cosette-style), checking optimizer rules and constraint/invariant satisfiability.
- Read code: turso’s simulator + DST setup (they blog about it), FoundationDB simulation docs, SQLancer, antithesis blog posts, redis
test/harness, Z3 (z3.rsbindings; skim the tactic/solver architecture — treat Z3 itself as a masterclass codebase: it’s a high-performance search engine over logic). - Papers: “Testing Database Engines via Pivoted Query Synthesis” (OSDI’20), “Finding Logic Bugs via TLP” (OOPSLA’20), Jepsen analyses (pick redis-raft and a graph DB one), “Z3: An Efficient SMT Solver” (TACAS’08), “Cosette: An Automated Prover for SQL” (CIDR’17).
- Build & bench: add proptest model-checking to the capstone (graph ops vs an in-memory model oracle); build a mini DST harness (simulated clock + fault-injecting IO layer); fuzz your parsers (Cypher + page/SST decoders); use Z3 to verify two of your topic-10 rewrite rules are equivalent (and to find a counterexample when you break one on purpose).
- Capstone M16: openCypher TCK subset runner as the correctness oracle + DST harness + fuzzers (the reference’s
fuzz/andtck_done.txtshow the bar). Graduation of the correctness spine.
17. SIMD & Hardware-Conscious Data Processing
Why: The last 10x on a single core. Touched in topic 11 — this is the dedicated deep dive: writing kernels that saturate the CPU.
- Concepts: SIMD fundamentals (AVX2/AVX-512 vs ARM NEON/SVE — know both, you’re on ARM), autovectorization and why it fails, Rust portable SIMD (
std::simd) vs intrinsics, branchless selection (masks + compress), SIMD hash probing (SwissTable), SIMD string parsing/comparison, bit-packed decoding at SIMD speed (FastLanes), gather/scatter costs, instruction-level parallelism & dependency chains, Mojo’s SIMD-first design (SIMD[type, width]as a first-class parametric type — compare its ergonomics vsstd::simdand intrinsics). - Read code: polars
crates/polars-compute/kernels, simdjson (the masterclass — read with the paper), hashbrown SIMD group probing, DuckDB compressed-scan kernels, usearch/SimSIMD distance functions, memchr crate, Mojo stdlib + Modular’s matmul optimization blog series. - Papers: “Rethinking SIMD Vectorization for In-Memory Databases” (SIGMOD’15), “Parsing Gigabytes of JSON per Second” (simdjson, VLDB’19), “The FastLanes Compression Layout” (VLDB’23).
- Build & bench: write filter-selection and dot-product kernels four ways: naive scalar, autovectorized,
std::simd, NEON intrinsics; bench withperf stat(IPC, vector-lane utilization); then SIMD-ize a bit-packing decoder and compare against topic 12’s scalar version; port one kernel to Mojo and compare both the numbers and the code you had to write. - Capstone M17: SIMD-accelerated kernels in the vectorized runtime + vector-distance functions; keep scalar fallbacks and a bench comparing them.
18. GPU Acceleration for Databases
Why: GPUs are reshaping analytics, graph algorithms, and vector search — directly relevant to FalkorDB’s future (GraphBLAS on GPU exists). Learn when the PCIe tax is worth paying.
- Concepts: GPU architecture for DB people (SIMT, warps, occupancy, memory coalescing, shared memory), the data-transfer bottleneck (PCIe vs NVLink vs unified memory on Apple Silicon), GPU hash joins & aggregation, GPU graph processing (Gunrock, cuGraph, GraphBLAST — SpMV on GPU!), GPU vector search (Faiss GPU, cuVS/CAGRA), programming models: CUDA vs Metal vs wgpu/WebGPU (portable, works on your Mac) vs Mojo/MLIR (one language targeting CPU SIMD and GPU — the portability bet worth understanding).
- Read code: cuVS/RAFT (vector search kernels), libcudf (GPU columnar ops), Gunrock or GraphBLAST (graph frontier expansion), HeavyDB query compilation to GPU, Rust:
wgpucompute examples,cudarc. - Papers: “A Study of the Fundamental Performance Characteristics of GPUs and CPUs for Database Analytics” (Crystal, SIGMOD’20), “Billion-scale similarity search with GPUs” (Faiss, arXiv:1702.08734), “Gunrock” (PPoPP’16), “CAGRA: Highly Parallel Graph Construction for GPU ANN” (ICDE’24).
- Build & bench: implement filter+aggregate and batch vector-distance as wgpu compute shaders (runs on Apple Silicon Metal); bench vs your topic-17 SIMD kernels including transfer time — find the crossover batch size where GPU wins; run BFS via SpMV on GPU vs SuiteSparse CPU.
- Capstone M18: experimental GPU backend for one hot path (SpMV traversal or vector distance scoring) behind a feature flag, with CPU-vs-GPU crossover benchmark.
19. JIT & Query Compilation
Why: The other answer to interpretation overhead (vs vectorization, topic 11). HyPer/Umbra made it famous; SQLite has quietly used a bytecode VM forever; SuiteSparse:GraphBLAS JIT-compiles kernels.
- Concepts: interpreter → bytecode VM → native JIT spectrum, SQLite’s VDBE, produce/consume compilation model (HyPer), compilation latency vs execution speed (why Umbra built its own IR — “Tidy Tuples”), copy-and-patch compilation, adaptive execution (start interpreting, JIT when hot), LLVM vs cranelift vs hand-rolled backends, expression JIT vs whole-pipeline JIT, postgres’s LLVM JIT (and why it’s often a regression).
- Read code: SQLite
vdbe.c(bytecode design), postgressrc/backend/jit/llvm/, cranelift-jit examples, SuiteSparse:GraphBLAS JIT kernel generation (Source/jit*), DuckDB’s absence of a JIT (find the discussions — vectorization as the counter-argument). - Papers: “Efficiently Compiling Efficient Query Plans for Modern Hardware” (Neumann, VLDB’11 — the paper), “Tidy Tuples and Flying Start” (Umbra, VLDBJ’21), “Copy-and-Patch Compilation” (OOPSLA’21), “Adaptive Execution of Compiled Queries” (ICDE’18), “Everything You Always Wanted to Know About Compiled and Vectorized Queries” (VLDB’18 — re-read after topic 11).
- Build & bench: JIT-compile filter expressions with cranelift; three-way bench: AST-walking interpreter vs vectorized (topic 11 kernel) vs JIT — including compile time; find the query length/selectivity crossover where each wins.
- Capstone M19: cranelift JIT for Cypher expressions (vs the
eval.rs-style interpreter) with fallback and a compile-time budget heuristic.
20. Sparse Linear Algebra & GraphBLAS Internals (Deep Home Turf)
Why: You use the GraphBLAS API daily in FalkorDB — this topic is about owning what’s underneath: the kernels, formats, and scheduling decisions SuiteSparse makes for you.
- Concepts: sparse formats and when SuiteSparse switches between them (CSR/CSC, bitmap, full, hypersparse), SpMV vs SpMSpV, SpGEMM algorithms (Gustavson, hash-based, heap-based), masks/accumulators/semirings as an execution model, push vs pull BFS = SpMV vs masked SpMSpV (direction-optimizing), non-blocking mode & lazy evaluation, FalkorDB’s delta-matrix pattern, JIT’d kernels (ties to topic 19), GPU GraphBLAS (ties to topic 18), how SuiteSparse parallelizes: OpenMP (saxpy3’s coarse/fine task scheduling,
#pragma omp parallel forstatic vs dynamic/guided loops, nthreads heuristics from flop counts) and the Rust alternatives: rayon work-stealing vs OpenMP static scheduling (irregular nnz-per-row is exactly where the difference shows),std::thread::scope, morsel-driven scheduling built by hand (topic 11) — note there is no mature native-Rust GraphBLAS: the crates (rustgraphblas,graphblas_sparse_linear_algebra) are FFI bindings to SuiteSparse, so a Rust rebuild must bring its own parallel runtime. - Read code: SuiteSparse:GraphBLAS internals — format-switch heuristics,
GB_AxB_*SpGEMM variants, mask handling, the OpenMP scheduling inGB_AxB_saxpy3(how nthreads/ntasks are derived from the flopcount pre-pass); LAGraph algorithm implementations (BFS, triangle counting, PageRank); FalkorDB’s own delta-matrix layer with fresh eyes; rayon internals (join/scope, work-stealing deques) as the OpenMP counterpart. - Papers: Davis “Algorithm 1000: SuiteSparse:GraphBLAS” (TOMS’19) + the v2 update (TOMS’23), Gustavson ’78 (two-pointer SpGEMM), Buluç & Gilbert SpGEMM survey, Beamer “Direction-Optimizing BFS” (SC’12), GraphBLAS C API spec (read cover to cover once).
- Build & bench: implement CSR SpMV and Gustavson SpGEMM in Rust; parallelize both with rayon and bench scaling 1→N cores against SuiteSparse’s OpenMP on the same matrices (SuiteSparse Matrix Collection) — measure where work-stealing beats/loses to static row partitioning on skewed (RMAT) vs uniform matrices; implement direction-optimizing BFS with masks; measure where hypersparse representation pays off.
- Capstone M20: the heart: your own sparse-matrix/GraphBLAS-subset kernels + delta matrices replace the M13 adjacency-list core; parallelism via rayon (document the OpenMP→rayon mapping decisions); benchmark both on LDBC queries, and against the reference’s
graph/src/graph/graphblaslayer.
21. Formal Methods & Verification
Why: Testing (topic 16) finds bugs you imagined; formal methods find the ones you didn’t. AWS, MongoDB, and CockroachDB all spec their protocols in TLA+. Also: e-graphs are quietly powering modern query optimizers.
- Concepts: SAT → SMT (DPLL(T), theories), Z3’s architecture (tactics, e-matching, the congruence closure e-graph), TLA+ & PlusCal (specify, then let TLC model-check), safety vs liveness, refinement, equality saturation with e-graphs (egg) for rewrite-rule optimizers, lightweight formal methods (spec only the scary parts), protocol testing languages (P, Ivy) as a lighter alternative, theorem proving with Lean 4 (proofs vs model checking — and Lean’s runtime itself: Perceus reference counting, functional-but-in-place updates, a systems-performance story in its own right).
- Read code: Z3 internals (
src/smt/, the e-graph — a high-performance search engine over logic), egg (Rust equality saturation — read fully, it’s small), published TLA+ specs: Raft (Ongaro’s), MongoDB replication, CockroachDB’s specs repo, Lean 4 (leanprover/lean4— the compiler/runtime insrc/runtime/, and how mathlib scales proof search). - Papers: “How Amazon Web Services Uses Formal Methods” (CACM’15 — the motivation paper), “egg: Fast and Extensible Equality Saturation” (POPL’21), “Z3: An Efficient SMT Solver” (TACAS’08), Lamport’s “Specifying Systems” (part I) + the TLA+ video course, “Cosette” (CIDR’17 — revisit from topic 16), “Counting Immutable Beans” + “Perceus: Garbage-Free Reference Counting” (the Lean/Koka runtime papers).
- Build & bench: write a TLA+ spec of the capstone’s WAL-replication protocol (topic 15) and model-check it — then remove an ack and watch TLC find the data-loss trace; build an expression-rewrite pass with egg and compare plans vs your hand-ordered rules from topic 10; in Lean 4, formalize and prove a small invariant (e.g., your B+tree ordering property or a delta-matrix merge property) — taste the proof-vs-test trade-off.
- Capstone M21: TLA+ spec of the MVCC visibility rules (or replication) checked by TLC in CI; Lean proof of a delta-matrix invariant; optional egg-based rewrite stage in the planner.
22. Standard Benchmarks: TPC-H, TPC-C, YCSB, LDBC & Friends
Why: The industry’s shared yardsticks — and their hidden messages. Knowing what each query actually stresses turns benchmarks from marketing into engineering tools.
- Concepts: OLTP vs OLAP benchmark design, TPC-C (contention, think times, and why nobody runs it honestly), TPC-H choke-point analysis (which of the 22 queries stress joins vs aggregation vs expression eval), TPC-DS, Join Order Benchmark (JOB — real data, real cardinality pain), SSB, YCSB workloads A–F & Zipfian skew, LDBC SNB + Graphalytics (graph), ann-benchmarks (vector), ClickBench, fair-benchmarking methodology & benchmarketing sins, scale factors and data generators.
- Read code/run: DuckDB’s built-in TPC-H/TPC-DS extensions, BenchBase (CMU), HammerDB,
dbgen/dsdgen, LDBC SNB datagen + driver, go-ycsb/memtier. - Papers: “TPC-H Analyzed: Hidden Messages and Lessons Learned” (Boncz — the choke-point paper, read alongside running it), “Fair Benchmarking Considered Difficult” (DBTest’18), “OLTP-Bench” (VLDB’13), “How Good Are Query Optimizers, Really?” (VLDB’15 — the JOB paper, revisit), LDBC SNB paper.
- Build & bench: run TPC-H SF10 on DuckDB and postgres, profile three choke-point queries and explain the gap; run YCSB against redis and your topic-7 RESP server; run LDBC SNB interactive on FalkorDB vs neo4j and analyze where each wins.
- Capstone M22: standing benchmark suite — LDBC SNB interactive, graph micro-benches, ann-benchmarks recall/QPS — with regression tracking across milestones, and a three-way shootout:
falkordb-scratchvs falkordb-rs-next-gen vs FalkorDB.
23. Full-Text Search & Inverted Indexes (Elasticsearch / Lucene / tantivy)
Why: The third great index family after trees and hash tables. Lucene is a 25-year masterclass, tantivy is its readable Rust rival, and RediSearch is home turf.
- Concepts: inverted index anatomy (term dictionary, posting lists), text analysis pipelines (tokenizers, stemming), posting-list compression (varint, bit-packing, roaring bitmaps), FSTs for term dictionaries, BM25 scoring, top-k retrieval with WAND / block-max WAND, Lucene’s LSM-like segment architecture + merge policies (compare with topic 4!), doc values (Lucene’s columnar side), Elasticsearch distribution layer (shards, scatter-gather, relevance vs recall), hybrid search (BM25 + vectors, reciprocal rank fusion — ties to topic 14).
- Read code: tantivy (Rust, the best read — postings, FST dictionary, block-max WAND), Lucene core (
codecs/, segment merging), RediSearch (redis-module perspective you know), quickwit (tantivy over object storage), Elasticsearch mostly at the architecture-docs level. - Papers: “Inverted Files for Text Search Engines” (Zobel & Moffat, CSUR’06 — the survey), BM25 origins (Robertson & Zaragoza “The Probabilistic Relevance Framework”), “Faster Top-k Document Retrieval Using Block-Max Indexes” (SIGIR’11), “Roaring Bitmaps” (arXiv:1603.06549).
- Build & bench: build a mini inverted index in Rust: tokenize → posting lists → BM25 → top-k with block-max WAND; bench vs tantivy on a Wikipedia dump; compare roaring vs raw-vec posting lists for AND/OR queries.
- Capstone M23: full-text index on node/edge properties + hybrid search fusing BM25 with the M14 vector index (RRF) — what FalkorDB delegates to RediSearch, built in.
24. Advanced Graph Algorithms & Analytics
Why: Traversal (topic 13/20) is table stakes; the value is in analytics — centrality, communities, components — and in knowing when the algebraic (LAGraph) formulation beats the frontier-based one.
- Concepts: SSSP (delta-stepping), betweenness centrality (Brandes; batched algebraic variant), PageRank (and convergence tricks), connected components (label propagation, Afforest), community detection (Louvain → Leiden, and why Louvain’s communities can be broken), triangle counting & k-truss (masked SpGEMM!), push vs pull direction switching (Ligra), algebraic vs frontier formulations trade-offs, the GAP benchmark suite as the yardstick.
- Read code: LAGraph (the algorithm collection over GraphBLAS — study how each algorithm maps to masks/semirings; you have
lagraph_libin the reference repo already), GAP benchmark reference implementations, Ligra. - Papers: “A Faster Algorithm for Betweenness Centrality” (Brandes ’01), “From Louvain to Leiden” (Sci. Reports ’19), “Ligra: A Lightweight Graph Processing Framework” (PPoPP’13), “The GAP Benchmark Suite” (arXiv:1508.03619), “Delta-Stepping” (Meyer & Sanders), Azad & Buluç masked-SpGEMM triangle counting.
- Build & bench: implement Brandes betweenness and Leiden in Rust over your M20 sparse core; compare against LAGraph on the same matrices (note LAGraph’s parallelism is also OpenMP — your rayon-based kernels from topic 20 carry over here); run the GAP suite (BFS, SSSP, PR, CC, BC, TC) and profile where the algebraic formulation wins/loses vs frontier-based.
- Capstone M24: LAGraph-style algorithm library over the sparse core, exposed as Cypher procedures (
CALL algo.pagerank(...)— FalkorDB-style).
25. Graph Neural Networks & Graph ML
Why: Message passing is SpMM over a semiring — your GraphBLAS core is already a GNN engine waiting to happen. And GraphRAG (which you know from GraphRAG-SDK) is pulling graph DBs into the ML serving path.
- Concepts: node embeddings (DeepWalk, node2vec — random walks + skip-gram), message passing as generalized SpMM, GCN / GraphSAGE / GAT (and what each adds), mini-batch neighbor sampling for graphs that don’t fit (GraphSAGE’s real contribution), knowledge-graph embeddings (TransE family), GNN systems view: how PyG/DGL kernels map to sparse ops, embeddings-in-the-database (compute → store in vector index → hybrid query), GraphRAG architectures.
- Read code: DGL / PyTorch Geometric sparse kernels (the SpMM/SDDMM ops underneath), candle or burn (Rust ML — for implementing), your own GraphRAG-SDK with fresh systems eyes.
- Papers: “node2vec” (KDD’16), “Semi-Supervised Classification with GCNs” (Kipf & Welling, ICLR’17), “Inductive Representation Learning on Large Graphs” (GraphSAGE, NeurIPS’17), “Graph Attention Networks” (ICLR’18), “TransE” (NeurIPS’13), “Graph Neural Networks meet Databases” survey (pick a recent arXiv one when starting).
- Build & bench: implement node2vec and a 2-layer GCN in Rust (candle/burn) using your own M20 SpMM as the aggregation kernel; train on Cora and ogbn-arxiv; bench your SpMM against DGL’s on the same graphs; store the learned embeddings in your M14 vector index and measure end-to-end hybrid query latency.
- Capstone M25: embeddings pipeline — compute node2vec/GCN embeddings with your own kernels, store them in the vector index, and answer GraphRAG-style hybrid queries (pattern match + semantic similarity) in one Cypher query.
26. Indexing & Probabilistic Data Structures
Why: Indexes are bets — you pay write amplification for read speed. And the probabilistic structures (bloom filters, HLL — redis’s PFCOUNT is one) buy huge wins by being slightly wrong.
- Concepts: secondary index design and its write cost, composite/covering indexes & index-only scans, hash vs B-tree vs bitmap vs BRIN (≈ zone maps), partial & expression indexes, index maintenance under MVCC (postgres HOT, index bloat), index selection (“what-if” analysis), learned indexes (RMI, ALEX, PGM — do they survive contact with updates?); spatial/geo indexes: R-tree & R*-tree (bounding-box hierarchy, node splits), quadtrees & kd-trees, space-filling curves that turn 2-D into a 1-D B-tree problem (Z-order/geohash, Hilbert — and why Hilbert clusters better), S2/H3 cell coverings, redis GEO (a 52-bit geohash stuffed into a zset — indexes-you-already-have reuse), postgres GiST/SP-GiST as the extensible index framework spatial rides on (nearest-neighbor via priority-queue traversal); compressed bitmaps: roaring internals (array/bitmap/run containers, galloping intersection), WAH/EWAH ancestry, SIMD-accelerated set operations, where they power real systems (Lucene doc sets, ClickHouse, Druid, Pilosa); succinct structures (rank/select, Elias-Fano encoding of sorted IDs — postings and adjacency lists both); probabilistic filters: bloom filter math (FPR vs bits/key), blocked bloom (cache-line friendly), cuckoo, xor, ribbon filters (RocksDB’s evolution); sketches: HyperLogLog (dense/sparse), count-min, t-digest, top-k.
- Read code: postgres index access methods (
nbtree/,gin/,brin/,gist/+ PostGIS’s R-tree-over-GiST), redisgeo.c/geohash.c(the zset trick end-to-end), s2geometry or h3 (cell covering APIs), RocksDButil/bloom*+ ribbon filter, redishyperloglog.c(the dense/sparse encoding dance — a classic), RedisBloom module, CRoaring + roaring-rs (container switching, SIMD intersections), LuceneRoaringDocIdSet, PGM-index and ALEX repos. - Papers: “The Case for Learned Index Structures” (SIGMOD’18), “ALEX” (SIGMOD’20), “The PGM-index” (VLDB’20), “R-trees: A Dynamic Index Structure for Spatial Searching” (Guttman, SIGMOD’84), “The R*-tree” (SIGMOD’90), “Better bitmap performance with Roaring bitmaps” (SPE’16) + “Roaring Bitmaps: Implementation of an Optimized Software Library” (SPE’18), “Cuckoo Filter: Practically Better Than Bloom” (CoNEXT’14), “Xor Filters” (JEA’20), “Ribbon Filter” (arXiv:2103.02515), “HyperLogLog in Practice” (Google, EDBT’13).
- Build & bench: implement a mini roaring bitmap (three container types + adaptive switching) and bench intersect/union vs
roaring-rsand a plainHashSet<u32>across densities — find where each container wins; implement blocked-bloom, cuckoo, and xor filters — bench FPR vs bits-per-key vs lookup latency in one chart; implement HLL and verify the error bound empirically; implement a Z-order/geohash index over your M3 B+tree plus a small in-memory R-tree — bench point-in-radius and bounding-box queries vs full scan across selectivities, and measure where the curve’s “cell boundary” false positives hurt; race a PGM-index against your M3 B+tree, then add updates and watch the story change. - Capstone M26: secondary range indexes maintained under MVCC + bloom filters in the LSM backend + roaring bitmaps for label/type filtering in pattern matching + HLL fast path for approximate
count(DISTINCT ...)in Cypher + geo index for point properties (Z-order over the range index) answeringWHERE distance(n.loc, $p) < r— FalkorDB has a point type; make it indexable.
27. Streaming & Incremental View Maintenance
Why: Recomputing from scratch is the enemy. Differential dataflow and DBSP made incremental computation rigorous — and FalkorDB’s delta matrices are already halfway there conceptually.
- Concepts: dataflow model (timely), differential dataflow (deltas all the way down), DBSP (the algebraic theory of incremental computation — Z-sets will feel familiar after semirings), materialized view maintenance, watermarks & out-of-order data, exactly-once semantics, the log as the database (Kafka), incremental graph queries (registered/standing Cypher queries).
- Read code: differential-dataflow + timely (Rust, Frank McSherry), Feldera (DBSP implementation, Rust), Materialize architecture, RisingWave (Rust streaming DB).
- Papers: “Naiad: A Timely Dataflow System” (SOSP’13), “Differential Dataflow” (CIDR’13), “DBSP: Automatic Incremental View Maintenance for Rich Query Languages” (VLDB’23 best paper), “Kafka” (NetDB’11).
- Build & bench: incremental PageRank and triangle counting with differential-dataflow — stream edge insertions and compare incremental-update cost vs full recompute as the graph grows; write a delta-join operator by hand to demystify it.
- Capstone M27: standing Cypher queries — register a query, keep its result incrementally maintained under graph mutations via delta matrices, push changes to subscribers.
28. Cloud-Native & Disaggregated Storage
Why: The architecture every serious DB is converging on: compute is stateless, the log/object store is the database. Aurora, Neon, Snowflake — and it changes every design trade-off you learned in topics 3–6.
- Concepts: compute–storage separation, Aurora’s “the log is the database”, Neon’s pageserver + WAL-redo model, object storage as substrate (S3 latency/cost/consistency model), caching tiers & request hedging, snapshots and copy-on-write branching, serverless & scale-to-zero, shared-data vs shared-nothing, LSM tiering to object storage.
- Read code: neon (Rust — pageserver, safekeepers), slatedb (Rust LSM on object storage — small and current), quickwit (search over S3), turso’s object-store work.
- Papers: “Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases” (SIGMOD’17), “Socrates: The New SQL Server in the Cloud” (SIGMOD’19), “The Snowflake Elastic Data Warehouse” (SIGMOD’16), “Building a Database on S3” (SIGMOD’08 — prescient), Neon architecture posts.
- Build & bench: move your LSM backend’s SSTs to object storage (MinIO locally) with a local NVMe cache tier; measure p50/p99 read latencies vs local-only and tune the cache; implement copy-on-write graph branching (Neon-style branches for graphs).
- Capstone M28: tiered storage backend — hot data local, SSTs on object storage — plus instant graph snapshots/branches.
29. Distributed Transactions
Why: The layer above topic 15’s Raft: making transactions span shards. The gap between 2PC-in-a-textbook and Spanner/FoundationDB is where the deep understanding lives.
- Concepts: 2PC and its blocking failure mode, Percolator (transactions over a KV store — TiKV’s model), Spanner’s TrueTime + external consistency, hybrid logical clocks (HLC — CockroachDB’s answer to no atomic clocks), Calvin & deterministic databases (Abadi’s counterpoint), FoundationDB’s decomposed architecture (sequencer/resolvers/storage), contention & abort-rate dynamics, the cross-shard graph traversal problem (why graph partitioning is hard).
- Read code: tikv (
txn/— Percolator in Rust), FoundationDB (with the SIGMOD’21 paper as the map), CockroachDBkv/txncoordinator + HLC. - Papers: “Spanner” (OSDI’12), “Large-scale Incremental Processing Using Distributed Transactions” (Percolator, OSDI’10), “Calvin” (SIGMOD’12), “FoundationDB: A Distributed Unbundled Transactional Key Value Store” (SIGMOD’21), “Logical Physical Clocks” (HLC, OPODIS’14).
- Build & bench: shard your graph across two processes; implement 2PC, then Percolator-style transactions over the KV layer; drive both with the M16 DST harness injecting crashes at every 2PC state; measure abort rates vs contention (Zipfian hot keys).
- Capstone M29: cross-shard transactions + cross-shard pattern matching over a partitioned graph.
30. Time-Series Engines
Why: Small, beautiful, and immediately useful: Gorilla’s encodings are the best compression-ratio-per-line-of-code in databases. And temporal graphs are an open frontier for FalkorDB.
- Concepts: Gorilla compression (delta-of-delta timestamps, XOR floats), time-partitioned storage & retention/downsampling, out-of-order ingestion (the hard part), tag inverted index (series lookup — topic 23 reappears), high-cardinality pain, IOx architecture (DataFusion + Parquet + object storage — topics 11/12/28 combined), TSBS benchmarking.
- Read code: influxdb (IOx engine, Rust), prometheus
tsdb/(Go, very readable — head block + WAL + compaction), VictoriaMetrics (ruthless efficiency). - Papers: “Gorilla: A Fast, Scalable, In-Memory Time Series Database” (VLDB’15 — read first), “Monarch: Google’s Planet-Scale In-Memory Time Series Database” (VLDB’20), “BtrDB” (FAST’16).
- Build & bench: implement the Gorilla codec (delta-of-delta + XOR floats) in Rust; bench compression ratio and decode throughput on real metrics (node_exporter dumps) vs Parquet+zstd; handle out-of-order writes and measure the cost.
- Capstone M30: temporal graph support — edge/property history with Gorilla-compressed values and time-travel pattern matching (
MATCH ... AT TIME t).
31. CRDTs & Multi-Master Replication
Why: The anti-consensus: let replicas diverge and merge deterministically. Redis Enterprise’s active-active CRDB is built on this — an active-active graph is a genuinely hard, genuinely interesting design problem.
- Concepts: strong eventual consistency, state-based vs op-based CRDTs, the classics (G-Counter, PN-Counter, LWW-Register, OR-Set), causality tracking (vector clocks, dots), sequence CRDTs (RGA, Fugue — why collaborative text is the hard case), JSON/tree CRDTs and the move-operation problem, when CRDTs beat consensus and when they quietly lose data (LWW’s lie), local-first software, graph CRDTs: OR-Set nodes/edges + LWW property maps, and the dangling-edge problem.
- Read code: automerge (Rust), loro (Rust — fast, modern engine), yrs (Yjs port), cr-sqlite (CRDT layer bolted onto SQLite — instructive architecture), diamond-types.
- Papers: “Conflict-free Replicated Data Types” (Shapiro et al., SSS’11 — the founding paper + the INRIA comprehensive study), “A Conflict-Free Replicated JSON Datatype” (Kleppmann & Beresford ’17), “A Highly-Available Move Operation for Replicated Trees” (Kleppmann ’21), “Local-First Software” (Onward! ’19), Loro/Fugue blog series on sequence CRDT performance.
- Build & bench: implement PN-Counter and OR-Set, property-test convergence (proptest: any permutation of concurrent ops merges to the same state — a beautiful proptest target); bench automerge vs loro on the editing-trace benchmarks; design a graph CRDT on paper first: what happens to an edge when one replica deletes its endpoint?
- Capstone M31: active-active mode — two masters accepting writes, OR-Set nodes/edges + LWW properties, deterministic merge; contrast its guarantees and latency with the M15 Raft path on the same workload.
32. HTAP Architectures
Why: Every earlier topic picked a side — row/OLTP (3, 8) or column/OLAP (12). HTAP is the refusal to pick: transactional writes AND analytical scans on the same (logical) data. FalkorDB has the same split waiting: OLTP graph mutations vs topic-24 analytics that want a stable, columnar-ish view.
- Concepts: the freshness/isolation/interference triangle (the HTAP trilemma); the architecture menu — separate copies wired by replication (TiDB→TiFlash: columnar replicas as Raft learners, consistent reads via learner-read + Raft index wait), dual formats in one engine (SAP HANA delta+main, Oracle Database In-Memory dual-format, SingleStore rowstore→columnstore), snapshot-the-memory (HyPer’s fork()-based virtual-memory snapshots), lakehouse-ish decoupled (F1 Lightning: CDC into a read-optimized store); delta-main merge policies (rhymes hard with FalkorDB delta matrices AND topic 4’s LSM); planner routing — one optimizer choosing row vs columnar replica per (sub)query with a freshness bound; resource isolation so scans don’t starve p99 writes; CDC/changelog as the universal glue (topic 27’s log-is-the-database, applied).
- Read code: TiFlash (DeltaTree storage — delta layer + stable layer, the merge), TiDB planner’s TiKV-vs-TiFlash cost-based routing + learner-read wait, SingleStore/HANA architecture docs (no source, read the papers/blogs as specs), DuckDB-inside-Postgres extensions (pg_duckdb/pg_analytics) as the budget HTAP pattern.
- Papers: “TiDB: A Raft-based HTAP Database” (VLDB’20 — the must-read), “Hyper: A Hybrid OLTP&OLAP Main Memory Database System Based on Virtual Memory Snapshots” (ICDE’11), “SAP HANA Database: Data Management for Modern Business Applications” (SIGMOD Record ’12), “F1 Lightning: HTAP as a Service” (VLDB’20), “Real-Time Analytics: The HTAP Survey” (Özcan et al., SIGMOD’17 tutorial).
- Build & bench: measure the interference first — run topic 22’s YCSB-style write workload concurrently with full-column scans on one engine and chart p99-write vs scan-throughput; then split: maintain a columnar replica from your WAL/changelog and re-measure both sides + the freshness lag; implement learner-read semantics (reads wait for a replication watermark) and price the wait.
- Capstone M32: HTAP FalkorDB — the M27 changelog feeds a read-optimized analytical replica (columnar property store from M12 + stable GraphBLAS matrices without delta overlays from M20); route topic-24
CALL algo.*and heavy aggregations to it with a declared freshness bound (AS OFwatermark), keep OLTP mutations on the primary; bench interference eliminated vs the single-copy engine, TiDB-style.
After the plan (ideas backlog)
- FPGA / SmartNIC / computational storage offload (beyond GPU)
Progress
Status: todo → in progress → done. Add a one-line takeaway when done.
| # | Topic | Status | Takeaway |
|---|---|---|---|
| 0 | The Performance Toolbox | done | Benchmarks lie by default: my own cache_ladder measured its own cache footprint until the walker carried state; flamegraph showed 21% of HashMap lookup time is SipHash; DRAM ladder verified at ~1/5/100 ns. |
| 1 | Storage Engine Landscape: B-Tree vs LSM | in progress | |
| 2 | In-Memory Structures: Hash Tables, Skip Lists, Tries | todo | |
| 3 | B-Tree Internals & Paged Storage | todo | |
| 4 | LSM-Tree Deep Dive | todo | |
| 5 | Durability: WAL, fsync, Crash Recovery | todo | |
| 6 | Buffer Pool & Memory Management | todo | |
| 7 | Networking, Protocols & Event Loops | todo | |
| 8 | Transactions & MVCC | todo | |
| 9 | Concurrency: Latches, Lock-Free & Epochs | todo | |
| 10 | Query Engines I: Parsing, Planning, Optimization | todo | |
| 11 | Query Engines II: Execution Models | todo | |
| 12 | Columnar Storage & Analytics | todo | |
| 13 | Graph Engines | todo | |
| 14 | Vector Search | todo | |
| 15 | Replication, Consensus & Distribution | todo | |
| 16 | Testing & Correctness Engineering | todo | |
| 17 | SIMD & Hardware-Conscious Data Processing | todo | |
| 18 | GPU Acceleration for Databases | todo | |
| 19 | JIT & Query Compilation | todo | |
| 20 | Sparse Linear Algebra & GraphBLAS Internals | todo | |
| 21 | Formal Methods & Verification | todo | |
| 22 | Standard Benchmarks: TPC-H, TPC-C, YCSB, LDBC | todo | |
| 23 | Full-Text Search & Inverted Indexes | todo | |
| 24 | Advanced Graph Algorithms & Analytics | todo | |
| 25 | Graph Neural Networks & Graph ML | todo | |
| 26 | Indexing & Probabilistic Data Structures | todo | |
| 27 | Streaming & Incremental View Maintenance | todo | |
| 28 | Cloud-Native & Disaggregated Storage | todo | |
| 29 | Distributed Transactions | todo | |
| 30 | Time-Series Engines | todo | |
| 31 | CRDTs & Multi-Master Replication | todo | |
| 32 | HTAP Architectures | todo |
Capstone milestones (falkordb-rs-next-gen from scratch)
| Milestone | Depends on topic | Status |
|---|---|---|
| M0 workspace + bench harness + reference baselines | 0 | done — workspace + workload gen + smoke bench + BASELINES.md (reference @ e8a44d25) |
| M1 storage-backend abstraction | 1 | todo |
| M2 attribute store + string pool + datablocks | 2 | todo |
| M3 B+tree backend (properties + range indexes) | 3 | todo |
| M4 LSM backend + backend shootout | 4 | todo |
| M5 WAL + crash recovery | 5 | todo |
| M6 buffer pool | 6 | todo |
| M7 RESP server (GRAPH.QUERY wire-compatible) | 7 | todo |
| M8 MVCC copy-on-write graph | 8 | todo |
| M9 threadpool + parallel execution | 9 | todo |
| M10 Cypher parser + binder + planner | 10 | todo |
| M11 vectorized runtime | 11 | todo |
| M12 columnar attribute storage | 12 | todo |
| M13 naive adjacency graph core (baseline) | 13 | todo |
| M14 vector index + distance kernels | 14 | todo |
| M15 replication → Raft | 15 | todo |
| M16 openCypher TCK runner + DST + fuzzing | 16 | todo |
| M17 SIMD kernels | 17 | todo |
| M18 GPU backend (experimental) | 18 | todo |
| M19 Cypher expression JIT | 19 | todo |
| M20 sparse-matrix/delta-matrix core (the heart) | 20 | todo |
| M21 TLA+ spec + Lean invariant proof | 21 | todo |
| M22 LDBC suite + 3-way FalkorDB shootout | 22 | todo |
| M23 full-text index + hybrid search | 23 | todo |
| M24 algorithm library as Cypher procedures | 24 | todo |
| M25 GNN embeddings pipeline + GraphRAG queries | 25 | todo |
| M26 MVCC secondary indexes + bloom + HLL count path | 26 | todo |
| M27 standing Cypher queries (incremental results) | 27 | todo |
| M28 tiered object storage + graph branching | 28 | todo |
| M29 cross-shard transactions + pattern matching | 29 | todo |
| M30 temporal graph + time-travel queries | 30 | todo |
| M31 active-active graph (CRDT merge) | 31 | todo |
| M32 HTAP: changelog-fed analytical replica + freshness-bound routing | 32 | todo |
Session log
- 2026-07-12 — restructure rollout: all 179 remaining reading guides across topics 00-25 and 27-32 rewritten as self-contained chapters (topic 26 was the pilot, previous entry), executed by 8 parallel agents (4 topics each) against the same spec: concept-first H1 titles replacing “Reading guide — …”, Sources blocks replaced by 2-4 sentence framing leads, one inline Rust-ish code sample of the core algorithm added where the guide lacked one (skips documented for pure surveys / guides already carrying equivalent code), all existing content kept (diagrams, line-anchor tables, questions, tie-backs),
## Referencesappended with Papers (arXiv) + Code (GitHub) links carrying the old reading advice, filenames unchanged to avoid link churn; the 32 topic READMEs’ guide lists updated to the new titles; one agent (topics 16-19) hit context limits with 2 files left — reading-umbra-tidy-tuples.md (retitled “Umbra & copy-and-patch: the war on compile latency” + copy-and-patch memcpy/patch-holes sample + References) and reading-sqlite-vdbe.md (References) finished by hand; SUMMARY.md link titles regenerated centrally by script from the actual on-disk H1s rather than agent reports (179 updated); verification: zero old-style H1s, zero Sources blocks, zero guides missing References, fence-and-backtick-aware bare-angle-bracket scan across all 186 guides found one genuine hazard (HashMap<fd, handler>in topic 7’s ae guide, backticked), mdBook build green. The whole book now reads as chapters instead of pointers. - 2026-07-12 — book-quality pass, three moves: (1) paper audit against dbscholar’s citation-PageRank ranking (rmarcus.info — pulled the underlying data.json, 11,867 SIGMOD/VLDB/CIDR/PODS papers) — resources/papers.md gains a “Modern systems & directions” section and 6 topic READMEs gain “Further references” (Kung-Robinson OCC ’81, Calcite, Spark SQL, Kipf/Neo/Bao learned optimization, Photon, Velox, GAMMA, Dremel, Lakehouse+Delta Lake, MillWheel, CockroachDB); (2) all
~/repos/...code references linkified to their GitHub repos (scripted, fence-aware: 143 links across 115 files) so the online book’s code pointers resolve; (3) restructure demo on topic 26 — all 7 reading guides rewritten as self-contained chapters: concept titles (“HyperLogLog: count distinct in 12 KB” not “Reading guide — …”), framing lead instead of a Sources block, an inline Rust code sample of each core algorithm (HLL add/merge + Ertl count skeleton, blocked-bloom 6-probe loop with golden-ratio remix, cuckoo kick loop with the XOR involution, PGM shrinking-cone add_point, roaring galloping intersect, BRIN one-sided range prune, Morton interleave64 magic masks), and a “## References” section at the bottom (papers with arXiv links, code with GitHub links); filenames kept (reading-*.md) to avoid link churn; SUMMARY.md + README titles updated; mdBook build verified locally (mdbook-mermaid install + build green). If the format lands, roll it out to the other 32 topics. - 2026-07-12 — PLAN.md expansions backfilled into the four already-scaffolded topics (the plan-only commit 2fb095a now has matching study material): topic 7 gains “Bolt: the third answer” — RESP/pgwire/Bolt framing-typing-streaming table (Bolt’s PULL{n}/DISCARD = protocol-level backpressure, §4’s problem solved at the wire) + reading-bolt-packstream.md anchored on FalkorDB’s removed Bolt server read frozen via
git show 0b11a00b3^:src/bolt/(#2170, 2026-07-08): session-sequence ASCII with handshake bolt_api.c:803/version-clamp :845-864, RUN-executes-but-PULL-streams :467-482/:504-521 decoupling, PackStream marker nibbles bolt.c:11/:21/:36 + graph types Node 0x4E/Rel 0x52/Path 0x50 in the type system, why-was-it-removed as a question, M7 stretch = Bolt listener beside RESP; topic 13 gains “The query-language landscape” — 6-language table (Cypher/GQL/SQL-PGQ/SPARQL/Gremlin/Datalog) on model/matching/composability/pushdown + reading-query-languages.md (SIGMOD ’22 GQL+PGQ paper, count-2-paths-in-a-triangle under homomorphism/isomorphism/trail as the same-pattern-three-answers demo, family-tree mermaid, kuzu Cypher.g4 anchor, M13 rule: keep the AST GQL-shaped — quantified path patterns + explicit path-mode field); topic 20 gains “Parallelism: OpenMP inside SuiteSparse, rayon in Rust” — saxpy3’s costed static scheduling (coarse/fine tasks GB_AxB_saxpy3.c:22-48, nthreads-from-flopcount slice_balanced.c:418, parallel flopcount pass :219) vs rayon work-stealing (join/mod.rs:93 inline+push+steal, registry.rs:248 crossbeam_deque Stealer) + reading-openmp-vs-rayon.md with the static-vs-stealing trade table, no-native-Rust-GraphBLAS note (crates are FFI), new M20 checkbox: document each OpenMP→rayon mapping decision; topic 26 gains “Geo indexes: 2D keys through 1D indexes” — valkey GEO as geohash-in-a-zset (interleave64 geohash.c:52 → 52-bit Morton score, geohashEstimateStepsByRadius helper.c:64, 9-cell scan geo.c:375 + haversine verify = bloom’s candidate-then-verify control flow) + reading-geo-indexes.md (Z-order seams vs Hilbert, Guttman R-tree/GiST, S2 prefix-containment vs H3 hexagons, M26 mapping: Morton key through the existing sorted property index). SUMMARY.md gains the 4 new guides so they appear in the book. - 2026-07-11 — topic 32 scaffolded (added to the plan this session, so the scaffold-all-topics run is complete again): study guide (the HTAP problem measured — bench lane 1 run: 1M-row store behind one coarse lock, fixed 2 s window per mode: writes alone = 11,438,647 writes at p99 333 ns; writes + a free-running full scanner = 69 writes with p99 7.49 seconds — not slowdown, starvation: std Mutex is unfair, the scanner re-wins the lock after each ~0.6 ms scan (3261 scans) and the parked writer never gets in; interference at its worst is zero writes, and the coarse lock is deliberate — mitigations ARE the topic; the freshness/isolation/cost trilemma triangle; the architecture-menu table HANA-delta+main / HyPer-fork() / TiFlash-learner / F1-Lightning-CDC / pg_duckdb-offload keyed on one-copy? freshness isolation; the changelog-is-the-glue mermaid tying topic 27’s thesis to every split design; the same-fold-four-costumes thread: topic 4 LSM minor compaction = HANA delta merge = TiFlash segmentMergeDelta = FalkorDB delta-matrix flush), 4 reading guides (TiDB VLDB ’20 — columnar-copy-as-Raft-LEARNER so the replica costs no write-quorum latency, freshness-is-a-WAIT with tiflash LearnerRead.cpp:35 doLearnerRead + :61 waitIndexTimeout as the anchor, one-planner-two-engines via tidb find_best_task.go:535/:1841/:1878 TiFlash-paths-retained-so-cost-not-topology-decides, learner-log vs CDC-changelog trade question; TiFlash DeltaTree — Segment.h:84 delta-over-stable ASCII with MemTableSet/DeltaValueSpace.h:65 as a-little-LSM-inside-the-delta, Delta/MinorCompaction.h why-compact-the-delta-at-all, DeltaIndex.h:27 as the structure that makes delta+stable merge reads cheap (the thing our scan_sum_a lacks), DeltaMergeStore.h:668 segmentMergeDelta with our merge_preserves_scans test as its correctness condition, MVCC-versions-in-both-layers GC question linking topic 31’s causal stability; HyPer ICDE ’11 + HANA SIGMOD Record ’12 paired as the one-copy family — fork() CoW-page ASCII (snapshot cost ∝ dirtied pages = MVCC-where-the-version-chain-is-the-page-table, GC is exit()), snapshot-ages-until-re-fork = lane 3’s apply interval in OS clothing, HANA delta+main = our replica.rs minus segmenting, HANA’s-trilemma-corner-is-exactly-what-lane-1-measures; F1 Lightning VLDB ’20 + Özcan SIGMOD ’17 survey — CDC-fed HTAP with zero OLTP changes, safe-timestamp = applied_lsn productionized (reads never wait, served stale-but-consistent at the max fully-applied ts — the opposite choice from doLearnerRead), Changepump ordering question = topic 27 changelog + topic 29 Spanner timestamps, the survey’s copies×engines quadrant with every cell trading the same three currencies), experiments crate compiles: row.rs PROVIDED (RowStore = rows + every-write-appended-to-log changelog + scan oracle + skewed_key + percentile — 2 provided tests pass) — replica.rs (ColumnarReplica delta+main: apply/scan_sum_a/merge_delta with delta-overrides-main + highest-lsn-per-key-wins + merge-preserves-scans-and-sorts contracts, freshness_is_visible via applied_lsn gap — TiFlash DeltaTree in miniature, 4 tests), learner.rs (read_wait over an apply schedule: first-batch-covering-read_index, Some(0) if already applied, None = waitIndexTimeout — doLearnerRead as arithmetic, 3 tests) are
todo!()stubs — 7 tests fail as todo panics; htap_bench lane 1 RUN (table above; original fixed-200K-writes design serialized into minutes behind ~0.6 ms scan lock-holds — switched to fixed-2s-window per mode, making the writes-completed collapse the headline number), lanes 2 (scan row-vs-delta-heavy-vs-merged + freshness max-lsn-gap vs batch 1K/10K/100K) and 3 (learner-read wait distribution vs apply interval 1/10/100, 50K reads demanding lsn==now) armed behind catch_unwind; notes.md predicts lanes 2-3 + records the starvation surprise + the RwLock exercise; M32 log: Lightning-shaped not TiFlash-shaped (no consensus group until M15, decoupling = zero primary changes), M27’s changelog feeds a delta-matrix replica, router advertises applied_lsn safe timestamps + freshness-bound routing with read_wait-or-fallback, before-shot recorded: 69 writes/2s when analytics shares the copy, success = restoring the 11.4M with scans elsewhere. Cloned tiflash + tidb. - 2026-07-11 — topic 31 scaffolded (the last topic): study guide (consensus-vs-CRDT as the same problem in opposite currencies — agree-on-an-order vs design-so-order-doesn’t-matter, 1-RTT vs 0-RTT, unavailable-in-minority vs available-under-any-partition; SEC via join-semilattice mermaid; CvRDT/CmRDT table with where-each-lives-in-this-crate; the zoo table clock/lww/counter/orset/rga/graph with the one idea per structure; LWW’s lie measured — lane 1 run: two replicas, 20K writes each, LWW map: 10 hot keys + sync-every-write loses 94.98% of writes, 1000 keys + sync-every-100 loses 88.34%, even 100K keys + rare sync loses 12.45% — “eventually consistent” without conflict semantics is not a semantics, priced; sequence-CRDT integration ASCII with the interleaving dragon; code-reading table across all five cloned repos), 4 reading guides (Shapiro SSS’11 + INRIA RR-7506 — SEC’s three clauses, the CvRDT⇔CmRDT equivalence proof, the catalog reading map ending at §4’s graphs where concurrent addEdge∥removeVertex is declared application-specific = the dangling-edge problem M31 inherits, causal-stability GC question; Kleppmann arc JSON-CRDT ’17 + move-op ’21 + Local-First Onward!’19 — ops-address-identities-not-paths with automerge op_set2/op.rs:52 succ as deletion-by-successor-ops, the concurrent-move duplicate/cycle problem and its undo/redo total-order fix, the does-move-survive-in-graphs M31 design question; sequence CRDTs in production — yrs block.rs:160 ID=our-Dot / :1302 Item with origin+right_origin=YATA’s pair vs RGA’s single parent / :1415 Item::integrate as the loop our rga.rs apply implements plus run-coalescing splits, diamond-types merge.rs:142 self-described “bastardization” + yjsspan.rs:29 INSERTED/NOT_INSERTED_YET retreat/advance = only-be-a-CRDT-at-merge-time, Loro/Fugue maximal-non-interleaving with the letter-soup demo, automerge-vs-loro bench as scratch-project exercise per deps convention; cr-sqlite as THE-database-goes-multi-master — crsql_as_crr clock-row-per-CELL diagram, local_writes/mod.rs:83-133 db_version bookkeeping = Lamport-clock spine, compare_values.rs version-tie-broken-by-VALUE-comparison = deterministic convergence with zero clock trust, changes_vtab.rs replication-endpoint-as-virtual-table, delete-wins-for-rows vs our add-wins-for-nodes tension question, the M31 change-feed schema design question), experiments crate compiles: clock.rs PROVIDED (Dot + VClock tick/covers/merge/partial_cmp-None-defines-concurrent, modeled on automerge clock.rs:109/:145) + lww.rs PROVIDED (register/map with (ts,replica) total order; merge counts its own discards so lane 1 can price the lie) — 6 provided tests pass — counter.rs (G/PN with semilattice-law tests + why-PN-is-two-G-Counters), orset.rs (add-wins over Dots: add-tags-fresh-dot / remove-kills-observed-dots, concurrent-add-beats-remove + remove-covers-all-observed-tags + seeded-permutation convergence), rga.rs (insert-after-parent + skip-larger-(counter,replica)-siblings + tombstones-still-anchor, idempotent apply, delete∥insert-after-it convergence), graph.rs (OR-Set nodes/edges + LwwMap props composition; dangling-edge-hidden-NOT-deleted test: edges()-filters-to-visible-endpoints, re-add-resurrects; props keyed by node id survive remove/re-add vs automerge’s key-by-creation-op as an exercise) are
todo!()stubs — 18 tests fail as todo panics; crdt_bench lane 1 RUN (table above; also caught state-based-sync’s quadratic cost live — sync_every=1 ships the whole map per write, workload shrunk and the delta-CRDT motivation written into the bench comment), lanes 2-4 armed behind catch_unwind (OR-Set gossip storm + tombstone census, RGA 50K-char trace + tombstone bloat, graph dangling storm 100-removes∥500-edge-adds + resurrection count); notes.md flags lane 1’s honest caveat (counts merge-time discards only — locally-overwritten writes don’t show, so it’s a lower bound); M31 log: node/edge identity must be Dots not user ids (cr-sqlite’s auto-increment-PK trap), dangling policy locked hide-not-delete, props LWW-with-HLC (topic 29), anti-entropy v1 whole-state → v2 db_version-watermark deltas, deliverable = same workload through M15 Raft vs active-active with latency histogram + concrete-conflict table. All 32 topics scaffolded. - 2026-07-10 — topic 30 scaffolded: study guide (the-shape-of-the-problem ASCII — regular append-mostly writes vs range+selector+aggregation reads; the baseline finding measured: delta+varint lands at a shape-blind 11.00 B/sample because raw f64 values dominate — whatever the codec does about timestamps is a rounding error until it attacks the value bytes, hence XOR; Gorilla→Prometheus/VM→IOx lineage mermaid with Monarch and BtrDB as the bracketing extremes; five-system table on codec / time organization / label index / out-of-order policy; TSDB=LSM-keyed-by-time thesis with retention=drop-the-oldest-level), 4 reading guides (Gorilla VLDB ’15 + prometheus chunkenc/xor.go — prediction-error framing with the dod/XOR ASCII, paper bucket table vs prometheus’s retuned 14/17/20 buckets (:195-208) as buckets-are-workload-parameters, writeVDelta :226 window reuse, :396 tDelta+=dod as the whole model in one line, no-random-access-by-design, entropy-floor bit accounting question; prometheus tsdb — full architecture ASCII head/WAL/2h-blocks/exponential compaction, head_append.go:436/:481/:688-693 as the exact head.rs contract (ErrOutOfOrderSample vs ErrTooOldSample), OutOfOrderTimeWindow head.go:168 quarantine design, MemPostings postings.go:60/:403 = topic 23’s inverted index with labels as terms, the two famous failure modes high-cardinality + churn; VictoriaMetrics + InfluxDB 3 paired as two-rebuttals — VM partition.go:75 rawRows→parts LSM-said-out-loud, nearest_delta2.go:15 byte-aligned varint batches with optionally-lossy precisionBits vs Gorilla’s exact bits, index_db.go:124 tagFilters cache + churn invalidation, vs IOx WAL→Arrow QueryableBuffer→Parquet-on-object-store (influxdb3_wal/lib.rs:75-98 SnapshotTracker, queryable_buffer.rs:41) as topic 28’s landing-zone applied to metrics, vertical-integration-vs-commodity-formats trade table, how-much-of-Gorilla’s-win-was-really-sorting question; Monarch VLDB ’20 + BtrDB FAST ’16 — monitoring-must-not-depend-on-what-it-monitors ⇒ RAM-first lazy durability, push-vs-pull, distribution-typed values as the schema cure for cardinality, query pushdown; BtrDB’s aggregate tree — min/mean/max/count per 64-way node ⇒ query cost ∝ pixels not samples, downsampling as the index structure itself, CoW versions), experiments crate compiles: gen (scrape jitter, gauge/counter/constant/random shapes, OOO arrivals, label sets with the unique-instance cardinality bomb) + bits (MSB-first BitWriter/Reader + sign_extend so the stub is the algorithm not bit plumbing) + baseline (zigzag varint delta) PROVIDED — 7 provided tests pass, lane 1 RUN (table above, decode ~270-330 Msamples/s — byte-aligned codecs have a real throughput edge over bit-packed Gorilla, the actual design axis is ratio-vs-decode-speed) — gorilla.rs (paper dod buckets + XOR leading/trailing windows; bit-exact roundtrip incl. bucket edges, constant ≤~2 bits/sample, gauge beats raw 3×, random must FAIL to compress >8 B/sample — the codec wins on regularity not magic), head.rs (in-order fast path + bounded OOO window + TooOld refusal + LWW merge flush that feeds the in-order-only encoder — prometheus semantics exactly), index.rs (MemPostings-style (name,value)→sorted-ids + shortest-list-first k-way intersect, brute-force oracle, cardinality-bomb-counted test) are
todo!()stubs — 15 tests fail as todo panics; tsdb_bench lanes 2-4 (gorilla ratios per shape, OOO tax sweep 0-50%, selector latency at 100K series) armed behind catch_unwind; notes.md predicts all lanes; M30 log: history chunks per (entity, attribute),MATCH ... AT TIME t= latest_write_before ≤ t so M29’s MVCC read path generalizes to time-travel, storage split by age (custom hot chunks → M28 Parquet cold), Gorilla dod survives for changelog timestamps but property values need dictionary+RLE not XOR, BtrDB-shaped rollup tree over the M27 changelog for graph-evolution queries. - 2026-07-10 — topic 29 scaffolded: study guide (the-problem-priced motivation table — measured conflict probability of the bank workload itself: 0.3% of 8-txn batches collide at zipf θ=0.5 but 29.9% at 0.9, 86.2% at 1.1, 99.6% at 1.3 — contention is the common case at real-workload skew; design-space mermaid rooted at textbook 2PC with the three escapes labeled on the edges — move-the-decision-into-the-data (Percolator), replicate-the-coordinator (Spanner), remove-runtime-agreement (Calvin), decompose+batch (FDB); one-table five-system summary keyed on concurrency control / clock / cross-shard atomicity / blocking window; Percolator-in-six-lines with THE-COMMIT-POINT marked), 4 reading guides (Percolator OSDI ’10 + TiKV — three-column-families ASCII (data/lock/write with write-CF-as-commit-index), lifecycle sequenceDiagram with the any-reader-resolves note, TiKV walk actions/prewrite.rs:37 (pessimistic_action + secondary_keys args as post-paper hardening) → commit.rs:64 with the :57 duplicate-commit-returns-Ok idempotency arm → check_txn_status.rs:92/:241 + MissingLockAction :458 as production resolve_lock → cleanup.rs:24 Rollback records our sim skips → latch.rs/scheduler.rs local-vs-distributed conflict split, txn_status_cache; Spanner OSDI ’12 + HLC OPODIS ’14 paired — bound-the-ERROR-vs-bound-the-SKEW fork diagram, commit-wait derivation, HLC rules + the l≤max-pt anti-Lamport-drift bound, uncertainty-interval restarts, CRDB walk hlc.go:38/:411/:471/:517 (UpdateAndCheckMaxOffset crashes the node — maxOffset is a promise) + txn_coord_sender.go:113 interceptor stack + txn_interceptor_committer.go:128 parallel commits with STAGING-is-implicitly-committed :195-205 as Percolator’s-resolve-idea-shaving-a-latency-round; Calvin SIGMOD ’12 — agree-on-inputs-not-outcomes diagram, sequencer/scheduler/executor layers, deterministic-locking-kills-both-deadlock-and-2PC, OLLP reconnaissance for dependent txns with the graph-traversals-are-the-ultimate-dependent-txn M29 question; FoundationDB SIGMOD ’21 — unbundled roles ASCII, ConflictSet.cpp:947 detectConflicts over the :224 SkipList as the-whole-SI-check-in-one-data-structure, CommitBatchContext :504 batch-is-the-unit, masterserver-is-barely-a-counter, ResolverBug.cpp injectable-wrong-answers as DST culture beyond crash injection, vs-Calvin and vs-Percolator design reads), experiments crate compiles: kv.rs PROVIDED (the three Percolator column families as HashMaps/BTreeMap, strictly-monotonic TSO, latest_write_before range scan, 2-shard cluster, Zipf transfer workload — 4 provided tests pass) — tpc.rs (2PC coordinator with 4-point CrashPoint injection + recovery-from-durable-state-only; blocking_window_demonstrated test names the flaw), percolator.rs (get/prewrite/commit_primary/commit_secondaries/resolve_lock with roll-forward-iff-primary-committed tests, no-lock-leak on failed prewrite, total-conserved-after-rollback), hlc.rs (send/recv rules; monotonic-under-backward-clocks, l-bounded-by-max-pt over 1000 skewed messages, concurrent-events-collide-without-node-id-tiebreak as an assert_eq teaching test) are
todo!()stubs — 14 tests fail as todo panics; txn_bench lane 1 RUN (conflict table above), lanes 2 (abort rate vs θ + bank invariant) and 3 (20K-txn crash storm, crash every 100th cycling 4 points, blocked_aborts counts the blocking window empirically) armed behind catch_unwind; notes.md predicts lane 2/3 numbers before implementation; M29 log: shard by node id, cross-shard edge = prewrite both adjacency sides with primary on u, supernodes are the Zipf head — per-shard adjacency segments turn the WW hotspot into scatter-gather reads, every protocol step gets a kill point with no-dangling-half-edges as the invariant. - 2026-07-10 — topic 28 scaffolded: study guide (the latency ladder measured and priced — local NVMe p50 0.10 ms vs raw S3 p50 14.17 / p99 112.99 ms = 140× median, 940× tail, and why everyone moved anyway: $/GB, 11-nines durability = replication-is-someone-else’s-problem, scale-to-zero; the 2008→Snowflake→Aurora→Socrates→Neon→SlateDB lineage mermaid; Neon’s four-box data flow with the safekeepers-commit-fast / pageserver-serves-pages split; five-system design-space table with what-crosses-the-network as the axis; WAL-rule-promoted-to-architecture rosetta linking topic 27’s Kafka thesis), 5 reading guides (Aurora SIGMOD ’17 — the-log-is-the-database, 6-copy 4/6 AZ+1 quorums over 10 GB protection groups, 35× network amplification killed, VDL-replaces-2PC question, commit=log-quorum-ack + recovery-without-REDO-at-compute; Socrates SIGMOD ’19 — durability≠availability as THE decomposition, XLOG landing zone vs page servers vs XStore mapped onto topic 5’s WAL lifecycle and onto Neon components, RBPEX = buffer pool made restart-durable; Snowflake SIGMOD ’16 + Building-a-DB-on-S3 SIGMOD ’08 paired — the prescient paper’s three blockers (eventual consistency/no CAS/request cost) vs what fixed each (strong consistency 2020, conditional PUT 2024, immutability routed around all three), micro-partitions as CoW-clone-by-file-list, min/max pruning = topic 26’s BRIN at cloud scale; Neon code walk — get_rel_page_at_lsn pgdatadir_mapping.rs:258 → Timeline::get :1227 → LayerMap::search layer_map.rs:448 as an LSM over (page, LSN), walredo.rs:173 = REDO on the read path in a sandboxed Postgres, branch_timeline_impl tenant.rs:4985 O(1) branches + the timeline.rs:4548 ancestor walk our stub reimplements, branch-aware GC retain-point question; SlateDB+Quickwit S3-first code walk — tablestore.rs:835 block-granular ranged GETs, cached_object_store part cache = our cache.rs in production form, fence.rs:105 CAS-epoch fencing = consensus outsourced to S3 conditional PUT, clone.rs:38 zero-copy clones, quickwit bundle hotcache footer + TimeoutAndRetryStorage :37 hedging with the AWS-recommends-it citation, pathology→countermeasure convergence table), experiments crate compiles: virtual-latency sim (charged-not-slept lognormal S3 with 2% 8× stragglers, NVMe, scripted Fixed model for exact contract tests) + block store + zipf + percentiles PROVIDED — 6 provided tests pass, tier_bench provided lanes RUN (numbers above) — LruBlockCache+TieredReader (touch-protects, 1/8-cache zipf hit-rate >50%, block-sharing hits), hedged_get (scripted 50ms-primary/1ms-backup/10ms-deadline ⇒ exactly 11ms, p99-halves-at-<10%-extra-GETs on straggler-heavy S3), BranchStore::get (parent-prefix visibility, sibling isolation, PITR historical branch points, 100-deep chains, branching-copies-nothing proven by version_count) are
todo!()stubs — 13 tests fail as todo panics; notes.md predicts cache-fixes-the-median-hedging-fixes-the-tail and flags the O(n)-eviction-scan wall-time trap; M28 log: L0+WAL stay local (landing-zone lesson), manifest-CAS fencing not leases, branch at SST-list not page granularity, image-layer materialization deferred until ancestor walks profile hot. - 2026-07-10 — topic 27 scaffolded: study guide (recompute-is-the-enemy with the priced motivation table — full recompute per 100-change batch: triangles 97.2 ms / wedge self-join 894.3 ms / re-BFS 24.7 ms vs µs-scale incremental targets; the one algebraic idea as a table — LINEAR ops stream deltas statelessly, BILINEAR joins need arranged inputs via Δ(A⋈B)=ΔA⋈B+A⋈ΔB+ΔA⋈ΔB, NONLINEAR distinct/aggregates need integrals; DBSP I→Q→D mermaid; timestamps/watermarks section: timely frontiers are proofs where Flink watermarks are heuristics; four-system comparison timely/DBSP/Materialize/RisingWave), 5 reading guides (Naiad+timely — could-result-in pointstamp protocol, MutableAntichain frontier.rs:380/update_iter :533, ChangeBatch :16 progress-updates-are-Z-set-shaped, worker.rs:235 step as the topic-7 event loop one layer up, the rosetta table frontier=vacuum-watermark; differential — consolidation.rs:24 = our from_updates verbatim, arrangements as LSM-of-batches with advance=compaction, join_traces join.rs:69 with the fuel/effort loop :348-395 as operator-level cooperative yielding, iterate.rs:192 Variable + bfs.rs:101-107 as the 40 lines our stub can’t do, why deletion-in-recursion needs lattice times; DBSP — Q^Δ=D∘Q∘I with the chain rule as the compositional bombshell, feldera anchors z1.rs:221/integrate.rs:85/differentiate.rs:38/join.rs:123-350/delta0.rs, nested-circuits-vs-lattice trade, the M27 mapping: delta matrix DP−DM=ΔA and wedges need NO new state because the integrals ARE the adjacency matrices; Materialize+RisingWave — dogs^3 delta_join.rs:47 + half_join :315/:402 avoiding intermediate arrangements, indexes-are-arrangements-are-memory, RisingWave Op enum stream_chunk.rs:45 as Z-set-weights-as-protocol, hash_join.rs:158 degree tables :269 as hand-rolled weight bookkeeping vs one consolidation rule, barrier checkpoints, single-writer-gets-the-hard-parts-free table; Kafka NetDB’11 — dumb-broker/smart-consumer, offset=LSN rosetta, log-compaction=arrangement-advance=LSM-GC same operation three communities, exactly-once = where-do-offsets-live, M27’s raw-log-vs-result-delta subscriber decision), experiments crate compiles: ZSet with consolidation + the distinct-is-not-linear load-bearing test, churn generator with set-semantics guard, full-recompute oracles (sorted-intersect triangles, hash join with weight multiplication, BFS) PROVIDED — 6 provided tests pass, ivm_bench baselines RUN (numbers above) — delta_join+IncrementalJoin (algebra-exact vs join(A+ΔA,B+ΔB)−join(A,B), 30-batch drift-free, deletes-retract), IncrementalTriangles (oracle-tracking under churn, K4-minus-edge=−2, <4K probes for 20 changes on 40K edges), SemiNaiveReach (insert-only BY DESIGN — deletion is differential’s lattice territory, documented; matches re-BFS per batch, ≤4 relaxations/edge EVER, intra-component edges free) are
todo!()stubs — 9 tests fail as todo panics; notes.md flags the honest suspicion that IncrementalJoin’s Vec-merge state integration may dominate and re-derive why arrangements exist. - 2026-07-10 — topic 26 scaffolded: study guide (indexes-are-bets framing with the measured motivation table — point-miss binary search 167 ns / BTreeMap 218 / HashSet 24 at 224 MB, vs blocked bloom’s ~15-25 ns at 12 MB target; three-families ASCII filters/sketches/learned; bloom math block with the reproduce-it FPR derivation; bloom→blocked→cuckoo→xor→ribbon lineage mermaid; HLL sparse-30-bytes-to-dense-12KB story; PGM ε-window thesis), 6 reading guides (bloom→ribbon over RocksDB code — FastLocalBloomImpl bloom_impl.h:144 golden-ratio probe remix vs LegacyBloomImpl :364, CacheLocalFpRate :42 as the Poisson-crowding honesty function, ribbon as banded GF(2) solve with StandardBanding ribbon_impl.h:471 / num_starts_=slots−kCoeffBits+1 :504 and the build-can-fail-vs-monotone question; cuckoo+xor — partial-key involution getAltHash cuckoo.c:122, RedisBloom’s LSM-of-subfilters growth CuckooFilter_InsertFP :256 vs the paper’s fail-at-MAX_KICKS, delete-a-false-positive-corrupts-someone-else contract, xor peeling 1.23× vs bloom 1.44× vs ribbon 1.10×; HLL — hllPatLen :467 / Ertl tau+sigma :1016/:1033 replacing HLL++’s empirical bias tables, ZERO/XZERO/VAL sparse opcodes :380 with promote-at-3KB-or-rank>32, merge-is-a-semilattice AVX2 :1116; learned indexes — Kraska RMI’s no-error-bound flaw, PGM_SUB/ADD_EPS pgm_index.hpp:32-33 + optimal hull PLA piecewise_linear_model.hpp:96/:154-190 vs our simpler shrinking cone, ALEX gapped arrays predict_position alex_nodes.h:1448 + exponential search :1462 with the degrades-in-space-vs-time-vs-write-amp scoreboard; roaring internals extending topic 23 — Store enum store/mod.rs:28-31, ARRAY_LIMIT=4096/RUN_MAX_SIZE=2048 as pure arithmetic container.rs:9-11, 3×3 pairwise kernel dispatch, three-adaptive-encodings table roaring/HLL-sparse/GIN-varbyte; postgres indexam as the classical baseline — _bt_search nbtsearch.c:100 + Lehman-Yao moveright :211, GIN varbyte ginCompressPostingList ginpostinglist.c:196 + pending-list-as-mini-LSM, BRIN bringetbitmap brin.c:301 as the one-sided filter that’s 10,000× smaller than bloom when clustering holds), experiments crate compiles: splitmix64/hash2/fastrange PROVIDED (avalanche + coverage tests pass), filter_bench motivation lanes RUN — binary search miss 167 ns ≈ 23 dependent misses, BTreeMap 218, HashSet 24 ns at 224 MB, hit≈miss 169 (the walk is the cost, not the compare) — BlockedBloom (no-FN + FPR<2.5%@10bpk + <4×-theory + halves-8→16 tests), CuckooFilter (no-FN at 90% load, FPR<1%@12-bit, delete-leaves-others-intact — the test bloom can never pass, graceful-full-failure), Hll (err<3% at 1K/100K/5M, merge registers EXACTLY equal union’s), and LearnedIndex (window ≤2ε+2 always contains true pos, uniform-1M <2K segments, ε holds on hostile powers-of-2+quadratic mix) are
todo!()stubs — 15 contract tests fail as todo panics, notes.md has predict-before-measure table and the M26 call that learned indexes are NOT in scope (node IDs are dense — a plain array is already the perfect model). - 2026-07-10 — topic 25 scaffolded: study guide (message-passing-is-SpMM with receipts — the message_and_aggregate table showing GCNConv =
spmm(adj_t, x)at gcn_conv.py:273 and SAGEConv =spmm(..., reduce=mean)while GAT can’t fuse because attention recomputes the matrix values per forward; associativity-as-query-plan — (AX)W vs A(XW) swaps which term carries the big dimension, 90× on Cora; GraphRAG closed-loop mermaid graph→embeddings→M14 vector index→hybrid Cypher), 7 reading guides (node2vec KDD ’16 — second-order walk figure, p/q as BFS↔DFS knobs, the alias-table O(m·avg_deg) memory trap with rejection sampling as the fix, PyG Node2Vec.loss node2vec.py:135 as the SGNS reference; Kipf-Welling GCN — renormalization trick, gcn_norm anchors gcn_conv.py:45-71, GCN-forward-is-a-query thesis, oversmoothing as why-2-layers; GraphSAGE — sampling as a page budget (B·10·25 fan-out math), mean+lin_r≈concat, inductive-is-the-only-write-friendly-variant; GAT — SDDMM+softmax+SpMM kernel decomposition = topic 24’s masked SpGEMM, a_src/a_dst per-node split as factor-out-of-join, materialized-vs-computed view line running exactly between GCN and GAT; PyG message-passing machinery — 10-stop code walk, COO gather-scatter materializes an m×d temp (145 MB on our bench) vs CSR spmm’s zero temporaries = materialize-the-join vs pipeline-the-aggregate, message()-as-arbitrary-callable = Ligra’s F-with-CAS tradeoff third community; TransE — relations as translations, symmetric-relation collapse, link-prediction-is-an-ANN-query so M14 serves KG completion natively; GraphRAG-SDK with systems eyes — vector_store.py:344 queryNodes / :219 SET embedding write path, relationship_expansion’s ANN+k-MATCHes as the k+1-round-trip join to push down, multi_path’s client-side cosine rerank, router-as-planner-with-no-cost-model, four systems smells), experiments crate compiles: CSR + SBM generator (ground-truth labels; O(m) inter-block sampling not O(n²) Bernoulli) + ring-of-cliques + dense Mat/glorot/softmax + SpMM + row-norm adjacency + uniform walks + dense GCN oracle PROVIDED and run — SBM 16,384 vertices/566K edges: uniform walks 42.8 Msteps/s, SpMM 21.2 GFLOP/s = 81% of dense matmul’s 26.2 (64-wide feature rows amortize the gather — fat RHS forgives sparsity, the number that makes M25 plausible) — node2vec_walks (4 tests: degree-stationary dist, p=q=1 ≡ uniform, q orders exploration on ring-of-cliques, p orders backtrack rate), train_skipgram (SBM intra-block cosine must beat inter by 0.2), gcn_norm+gcn_forward (dense-oracle 1e-4, sorted rows, transform-before-aggregate) aretodo!()stubs. - 2026-07-10 — topic 24 scaffolded: study guide (per-source vs whole-graph algorithm map; frontier-world vs algebraic-world mermaid with the honest trade — per-vertex tricks like Afforest’s edge skipping vs LAGraph’s batched matrix frontiers and atomics-free bulk ops; rmat-vs-uniform baseline table making skew the headline), 6 reading guides (GAP arXiv:1508.03619 + gapbs anchors — sssp.cc:44’s redundant-relaxation-beats-bookkeeping bet, bc.cc:76 succ bitmap, cc.cc:69/:106/:129 Afforest’s three phases, tc.cc WorthRelabelling, and the 5-graph matrix as topic 22’s change-anything-different-number; Meyer-Sanders delta-stepping as the Dijkstra↔Bellman-Ford dial with gapbs thread-local-bins vs LAGr_SSSP’s MIN_PLUS tmasked vxm + three implementation traps; Brandes ’01 with the dependency-recurrence derivation exercise + gapbs-vs-LAGr_Betweenness table — the batched ns×n matrix frontier amortizes what frontier code cannot; Ligra PPoPP ’13 — edgeMapData ligra.h:235-272, the m/20 threshold :238, dense/sparse/denseForward, PageRank-degenerates-to-SpMV lesson, m/20 = Beamer α/β = dot-vs-saxpy; Louvain→Leiden Sci Rep ’19 — the disconnected-communities bug as topic 21’s greedy-destructive trap, refinement as egg’s keep-both-forms, ΔQ accumulator = SPA, aggregation = S·A·Sᵀ SpGEMM, determinism-needs-seeding for CALL algo.community; LAGraph analytics — FastSV7 mngp/hooking-as-one-mxv :102 + FASTSV_SAMPLES :335, TriangleCount’s six formulations with the :44 urand-flips-to-saxpy exception, PageRankGAP-vs-PageRank as benchmark-specs-fork-implementations, and FalkorDB’s proc_pagerank.c:197 already calling LAGr_PageRank = M24’s pattern exists, re-plumb it), experiments crate compiles: weighted CSR + RMAT/uniform generators + heap Dijkstra with pop counter + pull PageRank + degree-ordered triangle count + union-find CC + O(n³) definitional BC oracle PROVIDED and run — RMAT scale 16 vs uniform same n/m: 15,645,988 vs 5,428 triangles (2,883×) in 376/158 ms, PR 8-vs-6 iters (hubs slow L1 decay), Dijkstra 343K pops = 1.74×n stale-entry tax, 18,844 components at avg_deg 16 (RMAT’s leaf quadrant strands vertices) — delta_stepping (bucketed, extremes-must-still-be-exact test + relaxation counters), brandes (must match the O(n³) oracle exactly on n=128, then sample GAP-style), and afforest (partition-equal + <50%-of-m edges-inspected bound) are
todo!()stubs; RMAT skew assertion needed scale-aware bounds (19.1% top-1% share at scale 12, 36.6% at 16). - 2026-07-10 — topic 23 scaffolded: study guide (inverted-index anatomy ASCII — analyzer → FST term dict → TermInfo{doc_freq, postings_range} → 128-doc Δ-bitpacked blocks with {last_doc, max_score} skip data; write-path mermaid making Lucene-segments-are-an-LSM explicit — tiered LogMergePolicy works for text because queries fan out anyway; the two speed tricks: compression-with-random-access + score upper bounds), 6 reading guides + 2 cross-linked (Zobel-Moffat CSUR ’06 design-space map — TAAT vs DAAT, capped accumulators as 2006’s WAND, merge-based construction = LSM before Lucene; Robertson-Zaragoza BM25 derivation ladder — eliteness ⇒ tf saturation at K1+1 ⇒ the static ceiling WAND needs, mapped to tantivy bm25.rs:8-59 with the 1-byte-fieldnorm quantization question; Ding-Suel SIGIR ’11 block-max WAND with pivot diagram + four implementation traps (θ seeding, livelock-on-failed-refinement, k-boundary ties, docs-evaluated metric); Roaring — 4096 crossover derivation, kernel matrix, containers = GraphBLAS sparse↔bitmap lattice at 64K granularity; tantivy code walk — compression/mod.rs:3 128-blocks, skip.rs:93/:175/:186 SkipReader/block_max_score, term_info.rs:9-13, fst_termdict, block_wand_union.rs:8-24 find_pivot_doc, log_merge_policy.rs:20-24, 90-minute read order; RediSearch redisearch_rs Rust-rewrite — newly cloned — InvertedIndex core.rs:30/:75 chained varint IndexBlocks vs tantivy’s immutable bitpacked segments, Encoder-as-type-parameter monomorphizing 11 codecs, gc_marker/unique_id cursor validation ↔ delta-matrix wait, new-block-on-delta-overflow, and the finding that RediSearch has NO block-max WAND — scored unions walk everything), experiments crate compiles: zipf corpus (term id = rank, df(t0)=99.9%) + tf-counting index builder with per-128-block max-BM25 metadata + saturating-BM25 + exhaustive TAAT oracle PROVIDED and run — 100K docs/7.9M postings built in 335 ms, common∧rare [t0 t12000] top-10 walks 99,964 postings in 6.34 ms at ~32 ns/posting (hash accumulate dominates — Q1’s lesson again) while the rare term carries ~93% of the winning score = the WAND poster child measured, and vec two-pointer dense∧sparse AND costs O(|dense|) 52 µs for 172 hits = the roaring motivation measured — block-max
wand_topk(recipe in doc comment, must match oracle top-k while scoring <25% of the postings) and mini-Roaring (array/bitmap containers, 3 density-crossing oracle tests) aretodo!()stubs. - 2026-07-10 — topic 22 scaffolded: study guide (OLTP↔OLAP benchmark map + the number-is-four-choices mermaid workload/data/harness/metric; choke points one-liner table — Q1 tiny-group agg = expression bench, Q6 2%-scan = the GB/s headline, Q9 = optimizer punisher, TPC-C = the D_NEXT_O_ID hot counter institutionalized; benchmarking-sins checklist linking topic 0’s fair-benchmarking guide), 4 new reading guides + 3 cross-linked existing ones (Boncz TPCTC ’13 choke-point taxonomy with duckdb dbgen/queries dir open + hidden messages — uniform data is why JOB exists, Q1’s 4-6 groups make hash-agg invisible; YCSB SoCC ’10 + go-ycsb zipfian.go:92-165 anchors — zetan/eta/alpha math, the two fast paths, scrambled-fnv rationale, coordinated-omission warning; OLTP-Bench VLDB ’13 + benchbase TPCC anchors — keying/think times :85-100, NURand C_LAST load-vs-run constants :94-116, why nobody runs TPC-C honestly = 12.86 tpmC/warehouse; DuckDB tpch extension — dbgen as streaming TABLE FUNCTION tpch_extension.cpp:17-99 with answers/ shipped next to queries/ = benchmark-as-oracle, run-real-TPC-H-here recipe), experiments crate compiles: dbgen-lite lineitem + row-at-a-time Q1/Q6 oracles + YCSB A-F driver over BTreeMap with ns-percentile Hist PROVIDED and run — Q1 oracle 5.6 GB/s effective (HashMap per row even at 6 groups: CP1.2 measured), Q6 branchy oracle 15.7 GB/s (2% selectivity = perfectly-predicted branch, the crater is hiding at 50%), YCSB uniform A-F 2.88/4.15/3.72/4.40/1.11/2.85 Mops/s with E’s scans 4× a point read — Zipfian/Scrambled generators (the actual YCSB math with head-frequency-vs-theory statistical contract tests) and q1_flat/q6_branchless columnar lanes are
todo!()stubs; fixed a nearest-rank percentile off-by-one in the harness itself (harness bugs are results bugs). - 2026-07-10 — topic 21 scaffolded: study guide (tool-per-guarantee table proptest→TLC→SMT→Lean with cost axis; e-graph = union-find + hashcons + congruence closure with egg’s deferred rebuild = delta-matrix wait = LSM compaction — batch the invariant repair; equality-saturation loop mermaid; TLA+ spec-as-math with the measured state counts; Z3 DPLL(T) diagram + the euf_egraph.h:23 comment where Z3 cites egg back; Perceus RC as the Arc::make_mut compiler pass), 5 reading guides (AWS CACM ’15 — 35-step S3 bug, exhaustively-testable-pseudo-code pitch, small-scope hypothesis; egg POPL ’21 with full source anchors — egraph.rs:970 add / :1147 union / :1416 rebuild / :1346 process_unions fixpoint, machine.rs Bind/Scan/Compare pattern VM = topic 19’s bytecode interpreter, extract.rs greedy find_best vs lp_extract, e-graph ≈ Cascades memo; Z3 TACAS ’08 + src/ast/euf anchors — backtracking trail + justifications = WAL for unions, e-matching triggers = index choice; Specifying Systems + Ongaro’s raft.tla — newly cloned — with the un-model-an-assumption exercise that re-derives terms; Beans + Perceus borrowed-vs-owned + reuse tokens with the proof-vs-TLC-vs-proptest calibration exercise), experiments crate compiles on egg 0.9: expr IR + hand-ordered fixpoint rewriter PROVIDED and run — ~30% cost reduction, ~2 µs/firing, and the planted trap measured: (a*2)/2 → strength-reduce fires before div-reassoc → stuck at (a<<1)/2 cost 5 where egg should reach cost 1 — egg_optimize is a
todo!()stub with trap + never-worse-than-hand tests; TLA+ WalReplication spec written AND model-checked (tla2tools.jar downloaded, java 17): SyncCommit=TRUE → Durability holds over 1080 distinct states depth 14 in <1 s; SyncCommit=FALSE → TLC finds the 5-state data-loss trace Append→Commit→Crash→Failover after 123 states — the postgres synchronous_commit=off story, found exhaustively. - 2026-07-10 — topic 20 scaffolded: study guide (format lattice hypersparse→sparse→bitmap→full with the actual switch tests from GB_convert_sparse_to_bitmap_test.c and GB_conform; one-mxm-four-engines mermaid — dot3 iterates the MASK so work ∝ nnz(M) vs saxpy3’s coarse/fine × Gustavson/hash task scheduler with its flopcount pre-pass = cudf size/retrieve five years early; push-vs-pull BFS as vxm-vs-mxv with LAGraph’s shipped α=8/β1=8/β2=512; delta matrices as LSM-over-matrices — DP=memtable, DM=tombstones, wait=minor compaction, delta_mxm’s
(A*(M+DP))<!A*DM>fold), 6 reading guides (Davis TOMS ’19+’23 — zombies/pending as the library’s own deltas, iso matrices, 32-bit indices; SuiteSparse internals with saxpy3.c:22-60 scheduling-essay and hash>m/16⇒Gustavson anchors; Gustavson ’78 + Buluç-Gilbert — SPA design space, symbolic/numeric two-phase; Beamer SC ’12 direction-optimizing with the ICPP ’18 linear-algebra translation; LAGraph — BFS template switch block anchors, ANY_SECONDI parent-without-comparisons, six triangle-count formulations, PageRankGAP; FalkorDB delta_matrix with fresh eyes — header state-table as spec, transposed twin, transpose-as-masked-copy sync, over-masking question — LAGraph newly cloned), experiments crate compiles: CSR+RMAT/uniform/path generators, SpMV, hash-SpGEMM, scalar BFS, hypersparse PROVIDED and run — SpMV 16-19 GB/s single-thread (gather tax vs 30 GB/s streaming), hash SpGEMM ~60-75 Mflop/s (15 ns/flop = the accumulator cost the SPA stub should crush), hypersparse 50× index memory and 171× full-sweep on the 10M-nodes/100K-edges FalkorDB shape — dense-SPA Gustavson and push/pull/direction-optimizing BFS (with per-level trace + path-graph-never-pulls test) aretodo!()stubs. - 2026-07-10 — topic 19 scaffolded: study guide (the spectrum tree-walker → bytecode VM → copy-and-patch → IR JIT → LLVM as a compile-latency-vs-run-speed trade with each system placed on it; produce/consume compiles the PIPELINE not the operators — push inverts control so tuples stay in registers; three JIT grains compared — postgres per-query, Umbra per-pipeline with adaptive Flying-Start→LLVM tiering, GraphBLAS per-kernel-specialization cached forever; DuckDB’s deliberate no-JIT with the VLDB ’18 tie as the counter-argument; M19 = expressions only, gate on measured cost), 6 reading guides (Neumann VLDB ’11 pipelines/breakers + produce-consume inversion; SQLite VDBE — vdbe.c:1049 switch over 199 opcodes, register machine, OP_Yield coroutines as flattened-bytecode’s free resumability; Umbra Tidy Tuples single-pass IR + copy-and-patch musttail stencils; postgres llvmjit_expr.c opblocks-per-EEOP-step + the jit_above_cost estimate-gate failure taxonomy + deform-JIT-as-the-real-win; GraphBLAS jitifyer encodify-hash → PreJIT → memory table → dlopen → invoke-cc ladder with FalkorDB cache-warming implications; cranelift-jit-demo declare→define→finalize→transmute ladder + per-node CLIF emission table for the stub), experiments crate compiles on cranelift 0.116: Expr enum + seeded generator, AST interpreter, and column-at-a-time vectorized lane PROVIDED and run — interp ~2.1 ns/node flat, vectorized 6-12× over interp across depths 2-10 (topic 11’s number reproduced; both linear in nodes, no y-intercept until compile time adds one) — jit.rs compile() is a
todo!()stub with bit-exact-vs-interpreter tests, jit_bench prints the three-way table with compile µs + e2e winner and survives the stub via catch_unwind. - 2026-07-10 — topic 18 scaffolded: study guide (GPU-for-DB-people translation table — SIMT = topic 17’s predication in hardware, coalescing = columnar × 32, shared memory = cache blocking made explicit; the bus decides the architecture — Crystal’s regime A ship-per-query vs regime B device-resident, rewritten for Apple unified memory; libcudf size/retrieve two-phase + cooperative-groups probing = SwissTable at warp scale; Gunrock advance/filter + load-balance menu; CAGRA = HNSW with SIMT-hostile parts deleted by construction), 6 reading guides (Crystal SIGMOD ’20 tile model + fair-CPU-baseline lesson; wgpu compute examples ladder incl. the hello_compute doc-comment our bench proves; libcudf join size/retrieve + shared-mem-until-spill groupby with cuco/cooperative-groups anchors — newly cloned; Gunrock Essentials bfs.hxx enactor + advance.hxx thread/block/merge_path dispatch anchors — newly cloned; CAGRA ICDE ’24 + single-CTA kernel/shared-mem-hashmap/bitonic-topk anchors — cuvs newly cloned; Faiss GPU billion-scale paper — WarpSelect register k-select, memory-tier table), experiments crate compiles AND RUNS on Metal: GpuCtx + workgroup-reduction sum kernel PROVIDED with per-phase timings, gpu_bench crossover sweep run on Apple M3 Pro — CPU wins at every size up to 16M elements (~1.5 ms fixed dispatch floor, flat 16K→1M; even amortized the GPU reads the same unified memory at ~9 GB/s effective vs CPU 30 GB/s — regime B’s bandwidth ratio doesn’t exist for streaming ops on this machine, which IS the lesson), filter_count (one-atomicAdd-per-workgroup, WGSL skeleton provided) and l2_batch (one-invocation-per-target + row/column-major coalescing experiment) are
todo!()stubs with exact-match/1e-3 tests, notes.md predicts where arithmetic intensity finally flips the verdict. - 2026-07-10 — topic 17 scaffolded: study guide (ports×latency mental model — M-series 4 FMA ports × 3cy ⇒ ~12 independent chains, the four autovectorization failures, branchy/branchless/compress filter shapes with AVX-512 vpcompress vs NEON’s missing compress, vshrn movemask idiom, FastLanes interleaved layout), 7 reading guides (simdjson VLDB ’19 + arm64 nibble-LUT classification / PMULL prefix_xor quote parity / LUT-shuffle compress emulation / flatten_bits over-write-under-advance — newly cloned; polars-compute float_sum STRIPE=16 + pairwise-128 and simd_filter! with per-ISA compress + selectivity-adaptive scalar bit-iteration fallback; hashbrown Group×3 backends + memchr Vector — newly cloned — with the finding that hashbrown’s NEON group is 8 BYTES so vceq output already IS the bitmask, no vshrn, while memchr keeps 16 lanes and narrows; SimSIMD under its numkong rename — per-instruction latency/port tables in headers, f64-upcast accumulation, 4-target streaming states = M14’s scoring loop, and the FCMLA lesson: the specialized instruction measured 2.3× SLOWER than 4 plain FMAs; SIGMOD ’15 selective-store/load primitives + vertical hash probing + gather-costs-a-load-per-lane; FastLanes VLDB ’23 1024-lane transposed layout; Mojo SIMD[type,width] parametric-width ladder), experiments crate compiles: dot naive+unrolled-8 PROVIDED and run — 10.89 → 42.12 GB/s, 3.9× from accumulator count alone, zero intrinsics — wide-f32x4 and NEON vfmaq 4-accumulator rungs are
todo!()stubs; filter branchy+branchless PROVIDED and swept — branchy craters 9× to 1.19 GB/s at 50% selectivity while branchless holds ~12.7 flat, the SIGMOD ’15 curve live — NEON count (vcltq+vsubq mask-accumulate) and LUT-compress compact (simdjson trick, f32 edition, all-16-masks test) are stubs; unpack4 scalar PROVIDED at 10.20 GB/s, NEON shift/mask stub; simd_bench catches stub panics so baselines always print. - 2026-07-10 — topic 16 scaffolded: study guide (every technique = generator + oracle table, DST determinism boundary diagram, PQS/TLP/NoREC comparison, Jepsen/elle, Z3-as-search-engine), 6 reading guides (turso testing/simulator with clock/io/file fault-injection anchors + interaction-plan properties + doublecheck + structured fuzz targets; FDB simulation + BUGGIFY + Antithesis determinism-boundary table; SQLancer oracle base classes — newly cloned — PQS check()/rectification, TLP 3-way partition, NoREC optimized-vs-forced-scan; PQS OSDI ’20 + TLP OOPSLA ’20 paired; Jepsen redis-raft/Dgraph findings + elle cycle inference; Z3 — newly cloned — TACAS ’08 + tactic/solver/smt_context anchors + Cosette symbolic-row rewrite verification), experiments crate compiles: sim_fs (buffered/synced/torn-tail file) + kv (WAL KV with 4 injectable bugs: LostDelete/NoSyncOnCommit/TornWriteAccepted/StaleRead) PROVIDED, dst harness + ddmin shrinker + TLP Kleene-eval checker are
todo!()stubs with 12 contract tests (all bugs caught ≤200 seeds, zero false positives over 500, deterministic replay, 1-minimal repro, null-blind engine exposed), crash_matrix PROVIDED and run: 5000 seeds/0.02 s per bug — 0.0% false positives, bugs caught at 48.8–99.6% per-seed rates, first failing seed ≤ 3; the matrix caught a real bug in this crate’s own recovery (missing WAL tail truncation made torn leftovers join the next batch — 72.7% divergence until fixed), the topic’s thesis self-demonstrated. - 2026-07-10 — topic 15 scaffolded: study guide (topology menu as WHO-can-ack axis, Raft state-machine mermaid + election/log-matching/§5.4.2 three-way split, write-path comparison valkey/WAIT/raft, consistency ladder + ReadIndex, hash slots vs ranges), 6 reading guides (Raft ATC ’14 with Fig 8 worked by hand; valkey replication.c shared repl buffer + PSYNC replid/offset + REPL_STATE_ handshake + WAIT + FAILOVER with line anchors; tikv raft-rs — newly cloned — RawNode/Ready contract + step_* dispatch + Progress tracking + maybe_commit-is-§5.4.2; qdrant consensus.rs raft-for-metadata-only split with the data path outside raft; VSR Revisited round-robin views + no-disk durability + TigerBeetle disk-can-lie; DDIA ch. 5/8/9 anomaly catalog + fencing tokens + linearizability), experiments crate compiles: sim.rs deterministic lockstep network PROVIDED (seeded delivery, partition/heal — topic 16 DST preview), raft.rs
todo!()stub with 5 safety-pinning tests (one leader, one-per-term across 10 seeds, replicate-to-all, minority-commit-freeze, stale-leader truncation), partition_test timeline binary, repl_lag PROVIDED and run: follower fsync policy AS ack latency — every-1 = 339 entries/s at 2973 µs p50 (F_FULLFSYNC) vs never = 18568/s at 6 µs, the topic 5 ladder measured as replication lag. - 2026-07-10 — topic 14 scaffolded: study guide (recall-vs-QPS curve framing, HNSW-as-skip-list ASCII, quantization ladder u8/PQ/binary with oversample+rescore, IVF + DiskANN families, filtered-search menu with percolation), 6 reading guides (qdrant GraphLayers builder/serve split + visited pool + the per-query algorithm choice HNSW/ACORN/plain via estimate_cardinality + measured percolation at build; qdrant quantization crate u8 affine dot-expansion / PQ ADC LUTs / binary xor_popcnt + get_oversampled_top; usearch — newly cloned — node-tape layout + paper-default constants + striped locks (helix-db dropped: public repo no longer ships engine source); HNSW paper with skip-list lens; Jégou PQ with SDC/ADC + IVFADC residuals-as-FOR; DiskANN Vamana robust-prune α-slack + PQ-steers/f32-ranks SSD layout), experiments crate compiles: brute-force oracle PROVIDED and run (185 QPS at recall 1.0 over 100K×128-d — the floor), hnsw (Alg 1/2/4, level draw, ef knob) and quant (affine u8 + symmetric distance + rescore pipeline) are
todo!()stubs with 10 contract tests (self-query top-1, recall@10 ≥ 0.9, sorted results, log level distribution, α/2 error bound, rescored recall ≥ 0.95), ann_bench sweeps ef 16..256 + oversampling 1/2/4. - 2026-07-10 — topic 13 scaffolded: study guide (adjacency representation menu with CSR ASCII, four-architecture table neo4j/memgraph/kuzu/FalkorDB across store/Expand/pattern-match/MVCC/updates, delta-overlay-as-LSM observation, pointer-chasing cost analysis, WCOJ/AGM section, LDBC referee), 6 reading guides (GraphBLAS 4 sparsity formats + dot-vs-saxpy mxm + masks-as-pushdown + FalkorDB Delta_Matrix M/DP/DM state machine — neo4j/kuzu/GraphBLAS newly cloned; neo4j 15 B node / 34 B rel fixed records + doubly-linked rel chains = one miss per edge; memgraph skip-list vertex + small_vector edges + PointerPack’d delta MVCC; kuzu columnar CSR node groups persistent+transient + Intersect WCOJ + factorization; AGM bound / Generic Join / EmptyHeaded with the
C<A>=A²equivalence; LDBC SNB correlated-power-law datagen + updates-during-reads), experiments crate compiles: adj_list oracle PROVIDED and run (1M-node/16M-edge preferential-attachment: 3.5 µs/query random vs 295 µs supernodes — the 85× graph-shaped tail, max degree 6565), csr (counting-sort build + slice two_hop) and matrix (masked-SpMV two_hop) aretodo!()stubs with oracle-agreement + exact-layout + cycle-self-exclusion tests, hop_bench cross-checks via checksums. - 2026-07-10 — topic 12 scaffolded: study guide (row-vs-column ASCII, lightweight-encoding zoo table incl. FSST, analyze→score→compress lifecycle, zone-map pruning diagram, Arrow-vs-Parquet boundary, MergeTree/DuckDB/Pinot architecture table), 6 reading guides (DuckDB compression framework + 4-mode bitpacking + fetch_row-shapes-the-menu + CheckZonemap; ClickHouse MergeTree — newly cloned — parts/granules/sparse-index/marks two-offset trick + merge-time work; arrow-rs + parquet-rs — newly cloned — buffer recipes, RLE-hybrid, two compression layers; C-Store + SIGMOD ’06 process-compressed thesis; BtrBlocks sampling cascade + FSST symbol tables; ClickHouse VLDB ’24 with ClickBench-on-DuckDB exercise), experiments crate compiles: RLE/Dict/BitPacked
todo!()stubs with exact-size + maximal-runs + FOR-width + O(1) random-access contract tests, scan_bench PROVIDED (100M values × 3 shapes, raw vs encoded scans incl. RLE sum-without-decode and dict codes-only sum — “raw-equiv GB/s > memory bandwidth” is the compression-IS-performance headline to verify). - 2026-07-10 — topic 11 scaffolded: study guide (Volcano→X100→HyPer mermaid, selection vectors + vector-type flags, morsel-driven parallelism diagram, vectorized hash join/agg internals), 6 reading guides (DuckDB DataChunk/2048 + pipeline executor push-pull hybrid + join-HT salt-in-pointer probe; postgres ExecProcNode self-replacing dispatch + execExprInterp computed-goto; polars-stream Morsel/MorselSeq/SourceToken + float_sum masked-SIMD multi-accumulator + DataFusion ExecutionPlan streams and intern-then-flat-arrays GroupedHashAggregateStream; X100 CIDR’05 U-curve; VLDB’18 compiled-vs-vectorized scorecard — memory-bound probes favor vectorized, the M11 architecture argument; SIGMOD’14 morsels), experiments crate compiles: one query three engines — Volcano PROVIDED and run (180.7 M rows/s; found LLVM DEVIRTUALIZING the statically-known
Box<dyn>chain, 202→180 after black_box — a compiler will silently turn your Volcano into a compiled engine), vectorized (batches + selection vectors + flat group array) and fused branchless kernel aretodo!()stubs with oracle-agreement tests incl. partial-final-batch and mask-sign-extension traps, exec_bench sweeps selectivity 5/50/95. - 2026-07-10 — topic 10 scaffolded: study guide (parse→bind→logical→rewrite→join-order→physical pipeline mermaid, rewrite-rule menu, Selinger DP vs DuckDB DPccp+greedy-fallback, cardinality three-lies table, Selinger-vs-Cascades memo ASCII), 5 reading guides (DuckDB optimizer.cpp 25-pass pipeline + plan_enumerator DPccp with greedy escape hatch :234 + cost=output-cardinality-only; postgres allpaths.c standard_join_search + geqo threshold 12 + DEFAULT_EQ_SEL 0.005; sqlparser-rs Pratt parse_subexpr + DataFusion fixpoint-of-rules vs DuckDB ordered passes — sqlparser/datafusion/polars newly cloned; Selinger ’79 vs Cascades with M10 architecture-choice question; Leis VLDB’15 JOB — cardinality error 10²–10⁴ dwarfs cost model 2× and search 1.2×, graph-JOB design exercise), experiments crate compiles: toy cost-based planner
todo!()stubs (parse_and_plan naive left-deep → push_down → greedy reorder_joins → estimate with 1/NDV + independence + containment) with contract tests incl. join_order_flips_with_stats, explain binary PROVIDED for side-by-side DuckDB EXPLAIN comparison. - 2026-07-10 — topic 9 scaffolded: study guide (latch vs lock table, memory-ordering cheat sheet + publication idiom, latch-coupling→OLC→lock-free ladder, epoch reclamation diagram, Bw-tree cautionary arc, false sharing), 4 reading guides (postgres lwlock.c packed u32 + recheck-after-enqueue lost-wakeup dance; crossbeam-epoch pin/defer/try_advance — newly cloned; RocksDB InlineSkipList CAS+splices vs memgraph lazy-locking skiplist with accessor-id GC — memgraph newly cloned; Bw-tree ICDE’13 + SIGMOD’18 reality check + Leis OLC), experiments crate compiles: lock-free ConcurrentSet
todo!()stub over crossbeam-epoch with 5 contract tests (same-key/remove races exactly-one-winner, reader-survives-removal-churn UAF canary), scaling shootout PROVIDED (global mutex / 16-shard / crossbeam SkipSet / yours, 1→16 threads), false_sharing PROVIDED and run — packed 63 M inc/s vs pad128 3707 M (59×), and pad64 still 2.2× slower than pad128: Apple M-series coherence granularity is 128 B, x86-style 64 B padding only half-fixes it. - 2026-07-10 — topic 8 scaffolded: study guide (anomaly-per-isolation-level table, doctors write-skew walkthrough, 2PL/OCC/MVCC comparison, postgres tuple-header + visibility flowchart, HOT chain, Hekaton contrast), 6 reading guides (postgres heapam.c/heapam_visibility.c HeapTupleSatisfiesMVCC + HOT + prune/vacuum with line anchors; RocksDB optimistic vs pessimistic txns over one base class — memtable-only OCC validation, point lock manager; surrealdb kvs layer — newly cloned — versioned reads + putc as portable OCC; Berenson ’95 history notation + SI dethroned; SSI VLDB’12 dangerous structure + the single-writer M8 shortcut question; Hekaton + Wu/Pavlo 5-axis menu), experiments crate compiles: Mvcc
todo!()stub with 8 contract tests including write_skew_HAPPENS_under_SI (test passes when the anomaly occurs) and Serializable-mode prevention via read-set validation, txn_bench PROVIDED (global Mutex baseline vs MVCC, 3 mixes incl. 64-key hot set, abort counts). - 2026-07-10 — topic 7 scaffolded: study guide (RESP wire anatomy, event-loop mermaid beforeSleep→poll→read→execute→buffer, three threading models table, backpressure: querybuf/output-buffer kills vs pgwire portals), 4 reading guides (redis ae.c + networking.c parse/reply path with line anchors; valkey 8 io_threads.c SPSC inboxes + tagged job pointers + memory_prefetch.c batch-MLP; pgwire Parse/Bind/Execute/Sync portals + qdrant dual tonic servers — both newly cloned; C10K → thread-per-core arc with the shared↔sharded plane exercise), experiments crate compiles: RESP2 parse/encode
todo!()stub with 8 format-fixing tests (incomplete-input-keeps-bytes, binary-safe bulks, pipelining), tokio server PROVIDED (16-shard store, parse-all-then-flush-once pending-writes trick) — benches vs real redis via redis-benchmark -P 1/-P 64 + flamegraph once resp.rs is implemented. - 2026-07-10 — topic 6 scaffolded: study guide (translation-cost table hash/swizzle/MMU, miss-path mermaid, three shapes of approximate-LRU, swip state diagram, mmap CIDR-’22 checklist), 6 reading guides (postgres bufmgr.c packed-atomic state + CLOCK + buffer rings; DuckDB eviction queue with dead nodes + 4096-insert purge — newly cloned; LeanStore swips/cooling/hybrid latches — newly cloned; redis zmalloc per-thread padded counters + turso CLOCK page cache bonus; mmap paper with LMDB rebuttal; LeanStore+vmcache paper arc), experiments crate compiles: CLOCK BufferPool
todo!()stub with contract tests (pinned-never-evicted, dirty-writeback, scan-pressure survival), pool_vs_mmap binary (1GiB file, 4× memory budget, Zipf, tail-latency focus), eviction bench PROVIDED and run — CLOCK 67.0% vs strict-LRU 66.3% hit rate at 20× less time per access (32ms vs 678ms per 1M trace): the “nobody ships strict LRU” lesson, measured. - 2026-07-10 — topic 5 scaffolded: study guide (WAL rule, four-designs axis LMDB→turso→postgres→redis-AOF, fsync ladder table, group-commit mermaid), 5 reading guides (postgres xlog.c — newly cloned — reserve-then-copy/XLogFlush-recheck/FPI with line anchors; turso WAL checksum chain + salts; redis aof.c/rdb.c with the AOF-as-LSM mapping + FalkorDB angle; ARIES three passes/CLRs; Aether four-bottleneck taxonomy), experiments crate compiles: fsync_ladder PROVIDED and run (this Mac: fsync 21µs vs F_FULLFSYNC 3.0ms — 140×, the macOS weak-fsync gap is real), Wal
todo!()stub with format-fixing tests (torn tail, uncommitted-txn invisibility, commit_many = 1 fsync), crash_test kill-9 harness (100 rounds, acked-key + atomicity checks), commit_throughput bench (per-commit vs group 8/64/512). - 2026-07-10 — topic 4 scaffolded: study guide (memtable→SST lifecycle mermaid, SST block anatomy, leveled/tiered/lazy RUM table, stall triggers, Monkey intuition), 6 reading guides (lsm-tree crate — newly cloned, fjall delegates to it — + RocksDB compaction/table with line anchors; Monkey, Dostoevsky, RocksDB TODS ’21, compaction design-space VLDB ’21), experiments crate compiles: mini-LSM with provided Bloom (tests pass) + Memtable, SST writer/reader + Lsm engine
todo!()stubs with correctness tests (tombstone-across-compaction, WA>1 check), write_amp binary measuring the full RUM position of leveled vs tiered. - 2026-07-10 — topic 3 scaffolded: study guide (slotted page anatomy, 3-sibling balance mermaid, LMDB double-meta COW commit diagram), 5 reading guides (turso btree deep + SQLite btree.c + LMDB mdb.c with line anchors from fresh clones; Graefe survey selective-read map, SQLite file-format hex-dump exercise), experiments crate compiles: slotted Page + DiskBTree
todo!()stubs with format-fixing tests, bench vs redb (point/scan) + prefix-truncation stress case (32B keys, 24B shared prefix). - 2026-07-10 — topic 2 scaffolded: study guide (chaining vs open addressing cache stories, incremental-rehash mermaid, skiplist/rax ASCII, dense-filter/fat-payload pattern table), 7 reading guides (redis dict/zset/rax, hashbrown SwissTable, RocksDB InlineSkipList — line numbers from local clones; ART paper, CppCon SwissTable talk), experiments crate compiles: skiplist + incremental_map
todo!()stubs with tests (the build work is the learning work), benches vs hashbrown/BTreeMap/crossbeam-skiplist, rehash_spike binary (HdrHistogram per-insert max/p99.9). - 2026-07-10 — topic 1 started: study guide (two-family write/read paths, amplification vocabulary, RUM triangle), 8 reading guides (fjall/turso/tidesdb/rocksdb code + O’Neil/Comer/RUM/Hellerstein papers, line numbers from fresh shallow clones), engine_shootout scaffold (fjall vs redb behind a common trait, db_bench workload names, durability parity) compiles + smoke-tested (space-amp binary at 20K keys shows fixed-overhead floor, not amplification — re-run at 1M+). Topic 0 plan audit: fixed phantom CMU-lecture reference in PLAN.md, added missing roofline-thinking section to topic 0 README §4.
- 2026-07-10 — topic 0 finished: cache_ladder (after fixing a self-caching bug: restarting the pointer chase at 0 measured an 8MB hot path — fixed by carrying the walker across iterations; true ladder 1.0 ns L1 / 5–9 ns L2 / ~110 ns DRAM+TLB), lookup_shootout (HashMap flat at ~7–9 ns thanks to MLP; binary search wins ≤1e4; linear scan never beats hashing at n≥100 — folklore busted), flamegraph captured (21% SipHash in HashMap lookups), reference baselines recorded in capstone/BASELINES.md. Topic 0 + M0 done.
- 2026-07-10 — topic 0 started: study guide + 3 experiment benches (cache_ladder, lookup_shootout, branch_misprediction); capstone workspace scaffolded with
workloadcrate (seeded Zipfian generator, ~11M ops/s). First measured result: branchy filter 8.1x slower on shuffled vs sorted data; branchless flat at 15 Gelem/s. Repo published to github.com/AviAvni/database-learning-path. - 2026-07-10 — repo initialized: plan, capstone design, resources.
Topic 0 — The Performance Toolbox
Learn to measure before learning to build. Every topic after this one ends with a benchmark; this topic makes sure those benchmarks tell the truth.
Outcomes
By the end you can:
- Write a microbenchmark that isn’t lying to you, and explain why it isn’t.
- Read a flamegraph and
perf stat-style counters and name the bottleneck (CPU-bound, memory-bound, branch-miss-bound, syscall-bound). - Recite the memory-hierarchy latency ladder from registers to disk within 2x.
- Report latency correctly (percentiles, not means) and explain coordinated omission.
1. Why microbenchmarks lie
The five classic failure modes — you will hit all of them this week on purpose:
- Dead-code elimination: the optimizer deletes your benchmarked work because the
result is unused. Fix:
std::hint::black_boxon inputs and outputs. - Warmup & frequency scaling: first iterations pay cold caches, page faults, and low CPU clocks. Criterion warms up for 3s by default — that’s not superstition.
- Variance & noise: background processes, turbo boost, thermal throttling. Criterion runs many samples and does outlier detection; single-shot timing is fiction. On Apple Silicon: P-cores vs E-cores — pin expectations, not threads (macOS gives you no affinity API; keep the machine idle instead).
- Wrong distribution: benchmarking uniform random keys when production is Zipfian (redis workloads are heavily skewed). The workload generator you build in M0 exists to fix this permanently.
- Coordinated omission: if you measure latency by sending request → wait → send
next, a stall backs up your load generator and the stall disappears from your data.
Gil Tene’s “How NOT to Measure Latency” talk is mandatory. This is why
redis-benchmarkgot--latencymodes and why HdrHistogram exists.
Coordinated omission, on a timeline (each | is one request):
intended schedule | | | | | | | | | | | | | | | | | | constant rate
actual (closed loop)| | | | | |. . . . . . . .| | | | | generator waits with the server
^--- server stall ---^
what you record: fast fast fast [ONE slow sample] fast → "p99 looks great!"
what users felt: every request due in the stall window waited up to the full stall
Fix: timestamp each request with its INTENDED send time; latency = completion − intended.
2. The memory hierarchy — the numbers that explain everything
Approximate, Apple M-series / modern x86, per access:
| Level | Size | Latency | ~Cycles |
|---|---|---|---|
| Register | bytes | — | 0 |
| L1 | 64–192 KB | ~1 ns | 3–5 |
| L2 | 4–16 MB | ~3–5 ns | 12–20 |
| L3 / SLC | 8–96 MB | ~10–20 ns | 40–80 |
| DRAM | GBs | ~80–100 ns | 300–400 |
| NVMe read | TBs | ~20–100 µs | ~10⁵ |
| fsync (NVMe) | — | ~0.1–1 ms | ~10⁶ |
The same ladder on a log scale — each step down is roughly an order of magnitude:
1ns 10ns 100ns 1µs 10µs 100µs 1ms
L1 ~1 ns ●
L2 ~4 ns ──●
SLC ~15 ns ────●
DRAM ~90 ns ───────●
NVMe ~50 µs ──────────────────────────●
fsync ~0.5 ms ────────────────────────────────────────●
└─ one DRAM miss ≈ 100 adds; one fsync ≈ 10,000 DRAM misses
flowchart LR
CPU["CPU core<br/>registers"] --> L1["L1<br/>64–192 KB · ~1 ns"]
L1 --> L2["L2<br/>4–16 MB · ~4 ns"]
L2 --> SLC["L3 / SLC<br/>8–96 MB · ~15 ns"]
SLC --> DRAM["DRAM<br/>GBs · ~90 ns"]
DRAM --> NVMe["NVMe<br/>TBs · ~50 µs"]
NVMe --> FSYNC["fsync<br/>~0.5 ms"]
Consequences you’ll verify in the experiments:
- A DRAM miss costs ~100 sequential integer additions. Databases are mostly memory-hierarchy management — B-trees (page-sized nodes), LSM SSTs (sequential IO), vectorized execution (cache-resident batches), roaring bitmaps (fit in cache) are all answers to this table.
- Sequential beats random not because of “seek time” (that’s spinning disks) but because of prefetching and cache-line granularity: touching 1 byte loads 64–128 bytes.
- Pointer chasing (linked lists, naive trees, neo4j-style record hopping) serializes misses: each next address depends on the previous load. Arrays let the CPU overlap many misses (memory-level parallelism). This single fact is why FalkorDB’s matrix-based adjacency can beat pointer-based graph stores.
Why arrays beat pointer chasing — memory-level parallelism:
pointer chase (next address depends on previous load — misses serialize):
load A ═══════►
load B ═══════►
load C ═══════► 3 × ~100 ns = ~300 ns
array scan (addresses known up front — misses overlap):
load A ═══════►
load B ═══════►
load C ═══════► ≈ ~110 ns total
3. Branches and the pipeline
Modern cores are ~8-wide and ~500 instructions deep in flight. A mispredicted branch flushes the pipeline: ~15–20 cycles. A 50%-unpredictable branch in a per-row filter is catastrophic; the fix is branchless/SIMD selection (topic 17). You’ll measure the sorted-vs-unsorted filter effect in experiment 3 — the classic 5–10x gap.
per-row filter `if x > t`, 50% unpredictable:
predict OK : [fetch|decode|exec|...]───► next row overlaps, ~1 row/cycle
mispredict : [fetch|decode|exec|✗ FLUSH] ~15–20 cycles of speculative work discarded
└──► restart fetch from the correct path
branchless : sum += (x > t) as u64 * x no control dependence — nothing to predict
4. Tools on this machine (macOS ARM)
| Task | Tool |
|---|---|
| CPU profile + flamegraph | samply record ./target/release/... (opens Firefox Profiler UI), or cargo flamegraph |
| Microbenchmarks | criterion (stats, regression detection) |
| Hardware counters | Instruments → CPU Counters template (macOS has no perf); for real perf stat work use a Linux box/container |
| Allocations | Instruments → Allocations, or dhat-rs |
| Syscalls | Instruments → System Trace (dtruss needs SIP off) |
| Disk baseline | fio — know your NVMe’s random-read IOPS and fsync latency before topic 5 |
| CLI-level timing | hyperfine |
Install: cargo install samply flamegraph hyperfine; brew install fio
Habit to build: criterion tells you what got slower, the profiler tells you why, counters tell you why at the hardware level. Always use at least two of the three before believing a conclusion.
Roofline thinking — before optimizing a kernel, ask which wall it’s against.
Peak performance is bounded by min(peak compute, bandwidth × arithmetic intensity),
where arithmetic intensity = ops per byte moved. Low intensity (scans, hash probes:
~0.1–1 op/byte) → memory-bound: more SIMD won’t help, cache-friendly layout will.
High intensity (compression, hashing wide rows) → compute-bound: SIMD/algorithm wins.
perf
│ peak compute ────────────────
│ ╱
│ ╱ ← bandwidth roof (slope = GB/s)
│ ╱
│ ╱
│ scan● ●hash probe ●compression
└───────┴────────┴─────────────────┴──────────── ops/byte
memory-bound ◄─── ridge point ───► compute-bound
Most database kernels live left of the ridge — that’s why this whole curriculum is obsessed with the memory hierarchy rather than FLOPs.
flowchart LR
C["criterion<br/>WHAT got slower<br/>(numbers + CIs)"] --> P["samply / flamegraph<br/>WHY — which code<br/>(where time goes)"]
P --> H["CPU counters<br/>WHY — which hardware limit<br/>(cache/branch/IPC)"]
H --> C
style C fill:#1f6feb,color:#fff
style P fill:#8957e5,color:#fff
style H fill:#bf4b8a,color:#fff
5. Code reading (2–4 h)
- criterion: read
analysis/mod.rs+ how it uses warmup and outlier classification. Question to answer: why does it report a confidence interval rather than a minimum? → chapter:reading-criterion.md— How criterion turns noise into a number you can trust - redis
redis-benchmark.c: how does it implement pipelining? What does it get wrong about coordinated omission? → chapter:reading-redis-benchmark.md— redis-benchmark: a throughput tool wearing latency clothes - RocksDB
tools/db_bench_tool.cc(skim): note the workload flags — this is the vocabulary of storage benchmarking (fillseq,readrandom,overwrite…). → chapter:reading-rocksdb-db-bench.md— db_bench: the shared vocabulary of storage benchmarking
6. Reading / watching
- Brendan Gregg, Systems Performance ch. 1–2 (methodologies — USE method).
- Gil Tene, “How NOT to Measure Latency” (talk) — lessons captured in
notes.md. - Raasveldt, Holanda, Gubner, Mühleisen — “Fair Benchmarking Considered Difficult:
Common Pitfalls in Database Performance Testing” (DBTest ’18, from the DuckDB authors)
— the DB-specific companion to this topic’s §1.
→ chapter:
reading-fair-benchmarking.md— Fair benchmarking: eight ways a system comparison lies - “What Every Programmer Should Know About Memory” (Drepper) — §3–4 only, skim the rest.
→ chapter:
reading-drepper.md— CPU caches and TLBs: the constants aged, the structure didn’t
7. Experiments (in experiments/)
cache_ladder— stride through arrays of 16KB → 512MB; plot ns/access. You should see L1/L2/SLC/DRAM as plateaus.lookup_shootout— point lookups:Veclinear scan vsVecbinary search vsHashMapvsBTreeMap, sizes 1e2 → 1e7. Find the crossover where linear scan beats hashing (it exists, and it’s bigger than you think).branch_misprediction— sum elements> thresholdover sorted vs shuffled data. Then make it branchless and watch the gap vanish.- Profile experiment 2 with samply; grab one flamegraph screenshot into
notes.md.
8. Capstone milestone M0 (in ../../capstone/)
- Cargo workspace scaffolded
-
workloadcrate: seeded, Zipfian-skewed graph workload generator (node/edge inserts, point reads, 2-hop traversal patterns) - criterion harness wired up (generator smoke bench)
- Baseline numbers from the reference
falkordb-rs-next-genrecorded incapstone/BASELINES.md(run its bench suite; numbers to chase later)
Done when
- All three experiments run and their results are explained in
notes.md(numbers + why), the flamegraph screenshot is captured, and M0 checklist is complete.
How criterion turns noise into a number you can trust
Every benchmark in this curriculum runs through criterion, so before trusting
any of them it pays to know exactly what the tool does to raw timings. The
answer lives in one 370-line file, analysis/mod.rs: common() (line 39) is
a pipeline, and every line criterion prints during a bench run maps to a
specific step here. Three ideas do all the work — bootstrap instead of
normality assumptions, slope instead of mean, label outliers instead of
dropping them.
The pipeline in common()
flowchart TD
S["1 · routine.sample() (line 83)<br/>(iters, times) — iters grows [d, 2d, 3d, ...]"]
S --> N["2 · avg_times[i] = times[i] / iters[i] (124–129)"]
N --> T["3 · tukey::classify (141)<br/>label outliers — NEVER remove"]
N --> E["4a · estimates() (300)<br/>mean/median/MAD, each bootstrapped (321)"]
S --> R["4b · regression() (269)<br/>slope of total_time vs iters = ns/iter"]
R --> H["headline: time: [lo mid hi] = slope's bootstrap CI"]
E --> C["5 · compare.rs (188)<br/>bootstrapped t-test vs saved baseline<br/>+ noise_threshold gate"]
H --> C
1. Sampling (line 83) — routine.sample(...) returns (sampling_mode, iters, times):
two parallel arrays, e.g. 100 samples where sample i ran iters[i] iterations and took
times[i] total. Key trick: it never times a single iteration (too noisy, clock
granularity). With linear sampling, iters grows as [d, 2d, 3d, ...] — that shape
matters for step 4. Warmup lives in routine.rs::warm_up, before this — its real job
is calibrating how big d must be to fill the target time; cold-cache mitigation is a
side effect.
2. Normalize (lines 124–129) — avg_times[i] = times[i] / iters[i] → per-iteration
averages. This is the Sample all univariate stats run on.
3. Outlier classification (line 141) — tukey::classify labels each sample using
Tukey fences (quartiles ± 1.5×IQR / 3×IQR → mild/severe). Outliers are labeled and
reported, never removed — that’s the “Found N outliers among 100 measurements” line.
Philosophy: deleting data you don’t like is how benchmarks lie.
4. Two independent estimators:
estimates()(line 300): mean/median/std-dev/MAD ofavg_times, each bootstrapped (line 321): resample the sample with replacementnresamples(100k) times, recompute the stat each time → an empirical distribution of the statistic → read the CI off its percentiles. No normality assumption — which matters because latency isn’t normal.regression()(line 269): fitstotal_time = slope × itersthrough the(iters, times)points — the slope is ns/iteration. Only valid for linear sampling (checked at line 152). The headlinetime: [lo mid hi]is the slope’s bootstrap CI. Why slope beats mean-of-averages: constant per-sample overhead (measurement, loop setup) inflates everyavg_times[i]but shows up as an intercept the slope ignores.
total_time why slope beats mean-of-averages:
│ ×
│ × slope = ns per iteration ← the answer
│ ×
│ ×
│ ×
├─────────────────────────── iters
╵← intercept = fixed per-sample overhead
(mean of averages absorbs it; the slope ignores it)
The bootstrap itself — the engine under every CI criterion prints — fits in ten lines:
#![allow(unused)]
fn main() {
fn bootstrap_ci(sample: &[f64], nresamples: usize) -> (f64, f64) {
let n = sample.len();
let mut stats = Vec::with_capacity(nresamples);
for _ in 0..nresamples { // 100_000 in criterion
// resample WITH replacement, same size — pretend the sample IS the population
let stat = mean((0..n).map(|_| sample[rand_below(n)]));
stats.push(stat); // distribution OF THE STATISTIC
}
stats.sort_by(|a, b| a.partial_cmp(b).unwrap());
(percentile(&stats, 2.5), percentile(&stats, 97.5)) // CI = its percentiles —
} // no normality assumed
}
5. Regression detection (line 188 → compare.rs) — loads the saved baseline,
bootstraps a two-sample t-test (line 200: p_value) asking “is the difference real?”,
then a bootstrapped relative-change estimate against noise_threshold asking “is it big
enough to care?”. Both gates must pass — e.g. +3781% (p = 0.00 < 0.05): significant
and large.
Study-guide question: why a CI, not a minimum?
The “report the minimum” school (older Python timeit advice) argues noise is strictly
additive, so min = true cost. Criterion rejects that:
- Min answers the wrong question. It estimates best-case-ever (all caches hot, zero interference) — a state production code never runs in. The CI estimates typical cost with honest uncertainty bounds.
- Noise isn’t strictly additive. Frequency scaling means early samples can run at a higher clock (pre-thermal-throttle) — the min can be an unrepresentatively lucky sample, and on modern laptops often is.
- Min is statistically fragile for comparison. It’s an extreme-value statistic with no usable sampling distribution — you can’t compute a p-value on “min got 2% slower.” The regression-detection machinery only works because mean/slope have bootstrap distributions.
- A point estimate hides confidence.
[69.6 70.1 70.5] µssays the measurement is tight; a bare69.6hides whether the spread was 1% or 40%.
Suggested reading order in the crate
analysis/mod.rs::common— the spinestats/univariate/outliers/tukey.rs— fences, ~100 linesstats/bivariate/regression.rs—Slope::fitis ~10 lines of least-squaresanalysis/compare.rs+stats/univariate/mixed.rs— the bootstrapped t-testroutine.rs::warm_up— see that warmup is really iteration-count calibration
Takeaway
Criterion is built on three ideas: bootstrap instead of normality assumptions, slope instead of mean, label outliers instead of dropping them.
References
Code
- criterion.rs
src/analysis/mod.rs(locally:~/.cargo/registry/src/index.crates.io-*/criterion-0.5.1/src/analysis/mod.rs, 370 lines) —common()is the spine; follow the suggested reading order above throughtukey.rs,regression.rs,compare.rs, androutine.rs::warm_up
CPU caches and TLBs: the constants aged, the structure didn’t
Every latency table in topic 0 §2 is a compressed version of one 2007 paper —
Drepper’s “What Every Programmer Should Know About Memory”. This chapter is a
reading lens for its two load-bearing sections: §3 (why misses cost what they
cost) and §4 (why a TLB miss is pointer chasing in silicon). The DDR2 numbers
are stale; the cache-organization math, the prefetching rules, and the
measurement methodology behind cache_ladder are forever.
What’s stale vs. what’s forever
2007 paper: the constants aged, the structure didn’t. Reading lens:
- Stale: DDR2 timings, front-side bus, Pentium 4/NetBurst details, exact cache sizes.
- Forever: cache organization math, why misses cost what they cost, prefetching rules,
the measurement methodology (his benchmark plots are the blueprint for
cache_ladder). - Apple Silicon deltas to keep in mind while reading: 128-byte cache lines on M-series (not 64!), no inclusive L3 (shared SLC instead), much larger L1 (128–192 KB).
§3 — CPU caches (the core, ~35 pages)
- 3.1–3.2 Skim. Cache hierarchy diagrams + associativity. Know: set-associative = hash table with N-way buckets; conflict misses = bucket collisions.
- 3.3 (read carefully) — the famous measurements. Fig 3.4 (sequential vs random
access over working-set size) is exactly the
cache_ladderexperiment; compare his plateau shapes with yours before explaining your numbers innotes.md. Understand why random is worse than sequential even in DRAM: TLB misses + no prefetch + row activation. - 3.3.2 Critical word first / early restart — why the miss cost isn’t a full line transfer.
- 3.4 Instruction cache — skim (matters again at topic 19, JIT).
- 3.5 (read carefully) Cache coherency + false sharing (Fig 3.27-ish, multi-thread scaling collapse). This is the section that pays off in topic 9 (concurrency) — two atomics on one line = cacheline ping-pong.
- Fig 3.11 (cache-line utilization) explains why columnar layouts win: touching 8 bytes of a 128-byte line wastes 94% of the transfer. Topic 12 in one figure.
filter on one 8-byte column, 128 B cache lines (M-series):
row layout: line = [ a │ b c d e f g ... padding ... ] use 8 B / 128 B → 94% wasted
col layout: line = [ a a a a a a a a a a a a a a a a ] use 128 B → 0% wasted
The measurement engine behind Fig 3.4 (and behind cache_ladder) is a
pointer chase through a shuffled ring — every load depends on the previous
one, so latency can’t hide behind memory-level parallelism:
#![allow(unused)]
fn main() {
// ring[i] holds the index of the next element to visit (a shuffled cycle).
// Because address N+1 is unknown until load N retires, ns/step == the raw
// latency of whatever level the working set lands in — L1, L2, SLC, DRAM.
fn chase(ring: &[usize], steps: usize) -> usize {
let mut i = 0;
for _ in 0..steps {
i = ring[i]; // serialized miss: nothing to prefetch
}
i // return it so the loop isn't dead code
}
// grow ring.len() from 16 KB to 512 MB and plot ns/step → the plateaus
}
§4 — Virtual memory (~10 pages)
- 4.1–4.2 Page tables are a 4-level radix tree walked in memory — a TLB miss is up to 4 dependent loads. Sound familiar? It’s pointer chasing (topic 0 §2).
A TLB miss is pointer chasing in silicon — 4 dependent memory loads:
CR3 ──► PGD entry ──► PUD entry ──► PMD entry ──► PTE ──► finally, your data
(load 1) (load 2) (load 3) (load 4)
each load can itself miss cache ⇒ worst case ~4 × DRAM latency
before the ACTUAL access even starts
- 4.3 (the key bit) TLB reach: 4 KB pages × ~2K entries ≈ a few MB — far smaller than working sets. Why databases care about huge pages (2 MB/1 GB; 16 KB base pages on Apple Silicon already 4x the reach).
- Skim the virtualization part (4.4+).
§6 — What programmers can do (skim for the checklist)
Sequential access > random; -O2 -march=native; struct layout: hot fields together,
sorted by size; pahole-style padding audits; NUMA awareness (§5/§7 — skip until a
NUMA box matters). §6.2’s cache-oblivious matrix transpose is worth 10 minutes — it’s
the intellectual ancestor of blocked/vectorized execution (topic 11).
Questions to answer in notes.md when done
- Why does
cache_laddershow gradual transitions between plateaus rather than steps? (Hint: set associativity + random chain touching multiple sets.) - Predict: on M-series with 128 B lines, at what stride does a strided-read benchmark stop getting faster per element? Verify with a quick experiment.
- How many memory accesses can a single TLB miss add on a 4-level page table, and why
don’t we see it in
cache_ladder? (Hint: 16 KB pages, working set vs TLB reach.)
Takeaway
Every table in topic 0 §2 is a compressed version of this paper. Drepper’s method — plot access cost against working-set size and explain every inflection — is the habit; the numbers you regenerate yourself on your own machine.
References
Papers
- Drepper — “What Every Programmer Should Know About Memory” (Red Hat, 2007) — PDF (~114 pages — read §3–§4 properly, skim §6, skip the rest; the study guide’s advice stands)
Fair benchmarking: eight ways a system comparison lies
Criterion and Tene cover how a single measurement lies; this chapter — built on a 6-page DBTest ’18 paper from the future DuckDB authors — covers how a comparison between systems lies. It is the database-specific companion to topic 0 §1, and its Appendix A checklist is an artifact you will reuse against every capstone comparison in this curriculum.
Structure
- §1–2 Intro + related work (skim, but note the gems): Jain’s mistakes vs games distinction; Hoefler & Belli’s 12 HPC benchmarking rules; van der Kouwe’s survey finding benchmarking crimes in 96% of 50 top-tier systems papers; Purohith et al.: SQLite throughput varies 28x on one parameter, and 0 of 16 surveyed papers reported it.
- §3 The eight pitfalls, each with a mock TPC-H SF1 experiment (MariaDB / PostgreSQL / SQLite / MonetDB, single-threaded) — this is the part to read carefully.
- §4 + Appendix A Conclusions + the checklist (the artifact you’ll reuse).
The eight pitfalls (§3)
flowchart TD
Q["Where a system comparison lies"]
Q --> SU["Setup"]
Q --> CMP["Comparison"]
Q --> MEA["Measurement"]
Q --> RES["Results"]
SU --> P1["3.1 non-reproducible<br/>(the Escher result)"]
SU --> P2["3.2 untuned baseline<br/>(debug build, default config)"]
CMP --> P3["3.3 apples vs oranges<br/>(kernel vs full system)"]
CMP --> P4["3.4 tuned to the benchmark<br/>(known selectivities)"]
MEA --> P5["3.5 cold/hot conflated"]
MEA --> P6["3.6 restart ≠ cold<br/>(OS page cache warm)"]
MEA --> P7["3.7 preprocessing ignored<br/>(index build, auto-imprints)"]
RES --> P8["3.8 fast but wrong<br/>(diff against a trusted engine)"]
- Non-reproducibility (3.1) — the Escher result: Fig. 2 shows MariaDB < Postgres < SQLite < MariaDB*, all “true”. The trick: MariaDB* used DOUBLE instead of DECIMAL columns — both allowed by the TPC-H spec, invisible unless the full setup is published.
- Failure to optimize (3.2) — the baseline is the author’s competitor, so nobody tunes it. MonetDB debug build vs release: 1.58s → 0.87s (Q1). Postgres default vs configured: 0.47s → 0.27s (Q9). “DBMS A vs B” can be the same system twice.
- Apples vs oranges (3.3) — hand-written Q1 program (“TimDB”) vs MonetDB: 0.03s vs 0.87s. A standalone kernel skips parsing, transactions, overflow checking, concurrency. Compare full system vs full system, and verify identical results.
- Overly-specific tuning (3.4) — TPC-H’s selectivities/cardinalities are known, so join heuristics can be tuned to the benchmark. Antidote: also run non-benchmark queries.
- Cold vs hot runs (3.5) — report them separately; hot runs discard initial iterations (criterion’s warmup, formalized).
- Cold vs warm runs (3.6) — subtler: restarting the server is NOT a cold run; the
OS page cache is still warm. True cold = stop server,
echo 3 > /proc/sys/vm/drop_caches, start, one query, repeat. (Nearly impossible in cloud — the hypervisor caches too.) - Ignoring preprocessing time (3.7) — excluding index-build time rewards expensive-to-build indexes. Watch automatic preprocessing: MonetDB builds imprints on first range filter and dictionary-encodes strings at load — “cold” first-query timing silently includes/excludes work per system.
- Incorrect code (3.8) — a fast wrong answer wins benchmarks (skipped overflow handling, hardcoded group counts). Always diff results against a trusted engine.
Methodology to steal (§3 preamble)
Their own reporting standard: median + non-parametric quantile-based 95% confidence intervals, all scripts/configs/plots public. Same philosophy as criterion (§ bootstrap CIs), applied to system-level runs.
Connections to this repo
- The capstone’s M4 backend shootout and M22 LDBC 3-way FalkorDB comparison must pass Appendix A — especially 3.2 (tune the reference FalkorDB properly) and 3.3 (a young engine missing features is structurally “TimDB” — say so explicitly next to numbers).
- FalkorDB/benchmark audit overlaps: no warmup (3.5), timeout asymmetry (3.3-ish), uniform keys (3.4’s cousin — tuning the workload to flatter caches).
- 3.7 is why M0’s
workloadcrate measures generation throughput separately from engine time.
Questions to answer in notes.md
- Which Appendix A checklist items does FalkorDB/benchmark currently fail? (I count at least four — list them.)
- The paper reports medians + CIs; Tene demands full percentile curves + max. When is each right? (Hint: throughput-style repeated identical runs vs latency under load.)
- Which “automatic preprocessing” (3.7) exists in FalkorDB that a fair Neo4j comparison must account for?
Takeaway
Appendix A is a reusable review checklist: benchmarks chosen + justified; reproducible
(hardware, params, code, data); both systems optimized; same functionality; cold/hot
separated and correctly collected; preprocessing equalized; results verified; medians +
CIs over several runs. Pin it next to every capstone notes.md comparison.
References
Papers
- Raasveldt, Holanda, Gubner, Mühleisen — “Fair Benchmarking Considered Difficult: Common Pitfalls in Database Performance Testing” (DBTest 2018) — PDF — 6 pages, one evening; read §3 carefully, Appendix A is the reusable artifact. (CWI — Raasveldt & Mühleisen later created DuckDB.)
Code
- pholanda/FairBenchmarking — the paper’s experiment scripts and configs
redis-benchmark: a throughput tool wearing latency clothes
The load generator you’ll imitate — and the mistake you’ll avoid. In one dependency-free file, redis-benchmark shows a masterclass in cheap pipelining (one pre-built buffer, patched in place) and, in the same 2000 lines, the canonical case of coordinated omission: a closed loop that measures service time and calls it latency. Two questions drive the read: how does it implement pipelining, and what does it get wrong about coordinated omission?
Structure
| Lines | What |
|---|---|
| 61–108 | struct config — all global state, incl. pipeline, two HdrHistograms (99–100) |
| 110–130 | struct _client — note start, latency, pending |
| 368–375 | resetClient — the closed loop, in 8 lines |
| 420–439 | clientDone — finished batch → resetClient (keepalive) or reconnect |
| 442–553 | readHandler — latency capture + histogram recording |
| 555–602 | writeHandler — batch start, c->start = ustime() |
| 625+ | createClient — pipelining via buffer replication |
| 830+ | showLatencyReport — percentiles off HdrHistogram |
| 946 | benchmark() — sets up clients, runs the event loop |
| 1696 | main — test loop over SET/GET/INCR/… |
How pipelining works (the elegant part)
c->obuf — the whole benchmark is one pre-built buffer, written over and over:
┌──────┬────────┬──────────────────────────┬──────────────────────────┬─ ─ ─
│ AUTH │ SELECT │ SET key:__0000000042__ v │ SET key:__0000000913__ v │ ×pipeline
└──────┴────────┴──────────▲───────────────┴──────────▲───────────────┴─ ─ ─
trimmed after 1st reply └── randptr[] patch digits in place — no re-serialization
There is no request queue. createClient (625) copies the same command bytes
config.pipeline times into one output buffer c->obuf, sets
c->pending = config.pipeline, and the event loop just writes the whole buffer and
counts replies back down (readHandler, 458: while(c->pending)). Randomized keys are
patched in place through saved pointers into the buffer (randptr, 377–393 — writes
digits directly into the command bytes, no re-serialization). Auth/SELECT prefix
commands ride in the same buffer once and are trimmed after the first reply (506–523).
Cost of the trick: within one batch every pipelined command has the same key randomization per slot of the buffer, and the whole batch is one timing unit.
Where the latency numbers come from
writeHandler574:c->start = ustime()when a batch begins writing.readHandler452:if (c->latency < 0) c->latency = ustime() - c->start— on the first read event only. So “latency” = batch send → first bytes of first reply. Deliberate (the comment says parsing overhead shouldn’t count), but it means the last reply’s extra wait is invisible.- 528–541: that single value is recorded into the HdrHistogram once per reply —
all
pipelinerequests inherit the first reply’s latency. With-P 100, one measurement pretends to be 100.
What it gets wrong about coordinated omission
flowchart LR
W["writeHandler (555)<br/>c->start = ustime()"] --> R["readHandler (442)<br/>latency = now − start<br/>on FIRST reply only (452)"]
R --> D["clientDone (420)"]
D --> RC["resetClient (368)"]
RC -->|"next batch starts only after<br/>the previous one finished"| W
The cycle above is the whole problem: it’s closed — there is no intended-arrival
schedule anywhere, so a server stall pauses the generator itself. In detail:
clientDone (420) → resetClient (368) → re-arm write handler
→ next batch starts after the previous one finished. c->start is set at send time,
not against any intended schedule. Consequences, in Tene’s terms:
- No target rate exists. The benchmark always sends as fast as the server answers, so a stall (fork for RDB save, AOF fsync, slow command) simply pauses the generator — requests that would have arrived during the stall are never sent, never measured. You get exactly one bad sample per client per stall instead of thousands.
- It measures service time and calls it latency. Queueing delay a real open-world client would experience never appears.
- HdrHistogram doesn’t save it. Redis added HdrHistogram (config lines 99–100) and
full percentile output (830+) — good display of a biased sample. Correction would
require an intended-arrival schedule, which doesn’t exist here. (Compare wrk2, which
was written to fix exactly this; memtier_benchmark has
--rate-limiting.) - Small extra:
hdr_record_valueclamps atCONFIG_LATENCY_HISTOGRAM_MAX_VALUE(line 530) — the worst outliers are also truncated.
Both loops, distilled to their timing skeletons — the entire bug and the entire fix is where the clock starts:
#![allow(unused)]
fn main() {
// closed loop (redis-benchmark): clock starts at SEND — a server stall
// pauses the generator, so the requests that would have queued up behind
// the stall are never sent, never measured.
loop {
let start = now();
send_batch_and_wait_all_replies();
record(now() - start); // one bad sample per stall
}
// open loop (the fix): clock starts at the INTENDED send time — the
// schedule advances whether or not the server keeps up.
let mut intended = now();
loop {
intended += period; // target rate exists
wait_until(intended);
send_one(); // reply handled async
on_reply(move |t| record(t - intended)); // queueing delay is visible
}
}
Takeaway
redis-benchmark is a throughput tool with percentile decoration: buffer-replication pipelining is a masterclass in doing the minimum work per event-loop tick, but the closed loop means its latency numbers systematically flatter the server under stress. For the capstone (M7+): keep the obuf trick, add an intended-send schedule.
References
Code
- redis
src/redis-benchmark.c(2028 lines, pinned at Redis 8.6.2 /a176d1225) — one file, no dependencies beyond hiredis + theaeevent loop; readable top to bottom in an evening
db_bench: the shared vocabulary of storage benchmarking
fillseq, readrandom, readwhilewriting — these workload names started in
LevelDB, were extended by RocksDB, and now appear in every LSM paper since.
This chapter is a skim route through the 10,000-line tool that defines them:
the goal is the vocabulary and the measurement shape, not the harness code.
Name your own benchmarks in this language and your numbers become comparable
to two decades of published results.
Skim route (30–60 min)
| Lines | What |
|---|---|
| 115–170 | DEFINE_string(benchmarks, ...) — the full workload menu; read the help text below it (172+), it’s the best documentation |
| 275–458 | The knobs that define a workload: num, threads, value_size, histogram, read_random_exp_range (452) |
| 1708–1717 | keyrange_dist_a..d — the mixgraph skew model |
| 2436 | class Stats — per-thread stats, HistogramImpl per op type |
| 2564 | Stats::FinishedOps — where each op’s micros get recorded |
| 3802 | GenerateKeyFromInt — int → fixed-width key; all key distributions reduce to picking the int |
| 4030–4140 | Benchmark::Run() dispatch: name == "fillseq" → method pointer — the map from workload name to implementation |
| 4583 | RunBenchmark(n, name, method) — spawns N threads, merges per-thread Stats (histogram merge at 2488, same lesson as Tene: merge histograms, never average percentiles) |
| 5869 | enum WriteMode { RANDOM, SEQUENTIAL, UNIQUE_RANDOM } |
| 6088 | class KeyGenerator — how UNIQUE_RANDOM permutes the key space |
The vocabulary worth memorizing
Under every workload name sits the same skeleton — pick an integer, format
it as a fixed-width key (GenerateKeyFromInt :3802, WriteMode :5869,
KeyGenerator :6088):
#![allow(unused)]
fn main() {
// every fill* workload reduces to how the next integer is chosen
fn next_key(mode: WriteMode, i: u64, n: u64, rng: &mut Rng, perm: &[u64]) -> Key {
let int = match mode {
WriteMode::Sequential => i, // fillseq: in-order, no
// compaction debt
WriteMode::Random => rng.next() % n, // fillrandom/overwrite:
// duplicates → garbage
// → compaction pressure
WriteMode::UniqueRandom => perm[i as usize], // pre-shuffled permutation:
// random order, no dups
};
generate_key_from_int(int) // zero-padded fixed width
}
}
fillseq— sequential-order load. Fast path for an LSM (no compaction debt); papers use it to build the DB before the real test.fillrandom/overwrite— random inserts vs. random overwrites of existing keys (overwrite creates garbage → compaction pressure — different beast).fillsync— one fsync per write, N/1000 ops: measures durability cost, not throughput.readrandom/readseq/readreverse— point lookups vs. iterator scans.readwhilewriting— 1 writer + N readers: the “does compaction wreck my read tail?” test. The*whilemerging/*whilescanningvariants isolate other interference.seekrandom— iterator Seek cost (touches every level; very different profile from Get).multireadrandom— MultiGet batching.mixgraph(4133) — the odd one out: models Facebook’s measured production distributions (the “Characterizing, Modeling…” FAST’20 paper) with two-term-exponential key ranges (keyrange_dist_a..d) and Pareto value sizes. The industrial answer to “uniform random keys are the wrong distribution” — same motivation as the capstone’s Zipfianworkloadcrate.- Standard invocation shape:
db_bench --benchmarks=fillseq,readrandom --num=10000000 --value_size=100 --histogram— comma list runs in order against the same DB, so earlier benchmarks create the state later ones measure. That ordering is the methodology.
What to notice about measurement
flowchart TD
F["--benchmarks=fillseq,readrandom,--histogram<br/>comma list runs IN ORDER against the same DB"]
F --> RUN["Benchmark::Run() (4030)<br/>workload name → method pointer"]
RUN --> RB["RunBenchmark(n, name, method) (4583)<br/>spawn N threads"]
RB --> T1["thread 1<br/>Stats + HistogramImpl (2436)"]
RB --> T2["thread 2 ..."]
RB --> TN["thread N"]
T1 --> M["merge per-thread histograms (2488)<br/>never average percentiles — Tene's rule"]
T2 --> M
TN --> M
M --> OUT["default output: throughput<br/>latency histogram only with --histogram"]
- Per-op latency goes through
FinishedOps(2564) into a plainHistogramImpl— reported only with--histogram. Default output is throughput (ops/s, MB/s). - It’s a closed loop like redis-benchmark: each thread issues the next op after the
previous completes. There’s a
--benchmark_write_rate_limit/read-rate variant for paced writes, but no coordinated-omission correction — same critique applies. - db_bench measures the embedded engine (no network), so “latency” here is service time by construction — legitimate for engine work, misleading if quoted as user latency.
Takeaway
db_bench’s value is the workload taxonomy, not the harness. When topic 4 (LSM) and M4
(backend shootout) arrive, name capstone benches in this vocabulary (fillseq,
readrandom, readwhilewriting) so numbers are comparable against published RocksDB
results.
References
Papers
- Cao, Dong, Vemuri, Du — “Characterizing, Modeling, and Benchmarking
RocksDB Key-Value Workloads at Facebook” (FAST 2020) — the measured
production distributions behind
mixgraph; optional, skim §4-5
Code
- rocksdb
tools/db_bench_tool.cc(~10,400 lines, shallow clone @7c80a5a) — do not read this linearly; it’s a flag-driven monolith — follow the skim route table above (30–60 min)
Topic 0 — Notes
Numbers from this machine (Apple Silicon, macOS). Record why, not just what.
Talk: Gil Tene — “How NOT to Measure Latency” (watched ✅)
Core thesis: almost everyone measures latency wrong, and the errors all point the same direction — making systems look better than they are.
- Latency is a distribution, never a number. Means and standard deviations are meaningless for latency (it’s multi-modal, heavy-tailed, not normal). Always report percentiles — and the whole curve, not just p50/p99.
- The tail is what users experience. A page load touching ~100 resources hits the p99 almost every time (1 − 0.99¹⁰⁰ ≈ 63%). “p99.9 doesn’t matter” is backwards: the more requests per user interaction, the deeper the percentile that dominates UX.
- Coordinated omission — the big one. If the load generator waits for a response
before sending the next request, a server stall silences the generator exactly when
things go bad: the bad results are omitted from the data, coordinated with the
stall. A 100s test with one 50s pause can report “p99 < 1ms” while reality is
~25s average during half the test. The error is ~1000x+, not a rounding issue.
- Fix: measure against the intended send schedule (constant-rate arrival), not the actual send time. If a request should have gone out at t=5s but went out at t=55s, its latency includes those 50s of wait.
- This is why HdrHistogram has correction modes and why wrk2/redis-benchmark grew constant-throughput modes.
- Service time ≠ response time. Service time = how long the server took once it started; response time = what the client experiences, including queueing. Load generators that back off measure service time and call it response time. Throughput-vs-latency plots made this way are fiction beyond saturation.
- “Sustainable throughput” framing. Don’t ask “what’s the max throughput?” — ask “what’s the max throughput at which we still meet the latency requirements?” Test by stating requirements first (e.g. p99.9 < 20ms, max < 200ms), then finding the highest load that passes. A benchmark without a latency requirement is a throughput benchmark, and throughput alone is easy to game.
- Beware the hockey stick you can’t see. Plotted percentile curves always bend up hard somewhere (“the hockey stick”); tests that stop at p99 just hide where. Plot to the max recorded value — the max is a real event that happened, not an outlier to trim.
- Never average percentiles across intervals/machines — p99s don’t average. Merge the histograms (HdrHistogram), then read percentiles off the merged data.
Rules for this repo’s benchmarks (from the talk):
- Capstone server benches (M7+) must use a constant-rate open-loop load generator with coordinated-omission correction (HdrHistogram), never closed-loop request→wait→request.
- Report p50/p90/p99/p99.9/max + full percentile plot; never mean latency.
- Criterion is fine for CPU microbenches (throughput of kernels), but not an oracle for request latency — different tool for a different question.
Experiment 3 — branch_misprediction (done, first pass)
| Variant | Sorted | Shuffled |
|---|---|---|
| branchy | 338 µs (3.1 Gelem/s) | 2.75 ms (0.38 Gelem/s) |
| branchless | 70 µs (15.0 Gelem/s) | 71 µs (14.7 Gelem/s) |
- 8.1x sorted→shuffled gap for the branchy version — the classic misprediction penalty. 1M elements, ~50% unpredictable taken rate ⇒ ~500K flushes; (2750−338)µs / 500K ≈ ~4.8 ns ≈ 15 cycles per miss at ~3.2 GHz. Matches the §3 estimate.
- Surprise: with plain
sum += xin the branch, LLVM if-converts + auto-vectorizes and the gap vanishes (both ~70µs). Had to putblack_box(x)inside the taken path to keep a real branch. Lesson: on modern compilers the famous StackOverflow sorted-array effect only reproduces if vectorization is defeated — always check the asm. - Branchless = data dependence instead of control dependence ⇒ NEON select, 4.8x faster than even the perfectly-predicted branchy loop.
Experiment 2 — lookup_shootout (done)
ns per lookup (median, 1024 shuffled probes, all hits):
| n | vec_linear | vec_binary_search | hashmap | btreemap |
|---|---|---|---|---|
| 100 | 17.8 | 3.0 | 7.4 | 5.0 |
| 1e3 | 141 | 4.9 | 6.8 | 7.9 |
| 1e4 | 1,350 | 8.1 | 7.2 | 11.1 |
| 1e5 | 13,400 | 16.0 | 8.3 | 17.2 |
| 1e6 | — | 25.8 | 8.8 | 26.6 |
| 1e7 | — | 44.1 | 9.3 | 38.9 |
- HashMap is almost flat (7→9.3 ns) even at 1e7 — a ~160MB table where a random probe “should” cost a ~100ns DRAM miss. The 1024 probes are independent, so the out-of-order window overlaps many misses (memory-level parallelism — §2’s array-scan lesson applied to hashing). Single dependent lookups would be much slower; batch APIs exist precisely to expose this parallelism.
- Binary search beats HashMap up to n ≈ 1e4 (3.0–8.1 ns): log₂(n) dependent compares, but the top of the tree stays cache-resident. Past 1e5 the deep levels miss and its dependent-load nature (no MLP within one search) makes it grow ~log(n) × miss cost.
- BTreeMap overtakes binary search at 1e7 (38.9 vs 44.1 ns): ~11-key nodes mean
fewer distinct cache lines touched than 23 scattered probes of
binary_search— page-sized fanout is the whole point of B-trees (topic 3). - Study-guide claim busted:
iter().findlinear scan never beats hashing at n ≥ 100 (17.8 vs 7.4 ns at n=100). Early-exit branchy scan averages n/2 compares; the real crossover sits somewhere below n ≈ 32, smaller than folklore says. A branchless/SIMD scan over a tiny array would move it — revisit in topic 17.
Experiment 1 — cache_ladder (done — after fixing a lying benchmark)
First version lied. chase restarted at idx = 0 every criterion iteration, so
every iteration re-walked the same 65,536 slots — an ~8MB hot path that fits in
L2/SLC no matter how big the array. The “DRAM” plateau read ~25 ns (mostly TLB misses,
since those 64K slots spread across the whole 512MB). Textbook §1 failure mode: the
benchmark measured cache residency created by the benchmark itself. Fix: carry idx
across iterations so the walk keeps visiting fresh lines.
ns per dependent access (median, fixed version):
| Working set | ns/access | Level |
|---|---|---|
| 16KB–128KB | 1.02 | L1 (P-core L1d is 128KB — the plateau ends exactly there) |
| 512KB–1MB | 5.3–5.8 | L2 |
| 4–8MB | 7.6–9.0 | L2 (Apple’s per-cluster L2 is huge — 16MB-class) |
| 16MB | 17.1 | falling out of L2 into SLC |
| 32MB | 59.6 | SLC → DRAM transition |
| 64MB | 87.4 | DRAM |
| 128–512MB | 104–113 | DRAM + growing TLB-miss share |
- Matches the §2 ladder within noise: ~1ns L1, ~5ns L2, ~100ns DRAM. One dependent DRAM access = ~110 sequential adds — verified, not folklore.
- Transitions are gradual, not steps (Drepper’s question 1): random chains straddle levels probabilistically, and set-associativity evicts unevenly.
- The last rise (64MB → 512MB: 87 → 113 ns) is TLB reach, not cache: 512MB / 16KB pages = 32K pages ≫ TLB entries, so most steps add a page-walk on top of the miss.
Flamegraph (done)
Captured from lookup_shootout/hashmap/10000000 via macOS sample → rustfilt →
inferno (SVG committed next to these notes; cargo flamegraph’s xctrace path is
broken on current Xcode). For interactive profiling use:
samply record ./target/release/deps/lookup_shootout-* --bench --profile-time 10 <filter>
- 21% of samples are inside SipHash (
core::hash::sip::Hasher::write) — Rust’s default DoS-resistant hasher costs a fifth of total lookup time even on u64 keys at a size (10M) where DRAM misses should dominate. Swapping inFxHashMap/ahashis the obvious first optimization for the capstone’s internal (non-adversarial) maps. - The remaining ~79% is the inlined probe loop (hashbrown’s SIMD group probe + the memory stalls themselves) — invisible as separate frames because it’s fully inlined into the bench closure. Sampling profilers attribute stalls to the instruction that waits; only counters (topic 0 §4) can split “executing” from “waiting on DRAM”.
M0 workload generator
- Seeded
StdRng+rand_distr::Zipf(s=0.99, YCSB default), skewed toward low ids (oldest nodes = hubs, matching preferential attachment). - Generation throughput: ~11 M ops/s (9.1 ms / 100K ops) — fine for now; if engine benches ever exceed ~10 M ops/s, pre-generate op vectors outside the timed loop.
- Zipf sampling dominates cost (rejection sampling per draw). Alias-table or precomputed CDF is the known fix — note for later, not needed yet.
Topic 1 — Storage Engine Landscape: B-Tree vs LSM
The single most consequential design decision in a database. Every later topic — WAL, buffer pool, MVCC, compaction, columnar layout — is a refinement of the choice made here: update in place, or write out of place?
Outcomes
By the end you can:
- Draw the write path and read path of both engine families from memory.
- Predict which family wins a given workload (write-heavy / point-read / scan / space-constrained) before benchmarking, then verify.
- Explain any measured difference in terms of read/write/space amplification.
- Recite the RUM conjecture and give one real engine as an example of each corner.
1. The two families
Everything on disk descends from two ideas:
- Page-oriented, in-place (B-tree): the database is a tree of fixed-size pages (4–16KB). Updates find the page and overwrite it. Reads are 1 tree descent. SQLite, Postgres, LMDB, InnoDB, redb.
- Log-structured, out-of-place (LSM): the database is a log. Updates append to a memtable + WAL; background jobs sort and merge immutable runs (SSTs). Reads must check every place a key could hide. RocksDB, LevelDB, Cassandra, fjall, Pebble.
flowchart TB
subgraph BTREE["B-tree: update in place"]
W1["write(k,v)"] --> D1["descend tree<br/>(root→leaf, ~3-4 pages)"]
D1 --> P1["modify leaf page<br/>in buffer pool"]
P1 --> WAL1["WAL append<br/>(for crash recovery)"]
P1 -. later, checkpoint .-> DISK1["overwrite page<br/>on disk"]
end
subgraph LSM["LSM: write out of place"]
W2["write(k,v)"] --> WAL2["WAL append"]
WAL2 --> MT["memtable insert<br/>(sorted, in RAM)"]
MT -- full --> FLUSH["flush → immutable SST<br/>(sequential write)"]
FLUSH -.-> COMP["compaction merges SSTs<br/>(background, rewrites data)"]
end
The read paths mirror the write paths, inverted:
B-tree point read: LSM point read:
root ──► inner ──► leaf memtable? ── miss ─┐
(3-4 page reads, cached sealed memtables? ── miss ─┤
upper levels ⇒ often 1 IO) L0 SSTs (each one!) ── miss ─┤ bloom filters
L1 SST ── miss ─┤ exist to skip
L2 SST ── hit! ─┘ most of these
2. Amplification — the vocabulary of the whole field
For a logical write of B bytes / logical read of one key:
- Write amplification (WA): physical bytes written ÷ logical bytes. B-tree: whole page per dirty record (4KB page / 100B row ⇒ up to 40x, amortized by the buffer pool). LSM: each byte rewritten once per level by compaction (leveled ⇒ ~10x per level fanout… typical WA 10–30x). On SSDs, WA burns endurance and steals bandwidth.
- Read amplification (RA): physical reads ÷ logical reads. B-tree: tree height (~O(log_fanout n), mostly cached). LSM: number of sorted runs to check — memtable + L0 files + one per level; bloom filters cut the misses, not the final hit.
- Space amplification (SA): physical size ÷ logical size. B-tree: fragmentation + ~30% average page slack. LSM: obsolete versions awaiting compaction (tiered can sit at 2x+; leveled ~1.1x).
3. The RUM conjecture
For Read, Update, and Memory (space) overhead: optimizing any two makes the third worse. You can pick where to sit, not escape the triangle.
Read-optimal
▲
╱ ╲
╱ ╲ B-tree → good R, ok U, poor M (slack)
╱ ○ ╲ LSM leveled → good M, ok U, poor R
╱ B-tree╲ LSM tiered → good U, poor R, poor M
╱ ╲ hash index → best point-R, no scans
╱ LSM-l ╲ bitmap/bloom→ M-optimal, approximate R
╱ LSM-t╲
▼───────────────▼
Update-optimal Memory-optimal
The conjecture’s sharpest claim: engines are not “good” or “bad”, they are positions. Tuning knobs (compaction style, page fill factor, bloom bits/key) move you along the edges continuously. Monkey (topic 4) is literally a Lagrange-multiplier walk on this triangle.
4. Where each family wins
| Workload | Winner | Why (amplification argument) |
|---|---|---|
| Write-heavy, random keys | LSM | sequential IO only; B-tree dirties a random page per write |
| Point reads, hot working set | B-tree | 1 descent, upper levels cached; LSM pays run-check tax |
| Range scans | B-tree (usually) | leaves are one contiguous logical order; LSM merges k runs per scan |
| Space-constrained | LSM leveled | ~1.1x SA vs page slack + fragmentation |
| Cold-cache point reads | LSM + blooms | one bloom-guarded IO vs full-height descent |
| Mixed read/write at scale | it depends | this is why both families still exist — measure |
Hybrid reality check: Postgres (B-tree) has a WAL — a log. RocksDB (LSM) has block indexes inside SSTs — little B-trees. The families differ in what is authoritative: the pages, or the log.
5. Code reading (4–6 h)
Read the two Rust engines as protagonists, skim the other two for contrast:
- fjall (~/repos/fjall) — small, clean Rust LSM. Trace insert → journal → memtable
→ flush, and get → memtable → SSTs.
→ chapter:
reading-fjall.md— fjall: the LSM lifecycle in clean Rust - turso (~/repos/turso)
core/storage/— SQLite’s B-tree re-implemented in Rust: slotted pages, cursor descent, balance, pager + WAL. → chapter:reading-turso-btree.md— Turso’s B-tree: the canonical page engine, in Rust - tidesdb (~/repos/tidesdb) — LSM in plain C; nothing hidden behind abstractions.
Skim to see memory ordering and disk layout made explicit.
→ chapter:
reading-tidesdb.md— tidesdb: the same LSM with nothing abstracted away - RocksDB (~/repos/rocksdb) — don’t read it yet; orient in it. Directory map for
topic 4 and beyond.
→ chapter:
reading-rocksdb-layout.md— RocksDB: buy the map before walking the territory
6. Papers (4–5 h)
- O’Neil et al., “The Log-Structured Merge-Tree” (1996) — the origin; read for the
cost model, skim the component algebra.
→ chapter:
reading-lsm-paper.md— The LSM-tree: an IO scheduling policy, not a data structure - Comer, “The Ubiquitous B-Tree” (1979) — still the cleanest B-tree intro ever written.
→ chapter:
reading-comer-btree.md— The B-tree: the memory hierarchy turned into a data structure - Athanassoulis et al., “Designing Access Methods: The RUM Conjecture” (EDBT 2016) —
short, foundational framing paper.
→ chapter:
reading-rum-conjecture.md— The RUM conjecture: optimize two, pay with the third - Hellerstein, Stonebraker, Hamilton — “Architecture of a Database System” (2007) —
read §1–2 + §6 now for the systems map; the rest is reference material for later topics.
→ chapter:
reading-architecture-of-a-dbms.md— Architecture of a DBMS: the five-box org chart
7. Experiment (in experiments/)
engine_shootout — fjall (LSM) vs redb (B-tree) on the same box, same data,
db_bench workload vocabulary (topic 0):
fillrandom— N random-key inserts, measure sustained insert throughput.fillseq— same N, sequential keys (B-tree best case: no random page dirtying).readrandom— Zipfian point reads (s=0.99, the M0 generator’s skew) on the loaded DB.scan— full-range iteration throughput.- Measure on-disk size after each fill (space amplification, directly).
Rules from topic 0: criterion for the timed loops, report medians, fsync settings identical across engines (fair benchmarking §3.2 — durability parity), record engine versions + config in notes.
Predict the winner of each workload in writing (notes.md) before running. Explaining a wrong prediction is the whole point of the topic.
8. Capstone milestone M1 (in ../../capstone/)
Define the storage-backend abstraction for falkordb-scratch:
- Design the trait first, no peeking at the reference: what operations does a
graph engine need from storage? (point get/put, prefix/range scan, atomic batch,
snapshot?) Write it down with rationale in
capstone/notes/m1-backend-design.md. -
storagecrate: trait + in-memory backend (BTreeMap-based is fine — it’s the semantics contract, not the fast path). - Wire the M0 workload generator through the trait; criterion smoke bench.
- Then read the reference
graph/src/storage/backend.rsand write a comparison: what did they need that you didn’t predict, and why?
Done when
- Both experiments’ results are in
notes.mdwith amplification-based explanations, wrong predictions called out explicitly, M1 checklist complete, and you can sketch §1’s two diagrams from memory.
Architecture of a DBMS: the five-box org chart
A database is five cooperating managers, and a storage engine is just one of them. This chapter maps Hellerstein, Stonebraker & Hamilton’s survey — the curriculum’s atlas — onto the topics ahead: read the map chapters this week, then return per-topic as each box gets built. You are NOT reading all ~120 pages now; budget 2 h.
Read NOW (topic 1)
- §1 (main components) — the five-box diagram of a DBMS. Memorize it; it’s the table of contents for topics 3–16:
flowchart TB
CM["Client communications manager<br/>(topic 7: protocol, RESP)"] --> PC["Process manager<br/>(topic 7/9: threads, admission)"]
PC --> RP["Relational query processor<br/>parse → rewrite → optimize → execute<br/>(topics 10-11)"]
RP --> TS["Transactional storage manager<br/>access methods + buffer + locks + log<br/>(topics 1-6, 8-9)"]
TS --> SC["Shared components<br/>catalog, memory allocator, replication<br/>(topics 15, 22)"]
- §2 (process models) — process-per-worker vs thread-per-worker vs event/async; where admission control lives. Directly informs the capstone server (M7/M9).
- §6 (storage management) — spatial control (why DBs fight the filesystem), buffer pools vs OS page cache, the double-buffering problem. This is the section that justifies this topic’s existence.
Skim NOW, return LATER
| Section | Return at |
|---|---|
| §3 parser/rewriter | topic 10 |
| §4 query processor internals | topics 10–11 |
| §5 transactions, ACID, locking | topics 8–9 |
| §7 shared components (catalog, replication) | topics 15–16 |
Questions to answer in notes.md
- §6 argues the DBMS should bypass OS caching (O_DIRECT). What are the two distinct problems with letting the OS cache pages? (Double buffering; the OS evicts/flushes with zero knowledge of WAL ordering.)
- Which of the five §1 boxes does fjall implement? redb? (Neither has a query processor or client manager — “storage engine” ≠ “database”. The capstone builds the other boxes on top, milestone by milestone.)
- 2007 blind spots: name three things the paper couldn’t see coming. (Candidates: NVMe erasing the seek-time mental model, cloud disaggregation — topic 28, columnar dominance for analytics — topic 12, LSM taking over write paths.)
The one-line takeaway
A database is five cooperating managers, and a storage engine is just one of them — this paper is the org chart for everything the capstone will build.
References
Papers
- Hellerstein, Stonebraker, Hamilton — “Architecture of a Database System” (Foundations and Trends in Databases, 2007) — PDF — read §1–2 + §6 now (2 h); §3–§5 and §7 are reference material to return to per the table above
The B-tree: the memory hierarchy turned into a data structure
Node size = transfer unit, fanout = whatever fits, height = the IO budget —
that’s the whole design, and Comer’s 1979 survey is still the cleanest
exposition of it in print. This chapter reads it as the theory half of the
topic’s B-tree thread: everything in turso’s btree.rs is a footnote to this
paper, and §3’s B+ variant is the shape every real engine actually shipped.
Read in this order
- §1–2 (the problem + the structure) — why balanced trees on disk need high fanout: tree height = number of IOs, and height = log_fanout(n). A 4KB page holding ~100 keys ⇒ 1 billion rows in height 5, of which 3–4 levels cache-resident. This is the whole game.
- §2.1–2.2 (insertion/deletion) — split on overflow, merge/borrow on underflow.
Map to turso:
balance_non_root(btree.rs:2995) is the “borrow from siblings first” refinement — Comer calls redistribution out as reducing splits. - §3 (B+-tree, B-tree variants)* — the section that matters most:
B-tree: keys+values in ALL nodes B+tree: values ONLY in leaves
┌─────k,v─────┐ ┌──────k──────┐ routing only
┌─k,v─┐ ┌─k,v─┐ ┌──k──┐ ┌──k──┐
... [k,v|k,v] ↔ [k,v|k,v] linked leaves
└── range scan = list walk
Why every real engine chose B+: (a) interior nodes hold only keys → higher fanout → shorter tree; (b) leaf-level linked list → range scans without re-descending; (c) uniform “all data at leaf depth” simplifies everything. 4. §4 (applications: VSAM, etc.) — skim for flavor; 1979’s product landscape.
The paper’s core loop, in the B+ shape §3 argues for — note that the cost of this function is exactly its iteration count:
#![allow(unused)]
fn main() {
// height = number of page reads = ceil(log_fanout(n)) — the whole game
fn lookup(pager: &Pager, root: PageId, key: u64) -> Option<Value> {
let mut page = pager.read(root); // each read: 1 potential IO
loop {
match page.kind() {
Interior => {
let i = page.keys().partition_point(|&k| k <= key);
page = pager.read(page.child(i)); // descend one level
}
Leaf => return page.find(key), // B+: values ONLY here;
} // leaf link → range scans
}
}
// 4 KB page ≈ 100 keys ⇒ 1 billion rows at height 5, top 3–4 levels cached
}
Questions to answer in notes.md
- Why do B-trees guarantee ≥50% page occupancy, and what’s the measured average (~69%, ln 2)? Connect to space amplification in the README.
- B*-tree defers splits by redistributing into siblings. What does turso implement — B+, B*, or a hybrid?
- Comer’s B-trees assume one page write is atomic. It isn’t (torn writes). Which later machinery patches this hole? (WAL — topic 5; checksums — topic 3.)
The one-line takeaway
The B-tree is the memory hierarchy turned into a data structure: node size = transfer unit, fanout = whatever fits, height = the IO budget.
References
Papers
- Comer — “The Ubiquitous B-Tree” (ACM Computing Surveys 1979) — ~15 pages, 2 h; read §1–3 in order, §3 (the B+/B* variants) matters most, skim §4
Code
- turso
core/storage/btree.rs— the living counterpart; walked in reading-turso-btree.md
fjall: the LSM lifecycle in clean Rust
The LSM protagonist of this topic — a codebase small enough that insert-to-SST
is traceable in an afternoon, and layered well enough to steal from. fjall is
the keyspace/journal/scheduling layer; the actual tree (memtable, SSTs,
blooms, block index) lives in the external lsm-tree crate (Cargo.toml:29).
Reading fjall shows you the LSM lifecycle; topic 4 descends into lsm-tree
itself.
Layout
src/
├─ lib.rs module map — start here
├─ keyspace/mod.rs insert/get/memtable rotation — the heart
├─ journal/writer.rs WAL writes
├─ flush/worker.rs sealed memtable → SST
├─ compaction/worker.rs compaction runs
├─ supervisor.rs background orchestration
├─ worker_pool.rs flume-channel thread pool
└─ poison_dart.rs panic guard
1. The write path
Start at Keyspace::insert() — src/keyspace/mod.rs:905. Read the whole function;
it is the LSM write path diagram from the README:
flowchart LR
I["insert()<br/>mod.rs:905"] --> J["journal write_raw<br/>mod.rs:928"]
J --> P["journal persist/fsync<br/>mod.rs:932"]
P --> M["tree.insert → memtable<br/>mod.rs:940"]
M --> C["check_memtable_rotate<br/>mod.rs:831"]
C -- over limit --> R["request_rotation<br/>mod.rs:818"]
R --> S["inner_rotate_memtable<br/>mod.rs:727<br/>seal + enqueue flush"]
S --> F["flush::run<br/>flush/worker.rs:12<br/>memtable → SST"]
De-sugared, the function is the topic’s write-path diagram in ten lines:
#![allow(unused)]
fn main() {
fn insert(&self, key: &[u8], value: &[u8]) -> Result<()> {
let journal = self.journal.lock(); // journal lock BEFORE memtable —
journal.write_raw(key, value)?; // replay order must equal apply order
journal.persist(self.durability)?; // fsync per policy, not per write
let bytes = self.tree.insert(key, value); // memtable: sorted, in RAM
self.write_buffer.fetch_add(bytes); // atomic accounting → backpressure
if self.memtable_over_size_limit() {
self.rotate_memtable(); // seal it + enqueue flush task —
} // event-driven, no polling
Ok(())
}
}
Questions to answer while reading:
- The journal lock is taken before the memtable insert. What ordering bug would reordering them create? (Hint: replay after crash.)
mod.rs:946— write buffer accounting is an atomic counter. Where does backpressure actually happen when writers outrun flushing?- What durability do you get per insert by default — fsync every write, or batched? Compare with what you’ll set in the experiment (durability parity!).
2. The read path
Keyspace::get() — src/keyspace/mod.rs:623 — is two lines: it delegates to
tree.get(key, SeqNo::MAX). The run-checking (memtable → sealed → L0… blooms, block
index) is all inside lsm-tree. Note where bloom policy is configured:
src/keyspace/config/filter.rs:8–43 (BitsPerKey vs FalsePositiveRate, per-level
policies — Monkey’s idea productized; topic 4).
3. Compaction scheduling
- Strategies re-exported at
src/compaction/mod.rs:7:Leveled,Fifo. - Worker:
compaction/worker.rs:10— thin:tree.compact(strategy, gc_watermark). - Trigger plumbing:
worker_pool.rs:141–145sendsWorkerMessage::Compact.
The interesting part is what fjall doesn’t do: no compaction geometry here — it
delegates policy to lsm-tree, keeping fjall pure lifecycle/scheduling. Good
layering to steal for the capstone’s storage crate.
4. Aha spots
poison_dart.rs:27–33— aDropguard that poisons the whole keyspace if a background worker panics. Crash-visibly instead of serving from corrupt state.ingestion.rs:37–51— comment explains holding the journal lock acrossfinish()to prevent seqno inversion between writes and bulk ingest. Sequence numbers are the spine of LSM correctness (MVCC preview, topic 8).snapshot_tracker.rs— open-snapshot seqno watermark gates GC: compaction can’t drop a version some reader might still see. This exact problem returns in MVCC vacuuming (topic 8).keyspace/mod.rs:746–750— rotation immediately enqueues the flush task; no polling anywhere. Event-driven background work via channels.
Done when
You can narrate insert-to-SST without looking, and you know which decisions live in
fjall vs lsm-tree.
References
Code
- fjall —
src/keyspace/mod.rs(write/read paths),src/journal/writer.rs,src/flush/worker.rs,src/compaction/worker.rs(shallow clone at~/repos/fjall; line numbers from the clone — expect drift) - the external
lsm-treecrate holds the actual tree (memtable, SSTs, blooms, block index) — topic 4’s territory
The LSM-tree: an IO scheduling policy, not a data structure
Where the origin of the LSM half of the topic’s dichotomy gets read on its own terms. Warning up front: 1996 LSM ≠ 2026 LSM. The paper’s C0/C1 components are B-trees merged by “rolling merge”; modern LSMs (LevelDB lineage) use immutable sorted files + whole-file compaction. Read it for the cost model — that part is timeless — and translate the mechanism as you go.
Why it was written
The motivating workload is TPC-A account history: massive insert rate, few reads. Indexing it with a B-tree means one random page IO per insert. The paper’s thesis: batch inserts in memory, migrate to disk sequentially, and the per-insert IO cost drops by orders of magnitude.
Read in this order
- §1 (intro + The Five Minute Rule) — the economic argument: pages hot enough are worth keeping in RAM; LSM works because recent data is hot by construction.
- §2 (two-component LSM) — the core picture. Translate as you read:
paper (1996) modern (LevelDB lineage)
───────────── ────────────────────────
C0 in-memory AVL/2-3 tree → memtable (skiplist)
C1 on-disk B-tree → a level of immutable SSTs
rolling merge cursor → compaction job
filling disk pages ~100% full → SST blocks, sequentially written
The whole 1996 idea fits in one loop — defer, batch, write sequentially, and pay for it at read time:
#![allow(unused)]
fn main() {
fn insert(&mut self, k: Key, v: Val) {
self.wal.append(&k, &v); // durability: a sequential append
self.c0.insert(k, v); // C0: sorted tree in RAM (≈ memtable)
if self.c0.bytes() > THRESHOLD {
// rolling merge: drain C0 into C1 in key order — pages written
// sequentially, ~100% full; ONE batch amortizes thousands of inserts
merge_into(&mut self.c0, &mut self.c1);
}
}
fn get(&self, k: &Key) -> Option<Val> {
self.c0.get(k).or_else(|| self.c1.get(k)) // the read-amp tax: check
} // EVERY component, newest first
}
- §3 (cost model) — the payoff. The key result, in modern words: with batching,
each insert’s amortized IO cost is
~(entry_size / page_size) × WAsequential bytes instead of one random page read+write. TheCOST_πalgebra formalizes “sequential bandwidth is ~100x cheaper than random IOPS” — the topic 0 ladder in 1996 dollars. - §4–5 (multi-component + concurrency/recovery) — skim. Multi-component C0…Ck
with size ratio
rbetween adjacent components is exactly modern leveled compaction’s fanout-10 geometry; the optimal-rderivation prefigures Monkey/Dostoevsky (topic 4). - §6 (comparison) — skim; the competitors (MD/1 hashing, TSB-tree) are dead, the framing (amortized cost per insert) survived.
Questions to answer in notes.md
- The paper claims LSM trades what for its insert speedup? (It’s read amp — find where the paper admits point reads must check every component.)
- Rolling merge keeps C1 a valid B-tree at all times. What do modern LSMs give up by using immutable files instead, and what do they gain? (Hint: crash recovery complexity vs write pattern.)
- Derive: at size ratio r between components, an entry is rewritten how many times before reaching the last component? Relate to leveled WA ≈ r × levels.
The one-line takeaway
LSM is not a data structure, it’s an IO scheduling policy: convert random writes into sequential ones by deferring and batching — and pay for it at read time.
References
Papers
- O’Neil, Cheng, Gawlick, O’Neil — “The Log-Structured Merge-Tree (LSM-Tree)” (Acta Informatica 1996) — PDF — read §1–3 in order for the cost model; skim §4–6 and translate the mechanism to modern terms as you go
RocksDB: buy the map before walking the territory
RocksDB is everything fjall and tidesdb do, ~50x larger — too big to read,
too important to skip. This chapter is not a walkthrough but an orientation
map: 30 minutes of ls and header-skimming now, so that when topic 4
(compaction), topic 6 (block cache), and topic 22 (db_bench) ask “where does
X live?”, you already know which directory holds the answer.
Directory map
flowchart TB
API["include/rocksdb/db.h<br/>public API"] --> DBI["db/db_impl/db_impl.h<br/>DBImpl — ~3.8K-line god class"]
DBI --> MEM["memtable/<br/>skiplist & friends"]
DBI --> TAB["table/<br/>SST formats<br/>block_based/*"]
DBI --> VS["db/version_set.h<br/>manifest: which SSTs exist"]
DBI --> CMP["db/compaction/<br/>compaction_job.h"]
TAB --> CACHE["cache/<br/>lru_cache.h — block cache"]
DBI --> FILE["file/ + env/<br/>IO + OS abstraction"]
DBI --> MON["monitoring/<br/>statistics, histograms"]
| Dir | What lives there | Anchor |
|---|---|---|
db/ | engine core: DBImpl, column families, versions, compaction | db/db_impl/db_impl.h, db/column_family.h |
table/ | SST file formats | table/block_based/, table/format.h |
memtable/ | memtable representations | memtable/skiplist.h |
cache/ | block/row cache | cache/lru_cache.h |
file/ | IO helpers, prefetch, filenames | file/filename.h |
util/ | blooms, hashing, compression | util/bloom_impl.h |
options/ | the infamous config surface | options/db_options.h |
env/ | OS abstraction | env/env_posix.cc |
monitoring/ | stats/histograms/perf context | monitoring/statistics.h |
utilities/ | transactions, backup, checkpoints | utilities/transactions/ |
The two entry points
DBImpl::Write()—db/db_impl/db_impl.h:256(write path entry)DBImpl::Get()—db/db_impl/db_impl.h:271(read path entry)
Everything you traced in fjall/tidesdb exists here too, ~50x larger: journal ↔
db/log_writer.cc, keyspace ↔ column family, manifest ↔ version_set.
Why orient now
When topic 4 asks “how does leveled compaction pick files?”, you should already know
the answer lives in db/compaction/ and version metadata in db/version_set.h —
navigation cost paid once, here.
References
Code
- rocksdb (shallow clone @
7c80a5aat~/repos/rocksdb) — don’t read it yet; orient with the directory map above. Anchors:db/db_impl/db_impl.h,db/version_set.h,db/compaction/,table/block_based/,memtable/skiplist.h,cache/lru_cache.h
The RUM conjecture: optimize two, pay with the third
After the B-tree and LSM papers give the triangle its concrete corners, this short vision paper names the trade-off every storage structure lives inside: read, update, and memory overhead cannot all approach optimal at once. It doesn’t build anything — it hands you the design compass the rest of the curriculum steers by. Read it after the two engine papers.
The claim
For any access method, define overheads relative to the bare minimum work:
- RO (read): bytes read ÷ bytes strictly needed to answer.
- UO (update): bytes written ÷ bytes logically changed.
- MO (memory/space): bytes stored ÷ bytes of live data.
Conjecture: you can optimize any two; the third has a hard lower bound that grows as the other two approach 1. Not a proven theorem — a design compass (hence “conjecture”; the paper is explicit about this).
Read in this order
- §1–2 — definitions above; make sure you can compute RO/UO/MO for a plain sorted array (RO≈1, UO≈n/2 shifts, MO≈1) and a log (UO≈1, RO≈n, MO grows).
- §3 (the map) — the paper places real structures on the triangle. Reproduce it:
RO = 1 (read-optimal)
▲
B+tree ● │ ● hash index
│
LSM leveled ● │ ● sorted array (static)
│
LSM tiered ● │ ● bitmap/bloom (approximate)
│
log ●────────────────────┴────────────────────● compressed archive
UO = 1 (update-optimal) MO = 1 (space-optimal)
- §4 (moving on the map) — the punchline for this curriculum: knobs are positions, not settings. Bloom bits/key trades MO for RO. Compaction eagerness trades UO for RO. Page fill factor trades MO for UO. Monkey (topic 4) turns this into an actual optimization problem.
- §5 (research directions) — skim; grade its 2016 predictions with 2026 hindsight (adaptive/learned indexes, versioned data — how did they age?).
Questions to answer in notes.md
- Place your engine_shootout results on the triangle: which measured number is RO, UO, MO for fjall and redb?
- Where does FalkorDB’s matrix adjacency sit? (Dense-ish matrix: MO poor for sparse graphs — that’s why delta matrices + roaring exist, topics 20/26.)
- What’s the RUM position of a WAL by itself? Why does every engine carry one anyway? (Durability isn’t in the triangle — it’s an orthogonal axis the paper deliberately excludes.)
The one-line takeaway
There is no best index, only a workload-shaped position on a three-way frontier — “which engine is better” is an ill-posed question until the workload is named.
References
Papers
- Athanassoulis, Kester, Maas, Stoica, Idreos, Ailamaki, Callaghan — “Designing Access Methods: The RUM Conjecture” (EDBT 2016) — PDF — ~6 pages, 1 h; read after the B-tree and LSM papers so the triangle has concrete corners
tidesdb: the same LSM with nothing abstracted away
The value of this skim (1–2 h) is seeing the machinery you just traced in fjall rendered in plain C, with nothing hidden — memory ordering, pointer arithmetic, and disk offsets are all in your face. Read it as a contrast exercise: match each fjall concept to its C twin and notice exactly what Rust’s abstractions buy you, and what they conceal.
Layout
| File | Role |
|---|---|
tidesdb.c (~38K lines) | the whole engine: write/read/compaction orchestration |
skip_list.c | memtable — lock-free skip list, arena bump allocator |
block_manager.c | physical block IO (WAL + SSTs) |
bloom_filter.c | ~600 lines, readable bloom filter |
manifest.c | level metadata: which SST is in which level |
Write path (file:line)
tidesdb_txn_put tidesdb.c:26535 stage in per-txn ops array
tidesdb_txn_commit tidesdb.c:29780 serialize WAL batch → block_manager_write_raw
apply_ops_to_memtable tidesdb.c:29837 skip-list inserts (atomic refcounts)
rotate check (CAS loop) tidesdb.c:29850 memtable over threshold → rotate
tidesdb_flush_memtable tidesdb.c:24887 worker serializes skip list → compressed SST
Read path (file:line)
txn write-set check tidesdb.c:26672 your own uncommitted writes first
active memtable tidesdb.c:26808 skip_list_get_with_seq_ref
immutable memtables tidesdb.c:26845 newest-first, refcount-protected
tidesdb_sstable_get tidesdb.c:9756 per level: bloom (9810) → block index
binary search (9832) → scan blocks
Exactly the README §1 LSM read diagram, one function per box. Which is to say, in code:
#![allow(unused)]
fn main() {
fn get(&self, key: &[u8]) -> Option<Val> {
if let Some(v) = self.txn_write_set.get(key) { return Some(v); } // own writes first
if let Some(v) = self.active_memtable.get(key) { return Some(v); }
for mt in self.immutable_memtables.newest_first() { // refcount-pinned
if let Some(v) = mt.get(key) { return Some(v); }
}
for level in &self.levels {
for sst in level.newest_first() {
if !sst.bloom.might_contain(key) { continue; } // skips MOST absent-key IO
let off = sst.block_index.binary_search(key)?; // a raw file offset —
if let Some(v) = sst.read_block_at(off).find(key) { // the disk format IS
return Some(v); // the data structure
}
}
}
None // read amp made concrete: every stop above was a potential miss
}
}
Compaction
- Enqueue after flush when level over capacity:
tidesdb.c:19910. - Dedup queued work via CAS
is_compactingflag:tidesdb_enqueue_compaction,tidesdb.c:25366— geometry computed at dequeue time, not enqueue. - Worker picks L_i → L_{i+1} by SSTable counts:
tidesdb.c:20143.
What the C makes visible
- Key+value in one malloc (
tidesdb.c:26579):op->value = op->key + key_size— layout as pointer arithmetic. Rust equivalent would be a singleBox<[u8]>with split indices; here the trick is load-bearing and explicit. - Memory ordering spelled out (
tidesdb.c:29761):memory_order_acq_relon the memtable refcount during rotation. Rust’sArchides exactly these barriers — topic 9 makes you write them yourself. - Block index returns raw file offsets (
tidesdb.c:9835): the reader seeks to a byte position from a struct array. No cursor abstraction — the disk format is the data structure.
Done when
You’ve matched each fjall concept (journal, memtable, rotation, bloom, level) to its C twin and noticed the abstractions Rust buys you — and what they hide.
References
Code
- tidesdb —
tidesdb.c(~38K lines, the whole engine),skip_list.c,block_manager.c,bloom_filter.c(~600 readable lines),manifest.c(shallow clone at~/repos/tidesdb; skim-read, 1–2 h)
Turso’s B-tree: the canonical page engine, in Rust
turso re-implements the SQLite file format, so this is a reading of the canonical page-oriented engine — slotted pages, cursor descent, multi-sibling balance, pager + WAL — with Rust types instead of C macros. It is the B-tree protagonist opposite fjall’s LSM. These files are huge and move fast — expect line-number drift, navigate by symbol name. Two files carry the topic:
| File | Size | Role |
|---|---|---|
core/storage/btree.rs | ~13K lines | B-tree cursor, slotted pages, balance |
core/storage/pager.rs | ~6.6K lines | page cache, dirty tracking, IO |
core/storage/wal.rs | ~10K lines | WAL frames + checkpoint |
core/storage/page_cache.rs | — | SIEVE-eviction page cache |
1. The slotted page — read this first
core/storage/btree.rs:76–124 has an ASCII diagram of the page layout in the source
itself. The shape to internalize:
┌────────────┬──────────────────────┬────────────┬─────────────────┐
│ header │ cell pointer array │ free space │ cell content │
│ 8/12 bytes │ u16 offsets, →grows │ │ ←grows, actual │
│ │ rightward │ │ records │
└────────────┴──────────────────────┴────────────┴─────────────────┘
two regions grow toward each other; a "full" page = they meet
- Cell parsing:
read_btree_cell()—core/storage/sqlite3_ondisk.rs:816. - Delete fragmentation + fix:
defragment_page()—btree.rs:8422; pointer-array maintenance viacopy_withininshift_pointers_left()—btree.rs:9067.
This layout is why B-trees have space amplification: the free gap in the middle of every page is the price of in-place insertion.
The insert, mechanically — two regions growing toward each other until they meet:
#![allow(unused)]
fn main() {
fn insert_cell(page: &mut Page, idx: usize, cell: &[u8]) -> Result<(), Full> {
let ptrs_end = page.header_len() + 2 * (page.ncells + 1); // ptr array grows →
let content_start = page.content_start - cell.len(); // content grows ←
if content_start < ptrs_end {
return Err(Full); // regions met: time to balance/split
}
page.buf[content_start..content_start + cell.len()].copy_from_slice(cell);
page.shift_pointers_right(idx); // open slot idx — keys stay sorted
page.write_u16(page.ptr_slot(idx), content_start as u16);
page.ncells += 1;
page.content_start = content_start;
Ok(())
}
// delete = remove the u16 pointer, LEAVE the bytes → fragmentation,
// reclaimed only by defragment_page() — cheap deletes, deferred cleanup
}
2. The cursor — how every operation moves
Main types: BTreeCursor (btree.rs:714), CursorContext (btree.rs:539),
PinGuard (btree.rs:375 — pins a page in the cache while the cursor points at it).
- Point lookup:
seek()—btree.rs:5681. Trace one descent: root → interior cells binary-searched → child page id → pager fetch → leaf. - Insert:
insert()—btree.rs:5779→insert_into_page()—btree.rs:2568.
flowchart LR
S["seek(key)<br/>btree.rs:5681"] --> D["descend: binary search cells,<br/>follow child ptr"]
D --> PG["pager.read_page<br/>pager.rs:3240"]
PG --> L["leaf: insert_into_page<br/>btree.rs:2568"]
L -- page overflows --> B["balance<br/>btree.rs:2793"]
B --> BNR["balance_non_root<br/>btree.rs:2995<br/>redistribute ≤3 siblings"]
3. Balance ≠ naive split
balance_non_root() (btree.rs:2995) rebalances up to three sibling pages at
once, redistributing cells — not the textbook “split one node in two”. This is
SQLite’s fill-factor trick: fewer, fuller pages ⇒ lower space amplification and
shallower trees. Compare with balance_root() (btree.rs:4774) which grows the tree
by one level.
4. Pager + WAL — where “in place” becomes crash-safe
Pagerstruct:pager.rs:1335. Reads:read_page()—pager.rs:3240(cache first),read_page_no_cache()—pager.rs:3185.- Dirty tracking:
add_dirty()—pager.rs:3412. Aha: the page is journaled to the WAL before modification — that’s the write-ahead in write-ahead logging, visible in code. - WAL:
WalFile(wal.rs:2593), frames appended inappend_frames_vectored()(wal.rs:708), andcheckpoint()(wal.rs:3795) copies frames back into the main DB file. So even the in-place family writes out-of-place first, then reconciles — keep this in mind for the README’s “what is authoritative” framing. - Page cache:
page_cache.rs:99— SIEVE eviction, default 2000 pages. Buffer-pool preview (topic 6).
Questions to answer
- How many pages does a point lookup touch on a 1M-row table (page 4KB, ~50 cells interior fanout)? Which of those are realistically cached?
- Why does
balance_non_rootprefer redistribution over splitting? What does it do to write amplification (3 dirty pages vs 2)? - During checkpoint, what blocks writers? (Read
checkpoint()far enough to answer.)
Done when
You can draw the slotted page from memory and explain how one insert can dirty 1 page (common), 3 pages (balance), or O(height) pages (root split).
References
Code
- turso —
core/storage/btree.rs(~13K lines: cursor, slotted pages, balance),core/storage/pager.rs,core/storage/wal.rs,core/storage/page_cache.rs,core/storage/sqlite3_ondisk.rs(shallow clone at~/repos/turso; line numbers drift — navigate by symbol name)
Topic 1 — Notes
Numbers from this machine (Apple Silicon, macOS). Record why, not just what.
Predictions (write BEFORE running the shootout)
Per README §7 — predict the winner and the mechanism, then let the data grade you:
| Workload | Predicted winner | Predicted mechanism | Verdict |
|---|---|---|---|
| fillrandom | |||
| fillseq | |||
| readrandom (zipf) | |||
| readrandom (uniform) | |||
| scan | |||
| space amp |
Shootout results
(engine versions: fjall 2.x, redb 2.6 — pin exact versions from Cargo.lock here;
durability parity: fjall PersistMode::Buffer vs redb Durability::None.)
- First smoke run (
cargo run --release 20000): both engines report ~15x “space amplification” — at 20K × 108B (2.2MB logical) the number is fixed overhead (fjall’s preallocated journal, redb’s initial region sizing), not amplification. Lesson from topic 0: measure at a size where the effect dominates the floor. Re-run at n=1M+ for the real number.
Papers
O’Neil ’96 — LSM-Tree
(questions from reading-lsm-paper.md)
Comer ’79 — The Ubiquitous B-Tree
(questions from reading-comer-btree.md)
RUM Conjecture (EDBT ’16)
(questions from reading-rum-conjecture.md — place shootout results on the triangle)
Architecture of a DBMS (2007)
(questions from reading-architecture-of-a-dbms.md)
Code reading
fjall
turso btree/pager
tidesdb
RocksDB layout
M1 — storage backend abstraction
Design rationale lives in capstone/notes/m1-backend-design.md; comparison with the
reference graph/src/storage/backend.rs goes there too (only AFTER the design).
Topic 2 — In-Memory Structures: Hash Tables, Skip Lists, Tries
Redis’s dict, RocksDB’s memtable, Rust’s HashMap — the workhorses of every in-memory database. This topic is where topic 0’s cache lessons become design rules: every structure here is a different answer to “how do I avoid DRAM misses?”
Outcomes
By the end you can:
- Explain open addressing vs chaining in terms of cache lines, not textbook O(1).
- Describe incremental rehashing (redis) and SIMD group probing (SwissTable) from memory, and say which latency problem each one solves.
- Explain why LSM memtables use skip lists instead of hash tables or B-trees.
- Implement a skip list and an incremental-rehash table, and measure yours honestly against hashbrown / crossbeam-skiplist.
1. Hash tables — the two families and their cache stories
chaining (classic, redis dict): open addressing (SwissTable/hashbrown):
buckets entries (malloc'd) control bytes slots (inline)
┌───┐ ┌──────┐ ┌──────┐ ┌─┬─┬─┬─┬─┬─┬─┬─┐ ┌────┬────┬────┐
│ ●─┼───►│k,v,●─┼──►│k,v,∅ │ │h│h│E│h│D│h│E│h│ │ kv │ kv │ kv │…
├───┤ └──────┘ └──────┘ └─┴─┴─┴─┴─┴─┴─┴─┘ └────┴────┴────┘
│ ∅ │ each hop = pointer chase 1 SIMD load checks 8-16 slots at once
├───┤ = dependent cache miss (16 × 7-bit tags in one NEON/SSE2 cmp)
│ ●─┼───► ...
└───┘
- Chaining (redis
dict): simple, stable pointers, tolerates high load factors — but every collision hop is a dependent DRAM miss (topic 0’s pointer-chase lesson). - Open addressing (SwissTable): entries inline, probe by scanning control bytes — a separate array of 1-byte tags (7-bit hash + empty/deleted markers). One 16-byte SIMD compare filters 16 slots; you only touch the (cache-line-sized) slot data on a tag match. ~87.5% load factor with almost no probe cost.
The latency-spike problem: a plain table doubling at 100M entries stalls one insert for the whole rehash — a giant hiccup in a server’s p99.9. Two industrial fixes:
flowchart LR
subgraph REDIS["redis: incremental rehash"]
A["keep TWO tables<br/>ht[0] old, ht[1] new"] --> B["every add/find moves<br/>1 bucket (dictRehash n=1)"]
B --> C["rehashidx sweeps until<br/>ht[0] empty → swap"]
end
subgraph SWISS["hashbrown: just make rehash fast"]
D["flat memory, no per-entry<br/>malloc → rehash is a<br/>linear cache-friendly sweep"]
end
Redis amortizes the spike across operations (reads during rehash check both tables); hashbrown accepts the spike but makes it a memcpy-speed sweep. You will measure both strategies in the experiment — the spike is visible as a max-latency outlier.
2. Skip lists — the memtable’s structure
A sorted linked list with probabilistic express lanes: node height ~ geometric(p).
L3 ──────────────────────────────► 42 ─────────────────────────► ∅
L2 ─────────► 17 ─────────────────► 42 ─────────► 71 ──────────► ∅
L1 ─► 8 ────► 17 ────► 29 ────────► 42 ─► 55 ───► 71 ─► 88 ────► ∅
search 55: descend when next > target — O(log n) expected
Why memtables (RocksDB, tidesdb) use them instead of:
- hash table — no ordered iteration; flushing to a sorted SST needs sorted data.
- B-tree — needs node splits ⇒ complex latching; a skip list insert touches a handful of independent pointers, so it can be made lock-free with CAS per level.
- the killer feature: memtables are insert-only until frozen, then flushed.
No deletes ⇒ no unlink logic ⇒ the lock-free variant stays simple (RocksDB’s
InlineSkipListsupports concurrent writers with plain CAS loops).
Cache reality check: a skip list is still pointer chasing (topic 0 §2) — each level step is a dependent miss. It wins on concurrency + sortedness, not raw lookup speed; your benchmark will show hashbrown beating it by 5-10x on point lookups. That’s fine — different RUM position.
3. Tries / radix trees — when the key IS the index
radix tree (rax), keys "foo", "foobar", "footer":
[f o o] ← compressed run (iscompr): one node holds the shared prefix
│
(key: "foo")
┌─┴──┐
[b] [t]
│ │
[a r] [e r] compressed tails
- Depth = key length, not log n; no hashing, no comparisons — branch on bytes.
- Redis’s
raxpacks child bytes + unaligned pointers into one flexible array — a node is one cache line for small fanouts. - ART (the paper) adds adaptive node sizes (Node4/16/48/256) so fanout adapts to density — Node16 is probed with SIMD like SwissTable. Used by DuckDB, HyPer for indexes. The graph-adjacent uses: prefix scans, IP routing, inverted-index terms (topic 23).
4. Cache-conscious layout — the recurring trick
Three structures in this topic all use the same move: separate the “filter” data from the payload so probing touches dense, small memory:
| Structure | Dense filter | Payload touched only on match |
|---|---|---|
| SwissTable | 1-byte control tags | inline kv slots |
| ART Node16 | 16-byte key array | child pointers |
| RocksDB skiplist | tower before node | key inline after node |
This is the topic 0 flamegraph lesson generalized: SipHash was 21% of lookup cost; control-byte designs make the other 79% (memory stalls) smaller too.
5. Code reading (4–6 h)
- redis
dict.c— the incremental rehash machine. → chapter:reading-redis-dict.md— redis dict: rehashing 100M keys without stopping the world - redis
t_zset.c— the skiplist behind sorted sets (spans + rank queries). → chapter:reading-redis-skiplist.md— The redis skiplist: spans make rank queries free - hashbrown — SwissTable in Rust: control bytes, NEON group probing.
→ chapter:
reading-hashbrown.md— hashbrown: the probe loop the flamegraph couldn’t show - RocksDB
memtable/inlineskiplist.h— lock-free concurrent skiplist. → chapter:reading-rocksdb-memtable.md— InlineSkipList: lock-free by refusing to delete - redis
rax.c— compressed radix tree (skim). → chapter:reading-redis-rax.md— rax: a radix tree packed into cache lines
6. Papers / talks (3–4 h)
- Leis et al., “The Adaptive Radix Tree: ARTful Indexing for Main-Memory Databases”
(ICDE 2013).
→ chapter:
reading-art-paper.md— ART: sorted like a tree, probed like a hash table - Matt Kulukundis, “Designing a Fast, Efficient, Cache-friendly Hash Table, Step by
Step” (CppCon 2017 — the SwissTable talk).
→ chapter:
reading-swisstable-talk.md— The SwissTable design walk: how benchmarks kill hash tables
7. Experiments (in experiments/)
Implement (this is the topic’s build work — the scaffold compiles with todo!()):
src/skiplist.rs— single-threaded skip list (insert, get, ordered iter).src/incremental_map.rs— two-table incremental-rehash hash map, redis-style (migrate ≤ N buckets per operation).
Then bench (benches/structures.rs, harness provided):
- point lookups + inserts vs
hashbrown::HashMap,std::BTreeMap,crossbeam_skiplist::SkipMap, sizes 1e3 → 1e7 (Zipfian probes, seed fixed). rehash_spike— the headline experiment: insert 10M keys one by one into (a) hashbrown and (b) your incremental map, recording per-insert latency max/p99.9 (HdrHistogram, not criterion — this is a tail-latency question, topic 0 rules). Expect hashbrown to show doubling spikes; yours should flatten them.- ordered scan — your skiplist vs BTreeMap iteration throughput (the memtable flush path in miniature).
8. Capstone milestone M2 (in ../../capstone/)
-
attribute-storecrate: string pool (interning: str → u32 id, id → str) + attribute store keyed by (entity id, attr id) + node/edge ID datablocks. - Design first, then compare with the reference’s
attribute_store.rs/string_pool.rs— same no-peeking rule as M1; write the comparison in notes. - Hash policy decision recorded: default SipHash vs FxHash/ahash for internal maps — justify with topic 0’s flamegraph finding (21% SipHash) + a bench.
- Wire into the workload generator; criterion smoke bench.
Done when
Both structures pass their tests, the rehash-spike plot exists (max latency,
incremental vs doubling), benchmark results are explained in notes.md in
cache/RUM terms, and M2 is checked off with the reference comparison written.
ART: sorted like a tree, probed like a hash table
The index inside HyPer and DuckDB — a radix tree tuned until it beats hash tables on some workloads while staying sorted. Where rax spends its design budget on memory, ART spends it on lookup speed: node layouts that adapt to fanout, each picking the cheapest search its density allows. It is also where this topic’s SwissTable and radix-tree threads literally meet, in Node16’s SIMD probe.
The problem it solves
Plain radix trees waste memory: a 256-pointer node with 3 children is 2KB of nulls. Binary-comparison trees (B-tree, T-tree) waste time: every level is a key comparison + dependent cache miss. ART’s move: make node size adapt to fanout, so space ≈ compact and depth ≈ radix.
The four node types (§III.A — the core of the paper)
Node4 keys[4] ┌k┬k┬k┬k┐ linear scan, fits in
ptrs[4] └●┴●┴●┴●┘ one cache line
Node16 keys[16] ┌k×16────────┐ SIMD compare — literally the
ptrs[16] └●×16────────┘ SwissTable group probe trick
Node48 index[256]┌256 × 1-byte ─┐ byte-indexed indirection:
ptrs[48] └48 × 8-byte ─┘ index[c] → slot in ptrs
Node256 ptrs[256] ┌●×256────────┐ direct array — no search at all
Nodes grow/shrink between types as children are added/removed. Note the progression of search strategy: linear → SIMD → indexed → direct. Each type picks the cheapest search its density allows.
One match carries the whole idea:
#![allow(unused)]
fn main() {
fn find_child(node: &Node, byte: u8) -> Option<&Node> {
match node {
Node4 { keys, ptrs, n } => // ≤4 children: linear scan,
(0..*n).find(|&i| keys[i] == byte) // one cache line
.map(|i| &ptrs[i]),
Node16 { keys, ptrs, .. } => {
let hits = simd_eq(keys, byte); // the SwissTable group probe
one_bit(hits).map(|i| &ptrs[i]) // (≤1 hit here: keys unique)
}
Node48 { index, ptrs } => // byte-indexed indirection
slot(index[byte as usize]).map(|s| &ptrs[s]),
Node256 { ptrs } => // direct — no search at all
ptrs[byte as usize].as_ref(),
}
}
}
Reading order
- §III.A–B — node types + lazy expansion / path compression. Map both onto
rax: lazy expansion ≈ rax storing the key tail in a compressed node;
path compression ≈
iscompr. ART’s per-node prefix is capped (8 bytes, “pessimistic” overflow re-checks the full key) — rax’s is unbounded. Why does ART cap it? (Fixed-size headers ⇒ no variable-size node layouts.) - §III.C–D — insert/delete with node-type transitions. Skim.
- §III.E + §IV — binary-comparable keys. Don’t skip this. To make ints, floats, strings radix-able you transform them so bytewise order = logical order (flip sign bit, big-endian, etc.). This idea is everywhere: RocksDB comparators, FoundationDB tuples, your capstone’s composite (entity,attr) keys in M2.
- §V — evaluation. Read Fig. 8/9 with topic-0 eyes: where does ART beat the hash table (dense integer keys — short paths, no hash cost) and where does it lose (long random strings — depth ∝ length)?
Space guarantee worth remembering
§III.B proves worst-case 52 bytes per key regardless of key distribution — the adaptive nodes + path compression make the bound possible. Compare: your skiplist’s per-node cost (1.33 pointers avg + key) has no such bound story.
Questions to answer in notes.md
- Node16 search is the SwissTable group probe (compare 16 bytes in one SIMD op). What’s the structural difference between how ART and SwissTable use the result? (ART: index into child pointers; Swiss: candidate slots to verify.)
- Height of ART on 8-byte integer keys is ≤ 8 regardless of n. At what n does log₂(n) exceed that — i.e., where does a B-tree start losing on depth alone?
- For the capstone: would ART beat your M2 hash-based attribute store for (entity id, attr id) → value? Sketch the key encoding and the RUM trade.
Done when
You can name the four node types with their search strategies from memory, and explain binary-comparable key encoding well enough to encode (u64, u16) pairs.
References
Papers
- Leis, Kemper, Neumann — “The Adaptive Radix Tree: ARTful Indexing for Main-Memory Databases” (ICDE 2013) — PDF — ~2 h; §III.A is the core, don’t skip §III.E/§IV (binary-comparable keys), read §V’s figures with topic-0 eyes
hashbrown: the probe loop the flamegraph couldn’t show
This IS std::collections::HashMap — you profiled it in topic 0 (21% SipHash,
rest inlined probe loop), and now you read the probe loop the flamegraph
flattened into “everything else”. One idea carries the whole design: keep a
dense array of 1-byte tags beside the slots, so one SIMD load filters 8–16
candidates before a single key byte is touched.
1. The control-byte array — the whole idea
Every slot has a 1-byte tag in a separate dense array (src/control/tag.rs:9–49):
tag values: EMPTY = 0xff DELETED = 0x80 FULL = 0b0xxxxxxx (h2: top 7 hash bits)
hash (64 bits): ┌──────── h1: index bits ────────┬─ h2: top 7 ─┐
└── which group to probe first ──┴─ tag value ─┘
control array: [23|EMPTY|91|07|DELETED|55|23|EMPTY| ... ]
└────────── one 8/16-byte SIMD load ─────────┘
slot array: [ kv | ___ | kv | kv | ___ | kv | kv | ___ ] touched only on tag hit
Probing = compare h2 against 16 tags in one SIMD op; only matching slots get a real key comparison. False-positive rate per group ≈ 16/128 — cheap. This is the “dense filter + fat payload” pattern (README §4).
The lookup, de-macro’d:
#![allow(unused)]
fn main() {
fn find(table: &RawTable, hash: u64, key: &K) -> Option<usize> {
let h2 = (hash >> 57) as u8; // top 7 bits = the tag
let mut probe = ProbeSeq::new(h1(hash), table.mask); // triangular stride
loop {
let group = Group::load(&table.ctrl[probe.pos]); // ONE dense cache line
for bit in group.match_tag(h2) { // SIMD: 8–16 tags at once
let slot = (probe.pos + bit) & table.mask;
if table.key(slot) == key { return Some(slot); } // 2nd line: the slot
}
if group.match_empty().any_bit_set() {
return None; // EMPTY stops the probe; DELETED does NOT —
} // the key may have been pushed past a tombstone
probe.move_next(table.mask);
}
}
}
2. Where things live
| What | Where |
|---|---|
RawTable | src/raw.rs:557 |
| Tag constants + h2 extraction | src/control/tag.rs:9–49 |
| Group dispatch (SSE2/NEON/generic) | src/control/group/mod.rs:8–46 |
| NEON match (your machine) | src/control/group/neon.rs:78–90 |
| Probe sequence (triangular) | src/raw.rs:76–93 |
| Insert / tombstone reuse | src/raw.rs:1952–1984, 1033–1043 |
| Load factor 7/8 | src/raw.rs:152–156 |
3. Read in this order
tag.rs— EMPTY/DELETED encoding. Why is EMPTY0xffand full tags0b0xxxxxxx? (Somatch_empty_or_deleted= “high bit set” — one SIMD sign test.)group/neon.rs:78–90— the 8-byte NEON group ops (Apple Silicon path). Note x86 SSE2 gets 16-wide groups; ARM gets 8. Measurable? (Experiment idea.)raw.rs:76–93—ProbeSeq: stride grows by one group per step (triangular numbers). The comment links the proof that mod-power-of-two triangular probing visits every group exactly once — no cycling, no missed slots.- Insert path
raw.rs:1952— find first EMPTY or DELETED. Tombstone subtlety (raw.rs:1033–1043): inserting over DELETED doesn’t consumegrowth_left, and a table full of tombstones triggers rehash-in-place instead of growth. - Aha: the trailing mirror —
raw.rs:223: the control array allocatesbuckets + Group::WIDTHbytes; the tail replicates the head so a group load starting near the end never wraps. Branchless boundary handling paid in 16 bytes.
4. Connect to your topic 0 numbers
Your flamegraph showed the probe loop fully inlined and memory-stall-bound at 10M keys. Now you can name what’s stalling: the control-byte load is the one guaranteed miss per probe (dense array, ~1 cache line per group); the slot touch is the second. h2 filtering exists precisely so there’s rarely a third.
Questions to answer in notes.md
- Why 7/8 load factor rather than redis’s 1.0? (Open addressing degrades near full — probe lengths explode; chaining just grows chains linearly.)
- Rust 2018 chose SipHash default for HashMap (DoS resistance) — after this reading plus the 21% flamegraph number, write the one-paragraph policy for the capstone: where FxHash/ahash, where SipHash stays.
- What does DELETED do to a long-lived table with churn? Relate to LSM tombstones — same problem, same fix (rewrite/compact).
Done when
You can draw the control-byte array and narrate one lookup from hash to slot, including both cache lines it touches.
References
Code
- hashbrown (shallow clone at
~/repos/hashbrown) —src/raw.rs(RawTable, ProbeSeq, insert path),src/control/tag.rs,src/control/group/neon.rs(the Apple Silicon path; SSE2 sibling for x86)
redis dict: rehashing 100M keys without stopping the world
A hash table serving 100K ops/s cannot stop the world to rehash 100M entries — the resulting p99.9 spike would be a service outage. This chapter walks redis’s answer, the topic’s first industrial latency fix: keep two tables and migrate one bucket at a time, piggybacked on normal operations. It is also the design you’ll replicate in this topic’s experiment.
1. The two-table struct
dict.h:143–159 — the whole design in one struct:
struct dict {
dictType *type;
void **ht_table[2]; // ht[0] = old, ht[1] = new (during rehash)
unsigned long ht_used[2];
long rehashidx; // -1 = not rehashing; else next bucket to migrate
int16_t pauserehash;
signed char ht_size_exp[2]; // sizes as exponents: size = 1 << exp
};
flowchart LR
OP["any dictAdd/dictFind<br/>dict.c:635 / dict.c:779"] --> STEP["_dictRehashStepIfNeeded<br/>dict.c:1705"]
STEP --> RH["dictRehash(d, 1)<br/>dict.c:405 — move 1 bucket<br/>ht[0]→ht[1]"]
RH --> DONE{"ht[0] empty?"}
DONE -- yes --> SWAP["free ht[0], ht[1]→ht[0]<br/>rehashidx = -1"]
DONE -- no --> OP
2. dictRehash — dict.c:405
Read the whole function (~50 lines):
empty_visits = n*10(dict.c:406) — the cap on empty buckets visited per step. Question: why is this needed? (A sparse old table would otherwise make one “step” scan unboundedly far — the amortization guarantee would silently break.)- Each migrated bucket’s chain is walked and every entry re-hashed into ht[1] (dict.c:420–431). Note: entries move one bucket at a time, not one entry.
The whole machine, distilled:
#![allow(unused)]
fn main() {
fn rehash_step(d: &mut Dict, mut buckets: usize) {
let mut empty_visits = buckets * 10; // cap the sparse-table scan
while buckets > 0 && d.used[0] > 0 {
while d.ht[0].bucket(d.rehashidx).is_empty() {
d.rehashidx += 1;
empty_visits -= 1;
if empty_visits == 0 { return; } // bounded work per op — the point
}
for entry in d.ht[0].take_bucket(d.rehashidx) {
let idx = entry.hash & d.mask[1]; // re-hash into the NEW table only
d.ht[1].push_bucket(idx, entry);
}
d.rehashidx += 1;
buckets -= 1;
}
if d.used[0] == 0 { d.swap_tables(); d.rehashidx = -1; }
}
// every dictAdd/dictFind calls rehash_step(d, 1) — and during the migration,
// every lookup must check BOTH tables
}
3. Who pays the rehash tax
dictAddRaw— dict.c:635;dictFind— dict.c:779;dictAddOrFind— dict.c:1742. Every read and write does one step. During rehash, lookups must check both tables (new keys go only to ht[1]; the key you want may be in either).- Cost model: rehash O(n) total, amortized O(1) per op, worst per-op ≈ one bucket chain + 10 empty visits. This is the design you’ll replicate in the experiment.
4. Resize policy — dict.c:1638
- Grow at load factor 1.0 (
ht_used >= size) when resizing is enabled; forced grow atdict_force_resize_ratioeven when disabled (dict.c:1655 — resizing gets disabled during fork/BGSAVE to avoid COW page storms — a durability-meets-data-structure interaction worth pausing on).
5. dictScan — the reverse-binary trick (dict.c:1518)
How do you iterate a table that may rehash under you without missing or endlessly
duplicating keys? dictScan increments the cursor in reversed bit order
(dict.c:1579–1615). Read the long comment above it — one of the great comments in
open source. The property: buckets already visited at size 2^n map onto
already-visited buckets at size 2^(n+1). Guarantee: every key present for the whole
scan is returned ≥ once (duplicates possible, misses not).
6. Contrast: valkey’s libvalkey client dict
~/repos/valkey/deps/libvalkey/src/dict.c — a single-table, full-rehash dict
(dict.c:103–150): no rehashidx, no two-table dance. Fine for a client’s small maps;
unacceptable for a server’s keyspace. Same structure, different RUM position —
latency requirements are part of the workload.
Questions to answer in notes.md
- During rehash,
dictAddRawinserts only into ht[1]. Why is inserting into ht[0] a correctness bug, not just a wasted move? - What does
pauserehashexist for? (Hint: safe iterators.) - Redis caps
empty_visitsat 10n. What tail-latency guarantee does that give one operation, in buckets touched?
Done when
You can implement the two-table scheme from memory — you’ll do exactly that in this topic’s experiment.
References
Code
- redis
src/dict.c,src/dict.h— line numbers from the local clone; thedictScancomment (dict.c:1518) is one of the great comments in open source - valkey
deps/libvalkey/src/dict.c— the single-table, full-rehash contrast case
rax: a radix tree packed into cache lines
Redis’s compressed radix tree — behind stream IDs, client tracking keys, and cluster slot→key maps — is what a trie looks like when memory is the corner of the RUM triangle you’re defending: one variable-size node layout, deliberately unaligned pointers, path-compressed runs. Read for the layout (~45 min, skim the insert logic); it’s the memory-first contrast case for the ART paper that follows.
1. The node — rax.h:78–111
typedef struct raxNode {
uint32_t iskey:1; /* this node terminates a key */
uint32_t isnull:1; /* key has no associated value */
uint32_t iscompr:1; /* node is a compressed run */
uint32_t size:29; /* # children (or run length if iscompr) */
unsigned char data[]; /* EVERYTHING else lives here */
} raxNode;
Four bytes of header, then one flexible array holding child bytes, child pointers, and the optional value pointer, all packed:
non-compressed, size=3 ("abc" branches): compressed run "xyz" (iscompr=1):
┌header┐┌── data[] ─────────────────────┐ ┌header┐┌── data[] ────────────┐
│4 bytes││a b c pad│ A* │ B* │ C* │ V*? │ │4 bytes││x y z pad│ Z* │ V*? │
└──────┘└─────────┴────┴────┴────┴─────┘ └──────┘└─────────┴────┴──────┘
▲ char bytes first (dense filter!) whole run = ONE child pointer
then pointers, then value if iskey (points past the run)
- Layout comment at rax.h:83–109 — read it in full; it’s the spec.
- A compressed node stores a multi-byte run (“foo”) with a single child pointer — that’s the path compression that keeps depth ≈ distinct branches, not key length.
2. The unaligned-pointer aha — rax.h:90, 99
The child pointers in data[] are not aligned: chars come first, so a pointer
may start at any byte offset. Redis reads/writes them with memcpy
(raxNodeLastChildPtr, raxNodeFirstChildPtr helpers). Why tolerate that?
- One allocation per node; header + chars + pointers usually fit one cache line for small fanouts.
- Scanning the char bytes to pick a branch touches only the dense prefix of the node — same “dense filter, fat payload” move as SwissTable control bytes (README §4). Alignment padding would spread the node across lines.
Modern ARM/x86 do unaligned loads nearly free; the cache line saved is worth more.
3. Insert = split machinery — rax.c:515–658 (skim)
raxGenericInsert walks with raxLowWalk, which returns splitpos — where the
new key diverges inside a compressed run. The walk itself is the tree’s whole
read path:
#![allow(unused)]
fn main() {
// returns (bytes of key consumed, split position inside a compressed run)
fn low_walk(mut node: &RaxNode, key: &[u8]) -> (usize, usize) {
let mut i = 0;
while i < key.len() {
if node.iscompr() {
let run = node.chars(); // e.g. "oot" — one node
let m = common_prefix(run, &key[i..]);
if m < run.len() { return (i + m, m); } // diverged MID-run: splitpos
i += m;
node = node.child(0); // whole run = ONE pointer
} else {
match node.chars().iter().position(|&c| c == key[i]) { // dense scan:
Some(j) => { node = node.child(j); i += 1; } // chars only,
None => return (i, 0), // ptrs untouched
}
}
}
(i, 0) // consumed the whole key: node.iskey ⇒ hit
}
}
The long comment before the insert code enumerates the cases; the picture:
insert "first" into node ["footer"]: split the run at splitpos=1
[f] ← shared prefix survives as run (or single node)
┌─┴─┐
["ooter"] ["irst"] ← two compressed tails, new branching node
Every case is “cut the run, make a 2-child branching node, re-hang the tails”. Don’t memorize the five cases — just verify the invariant: after any insert, no node has exactly one child unless it’s compressed (otherwise it would be merged into a run).
4. Contrast with ART (next reading)
| rax | ART (Leis 2013) | |
|---|---|---|
| node sizes | one variable-size layout | adaptive Node4/16/48/256 |
| child search | linear scan of char bytes | SIMD (Node16), direct index (Node256) |
| pointers | unaligned, memcpy’d | aligned arrays |
| optimized for | memory (redis: millions of tiny trees) | lookup speed (main-memory index) |
Same structure, opposite RUM corner: rax minimizes M, ART minimizes R.
Questions to answer in notes.md
- Why does rax put the char bytes before the pointers instead of interleaving (char,ptr) pairs? (Branch decision reads only chars — one dense scan.)
- A radix tree has no hash function and no key comparisons — what does it give up vs a hash table? (Point-lookup cost ∝ key length; but you gain prefix scans and ordered iteration — which topic 23’s inverted index will want.)
Done when
You can sketch a compressed vs non-compressed node’s data[] layout from memory
and say why the pointers are unaligned on purpose.
References
Code
- redis
src/rax.h,src/rax.c— the layout comment at rax.h:83–109 is the spec; read it in full before the functions
The redis skiplist: spans make rank queries free
The canonical readable skiplist — the structure behind ZADD/ZRANGE/ZRANK in
t_zset.c — with one addition the textbooks skip: every forward link records
how many level-0 nodes it jumps over, so summing spans during an ordinary
descent yields a node’s rank at no extra cost. Read it before the RocksDB
memtable chapter to see what a skiplist looks like when concurrency isn’t
allowed to take features away.
1. The structs — server.h:1699–1716
typedef struct zskiplistNode {
sds ele; double score;
struct zskiplistNode *backward; // level-0 doubly-linked
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span; // # of L0 nodes this link jumps over
} level[]; // flexible array: height varies per node
} zskiplistNode;
Two things beyond the textbook skiplist:
span— each forward link records how many level-0 nodes it skips. Summing spans during a descent = the node’s rank, for free. That’s ZRANK/ZRANGE-by-index in O(log n) without any extra structure.backward— level-0 only, making reverse range queries (ZREVRANGE) a plain list walk from the tail.
The span trick in action — an ordinary descent that counts as it goes:
#![allow(unused)]
fn main() {
fn rank_of(list: &SkipList, target: &Key) -> u64 {
let mut node = &list.head;
let mut rank = 0u64;
for lvl in (0..list.level).rev() { // express lanes: top → bottom
while let Some(next) = node.forward(lvl) {
if next.key < *target {
rank += node.span(lvl); // spans sum to the rank — free
node = next;
} else {
break; // too far: drop one level
}
}
}
rank // ZRANK in O(log n), no auxiliary structure, no re-walk
}
}
2. Height selection — t_zset.c:254
zslRandomLevel(): geometric with p = 0.25 (ZSKIPLIST_P, server.h:630), max
level 32. Compare: RocksDB uses branching factor 4 (same p) but caps at 12. Question:
expected pointers per node at p=0.25? (1/(1−p) = 1.33 — vs 2 for a binary tree.)
3. zslInsert — t_zset.c:265–339
The heart. The descent records, per level:
update[i]— the rightmost node at level i that precedes the insert point (the nodes whose forward pointers must be spliced);rank[i]— cumulative span up toupdate[i](so new spans can be computed without re-walking).
insert 55, height 2: update[] captured on the way down
L2 ──────► 17 ────────────────► 71 update[2]=17 rank[2]=2
L1 ──────► 17 ────► 42 ─[55]──► 71 update[1]=42 rank[1]=3 splice
L0 ─► 8 ─► 17 ─► 29 ─► 42 ─[55]► 71 update[0]=42 rank[0]=3 splice
levels above height: span += 1 only
Note the span bookkeeping at t_zset.c:304–305: levels above the new node’s height don’t get a new link, but their spans still grow by one — subtle, and the kind of invariant your own implementation will get wrong first try.
4. What redis does NOT do
No locks, no CAS — redis is single-threaded on the data path, so this skiplist is
free to use backward pointers and spans (both hard to maintain lock-free). Contrast
with RocksDB’s InlineSkipList (concurrent writers ⇒ no backward pointers, no spans,
no deletes). Concurrency removes features — a theme topic 9 makes precise.
Questions to answer in notes.md
- Why does the zset need both the skiplist and a dict (score lookup by member)? What does that cost in memory, and what’s the RUM read?
- Derive the expected search cost at p=0.25: levels × nodes-per-level ≈ log₄(n) × ~3 compares. At n=1M: ~30 dependent pointer hops — now price it with topic 0’s ladder (30 × ~100ns if cold). Compare your measured number.
Done when
You can explain spans to someone in two sentences, and you know which features your experiment’s skiplist can steal (backward/span) vs what RocksDB’s concurrency forbids.
References
Code
- redis
src/t_zset.c(zslInsert, zslRandomLevel) — struct definitions insrc/server.h:1699–1716
InlineSkipList: lock-free by refusing to delete
This is where LSM write throughput lives: every Put in half the industry
lands in this one header. Two ideas are the whole file — a node layout that
puts the hot pointer and the key on the same cache line by indexing the tower
negatively, and a concurrency contract kept simple by one workload
restriction: memtables never delete, they freeze and drop wholesale. Budget:
1–2 h.
1. The node layout trick — read this first
Lines 358–421. A node is one allocation, three regions, and the struct points at the middle:
raw allocation (from concurrent arena, line 860-869):
┌──────────────────────────┬───────────────┬─────────────────┐
│ tower: next_[h-1]…next_[1]│ Node: next_[0]│ key bytes inline│
└──────────────────────────┴───────────────┴─────────────────┘
▲ Node* points HERE
levels accessed by NEGATIVE indexing: (&next_[0] - n) line 383
key accessed as (&next_[1]): line 374
Why: the common case touches next_[0] and the key — adjacent, same cache
line(s). Taller levels (rare) sit before the node. No separate key allocation, no
pointer to the key. This is README §4’s dense-filter/inline-payload pattern again,
by an author who priced the cache lines.
2. The concurrency contract
Next()/SetNext()— acquire/release (lines 383, 390);CASNext— line 395.InsertConcurrently— line 913; the CAS loop lines 1135–1171: compute splice (prev/next per level), CAS level 0 first? No — read carefully: which level is linked first, and why does a partially linked node never break readers? (A node visible at level i but not i+1 is just… slower to find. Correctness needs only level 0.)- No deletes, no unlink (comment lines 31–33): memtables are frozen then dropped wholesale. This one workload restriction is what keeps the lock-free code ~200 lines instead of a research paper (no hazard pointers to unlink, nothing is ever freed while readers run). Constraint-driven simplicity — the design lesson of the whole file.
The concurrent insert, reduced to its CAS skeleton:
#![allow(unused)]
fn main() {
fn insert_concurrently(list: &SkipList, node: &Node, height: usize) {
let mut splice = list.find_splice(node.key()); // prev/next per level
for lvl in 0..height { // bottom-up: correctness
loop { // needs only level 0
node.set_next(lvl, splice.next[lvl]); // prepare BEFORE publish
if splice.prev[lvl]
.cas_next(lvl, splice.next[lvl], node) // release: key bytes are
{ // visible before the link
break;
}
splice.recompute(lvl, node.key()); // lost the race — re-find
} // neighbors, retry
}
}
// a node linked at level 0 but not yet above is merely slower to find —
// never incorrect. That asymmetry is what makes the lock-free version small.
}
3. Supporting cast
RandomHeight— lines 559–573, branching factor 4, max 12 levels.- Arena:
memory/concurrent_arena.h:57–68— per-core shards so concurrent inserts don’t contend on the allocator either. - The plug into the engine:
memtable/skiplistrep.cc:17–397implementsMemTableRep; siblings inmemtable/:hash_skiplist_rep(hash → per-bucket skiplists, for point-heavy),hash_linklist_rep,vectorrep(bulk-load: append then sort-on-flush). The memtable is pluggable because RUM positions differ per workload — RocksDB ships four answers.
Questions to answer in notes.md
- Redis’s skiplist has spans + backward pointers; this one has neither. For each, say exactly what breaks under concurrent CAS inserts.
- Why acquire/release on the links rather than SeqCst? What reorder is actually being prevented at line 383? (Reader must see the node’s key bytes written before the pointer that publishes it — classic publish pattern, topic 9.)
- Estimate: at branching 4 and 1M entries, how many dependent misses per lookup, and why does your hashbrown number from topic 0 beat it? Where does the skiplist still win? (Sorted iteration for flush; concurrent writers.)
Done when
You can explain the negative-index tower AND why insert-only makes lock-free easy — these two ideas are the file.
References
Code
- rocksdb
memtable/inlineskiplist.h— the header comment (lines 31–33) states the no-delete contract; alsomemory/concurrent_arena.h:57–68(sharded arena) andmemtable/skiplistrep.cc(theMemTableRepplug-in point and its three siblings)
The SwissTable design walk: how benchmarks kill hash tables
How Google replaced std::unordered_map fleet-wide — told as a sequence of
designs, each rejected by a measurement. This chapter is a watching guide for
Kulukundis’s CppCon talk: watch it after reading
reading-hashbrown.md, because the talk is the design
narrative for the code you just read. Budget ~60 min video + 30 min notes.
Why watch a talk about a table you already read
The hashbrown source shows the final design. The talk shows the sequence of rejected designs and the benchmark that killed each one — it’s a masterclass in the topic-0 method (hypothesize → measure → iterate).
The design walk (watch for these beats)
std::unordered_map chaining, per-node malloc, iterator stability
│ "every lookup = 2+ dependent misses"
▼
dense_hash_map open addressing, quadratic probe, but 2 sentinel
│ keys stolen from the user + 50% max load
▼
"store metadata per slot" 1 byte: empty/deleted/full + 7 hash bits
│ "but scanning bytes one at a time is slow"
▼
SwissTable group the bytes, compare 16 at once with SSE2
→ 87.5% load factor, ~1 miss per lookup
Timestamps are approximate across uploads — navigate by slide titles instead:
- “The C++ standard basically mandates chaining” — why
unordered_mapcan’t be fixed in place (pointer stability + bucket API promises). - The metadata byte slide — the h2/control-byte idea introduced.
- The SSE2
_mm_movemask_epi8slide — the group probe; this is hashbrown’sGroup::match_tag, NEON on your machine. - Load factor + tombstone discussion — where the 7/8 and rehash-in-place decisions come from (hashbrown raw.rs:152, 1033).
Connect to what you’ve read
| Talk moment | You saw it in |
|---|---|
| metadata byte = 1 bit state + 7 bits hash | tag.rs:9–49 |
| group probe, movemask | group/neon.rs:78–90 (ARM twist: 8-wide) |
| “deleted vs empty” probe-stop rule | raw.rs tombstone logic :1033–1043 |
| iterators break on rehash — API cost | Rust never promised stability, so hashbrown got this for free |
Questions to answer in notes.md
- Google couldn’t ship this as
std::unordered_mapbecause the standard’s API promises (pointer stability, bucket interface) mandate chaining. Which redisdictfeatures would SwissTable similarly break? (Incremental rehash needs stable entries? Check — redis moves entries between tables anyway; the real conflict isdictScan’s bucket cursor.) - The talk reports big fleet-wide RAM savings from the load-factor jump (50% → 87.5%) plus removing per-node mallocs. Estimate the bytes-per-entry difference for a u64→u64 map: chaining with malloc’d nodes vs SwissTable at 7/8 load. Show the arithmetic in notes.
- Kulukundis says hash quality matters more for open addressing than chaining — why? (Clustering compounds; a bad h2 also raises false positives.)
Done when
You can retell the rejected-design sequence (chaining → dense_hash_map → metadata bytes → SIMD groups) and give the one-line benchmark reason each step was taken.
References
Papers
- Kulukundis — “Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step” (CppCon 2017 talk) — video — ~60 min; timestamps vary across uploads, navigate by the slide titles listed above
Code
- hashbrown — the Rust incarnation of the final design; walked in reading-hashbrown.md
Topic 2 — notes
Predictions (fill BEFORE running benches)
| Bench | hashbrown | BTreeMap | crossbeam SkipMap | my skiplist | my inc. map |
|---|---|---|---|---|---|
| point lookup, 1e7, Zipf (ns/op) | |||||
| insert 1e6 (M ops/s) | |||||
| ordered scan 1e6 (M elems/s) | |||||
| rehash_spike max: hashbrown vs incremental | — | — | — |
Reading answers
redis dict (reading-redis-dict.md)
- Insert into ht[0] during rehash — why a bug:
- pauserehash exists for:
- empty_visits=10n tail guarantee:
redis skiplist (reading-redis-skiplist.md)
- Why skiplist + dict both:
- Expected search cost at p=0.25, priced vs measured:
hashbrown (reading-hashbrown.md)
- 7/8 vs 1.0 load factor:
- Hash policy paragraph (for M2 decision):
- DELETED churn ↔ LSM tombstones:
RocksDB memtable (reading-rocksdb-memtable.md)
- spans/backward under concurrent CAS:
- acquire/release vs SeqCst at line 383:
- Miss estimate vs hashbrown number:
rax / ART / SwissTable talk
- (questions in each guide)
Experiment findings
- rehash_spike table + per-decile max:
- Where my skiplist loses to hashbrown and by how much (RUM terms):
- Implementation trade I chose for skiplist node layout, and why:
M2 log
- attribute-store design written BEFORE peeking at reference
- comparison vs reference attribute_store.rs / string_pool.rs:
- hash policy decision + bench evidence:
Topic 3 — B-Tree Internals & Paged Storage
Pages are how disks think. SQLite’s btree.c, turso’s Rust re-implementation, and LMDB’s copy-on-write variant are three answers to the same question: how do you keep a sorted map in fixed-size blocks that survive power loss?
Outcomes
By the end you can:
- Draw a slotted page from memory: header, cell pointer array, cell content area, freeblock chain — and explain why the two regions grow toward each other.
- Narrate a node split (SQLite’s 3-sibling balance) and say why it’s ≤3.
- Explain LMDB’s no-WAL commit (COW + double meta page) and its costs.
- Build a disk B+tree with 4KB slotted pages and bench it honestly vs redb.
1. The slotted page — one picture to rule the topic
4096-byte page (SQLite/turso format):
┌──────────────┬─────────────────┬─────────▼── grows down ──┬──────────────┐
│ header 8/12B │ cell ptr array │ free space │ cell content │
│ type,frag, │ [u16,u16,u16..] │ │ (cells added │
│ freeblock, │ sorted by KEY │ freeblocks chained │ right→left, │
│ ncell,cstart │ grows up ▲ │ through here too │ any order) │
└──────────────┴─────────────────┴──────────────────────────┴──────────────┘
binary search touches ONLY the ptr array (dense) → then one jump to the cell
delete = remove ptr + add freeblock; insert = find slot (allocateSpace) or defrag
The indirection is the whole trick: cells never move on insert/delete of others (pointers do), so binary search stays cheap and deletion is O(1) + freeblock bookkeeping. This is the dense-filter/fat-payload pattern (topic 2 §4) on disk: the pointer array is the filter.
Header fields (turso btree.rs:76–124, spec in SQLite btreeInt.h:1–215):
byte 0 page type; 1–2 first freeblock; 3–4 cell count; 5–6 content-area start;
7 fragmented bytes; 8–11 rightmost child pointer (interior only).
2. The four cell types (SQLite family)
| Cell | Format |
|---|---|
| table interior | left_child u32 ∥ rowid varint |
| table leaf | payload_size varint ∥ rowid varint ∥ payload |
| index interior | left_child u32 ∥ payload_size varint ∥ payload |
| index leaf | payload_size varint ∥ payload |
Payload > maxLocal spills to an overflow chain: keep
minLocal + (n − minLocal) % (usable − 4) bytes local, rest in a linked list of
overflow pages (last 4 local bytes = first overflow page number). The formulas
(maxLocal = (usable−12)·64/255 − 23, etc.) look arbitrary — they guarantee
each page holds ≥4 cells so the tree keeps fanout even with fat keys.
3. Splits and balancing — where the complexity lives
flowchart TD
INS["insert overflows page"] --> Q{"rightmost leaf,<br/>append pattern?"}
Q -- yes --> BQ["balance_quick:<br/>new right sibling,<br/>1 divider up<br/>(btree.c:8039)"]
Q -- no --> BNR["balance_nonroot:<br/>pool cells of page +<br/>≤2 siblings + dividers,<br/>redistribute evenly<br/>(btree.c:8277)"]
BNR --> DEEP{"root itself full?"}
DEEP -- yes --> BD["balance_deeper: new root,<br/>tree grows UP (btree.c:9081)"]
NB = 3(btree.c:7552): balance pools at most 3 sibling pages. SQLite’s comment: the right-bias tweak alone made the whole database “about 25% faster” — splits are hot.- Deletion from an interior node: swap with the predecessor from the leaf level, then rebalance the leaf (btree.c:9873) — interior deletes reduce to leaf deletes.
- Tree grows up (new root), never down — parent pointers stay implicit in the cursor stack.
4. LMDB — the copy-on-write counterpoint
commit N (writes pages 7',3',root'): the two meta pages:
meta0(txn N-2) meta1(txn N-1) ┌───────────────────────────┐
│ │ ◄─ readers │ commit = write dirty pages │
▼ ▼ │ + fsync │
[root] [root'] │ + write meta[N%2] │
/ \ / \ │ + fsync │
[3] [7] [3'] [7'] │ crash anywhere ⇒ old meta │
▲ old pages still valid │ still valid. NO WAL. │
(readers may hold them) └───────────────────────────┘
- Never overwrite:
mdb_page_touch(mdb.c:3015) copies any clean page before the first write in a txn; the whole root-to-leaf path gets new page numbers. - Commit = flush dirty pages, fsync, write the other meta page, fsync
(mdb_env_write_meta, mdb.c:4847, slot
txnid & 1). Torn writes can’t corrupt: the previous meta still points at a complete old tree. - Old pages are recycled through a freelist DB once the oldest reader (mdb_find_oldest, mdb.c:2640) has moved past them — MVCC GC as a data problem.
- Cost: write amp (whole path copied per commit), single writer, and the reader table pins pages (a stuck reader = unbounded growth). Same trade the reference capstone’s cow_btree makes in memory — compare deliberately in M3 notes.
5. Code reading (5–7 h)
- turso
core/storage/btree.rs— deep dive: slotted page ops, balance state machines, overflow, freelist. → chapter:reading-turso-btree-deep.md— Inside the slotted page: freeblocks, overflow, balance - SQLite
src/btree.c— the classic (11.6K lines; guided skim). → chapter:reading-sqlite-btree.md— btree.c: twenty years of production scars - LMDB
libraries/liblmdb/mdb.c— COW, double meta, no WAL. → chapter:reading-lmdb.md— LMDB: recovery is choosing a root pointer
6. Papers / docs (3–4 h)
- Graefe, “Modern B-Tree Techniques” (Foundations & Trends 2011) — the survey;
read selectively.
→ chapter:
reading-graefe-survey.md— Modern B-tree techniques: height is the metric, fanout is the lever - SQLite database file format (official doc) — read alongside the code.
→ chapter:
reading-sqlite-file-format.md— The SQLite file format: decode a row by hand
7. Experiments (in experiments/)
Implement a slotted-page disk B+tree, fixed 4KB pages (scaffold compiles
with todo!(); page-format helpers + tests provided):
src/page.rs— slotted page: header, cell ptr array, insert/delete/defrag.src/btree.rs— B+tree on a page file: search, insert with leaf split, range scan via leaf sibling links.
Then bench (benches/disk_btree.rs):
- point lookups + range scans vs
redb, 1M keys, cold-ish (drop your page cache between runs is impractical — note it; compare warm numbers honestly). - prefix truncation experiment: keys = 32-byte strings sharing 24-byte prefixes. Measure fanout (keys/page) and lookup speed with full keys vs suffix-truncated separators in interior pages. Predict first: fanout ratio ⇒ height change at 1M keys?
8. Capstone milestone M3 (in ../../capstone/)
- Disk-backed B+tree behind the M1 storage trait: properties + range indexes.
- Design the page format FIRST (no peeking), then compare with the reference
cow_btree(in-memory Arc-page COW) — write up: what changes when pages live on disk vs in Arc? (Free-space mgmt, splits, checksums vs refcounts.) - Range-index smoke bench wired into the workload generator.
Done when
Your B+tree passes the crash-free tests, the redb comparison + prefix-truncation
numbers are in notes.md with predictions, and you can draw the slotted page +
LMDB commit diagrams from memory.
Modern B-tree techniques: height is the metric, fanout is the lever
Every “B-trees are simple” take dies in Graefe’s ~200-page survey of what production B-trees actually do — compression, latching, logging interactions, bulk loads. Do not read it all. This chapter picks the ~50 pages that matter for this topic and the capstone (budget: 3 h); you’ll come back for more in topics 5 (logging), 8/9 (latching), and 12 (columnar).
Read now (this topic)
| Section | Pages (approx) | Why |
|---|---|---|
| §2 Basic techniques | skim | you know this from the code |
| §3.1–3.3 Prefix + suffix truncation | read | your experiment; separators need only be shorter than both neighbors, not real keys |
| §3.4 Normalized keys | read | binary-comparable encoding again (ART §III.E) — one memcmp replaces typed comparison |
| §3.5 Poor man’s normalized key | read | first bytes of the key cached IN the pointer array slot — dense filter pattern yet again |
| §4.2 Overflow / variable-length records | skim | you saw SQLite’s version |
| §5.1–5.2 Node sizes | read | why 4KB? (it’s not sacred — CPU cache vs disk trade; big nodes + in-node structure) |
Defer (note where, come back later)
- §6 latching & B-link trees → topic 9 (concurrency)
- §7 logging & recovery interplay (fence keys, ghost records) → topic 5
- §8 bulk load / index creation → topic 12/22
The three ideas to extract
1. suffix truncation: separator between "smith,bob" and "smyth,al"
needs only "smy" — interior keys shrink ⇒ fanout grows
⇒ height shrinks ⇒ every lookup saves a page
2. prefix truncation: page stores common prefix once
page ["foo/aaa".."foo/zzz"]: header prefix="foo/", cells store "aaa"…
3. normalized keys: encode (type,collation,composite) into memcmp-able bytes
— comparison becomes branch-free byte compare (SIMD-able,
topic 17)
Suffix truncation is small enough to write down whole — the point is that a separator is synthetic, so it only has to sort between its neighbors:
#![allow(unused)]
fn main() {
// separator between leaf keys "smith,bob" and "smyth,al" → "smy"
fn shortest_separator(left: &[u8], right: &[u8]) -> Vec<u8> {
let mut i = 0;
while i < left.len() && left[i] == right[i] {
i += 1; // skip the shared prefix
}
right[..=i].to_vec() // one byte past divergence: > left, ≤ right
}
// shorter separators ⇒ more fit per interior page ⇒ fanout up ⇒ height down —
// and height is priced in page reads, so EVERY lookup collects the saving
}
Fanout arithmetic to internalize: 4KB page, 16-byte keys + 8-byte child pointers ≈ 170 fanout ⇒ 1B keys in height 4. Truncate separators to 4 bytes ⇒ fanout ~340 ⇒ height 4 still, but at 10B keys. Height is the metric; fanout is the lever; key size is what you control.
Questions to answer in notes.md
- Why does suffix truncation apply to interior separators but prefix truncation mostly to leaf pages? (Separators are synthetic; leaf keys must be exact.)
- SQLite/turso do neither. Given SQLite’s design goals (simplicity, robustness, integer rowids as the common key), argue whether that’s the right call.
- Poor man’s normalized key = SwissTable h2 = skiplist tower = pointer-array-as- filter. Write the general principle in one sentence for the capstone notes.
Done when
You can do the fanout→height arithmetic cold, and you’ve marked which sections you’ll return to in topics 5 and 9.
References
Papers
- Graefe — “Modern B-Tree Techniques” (Foundations and Trends in Databases, 2011) — ~200 pages; do NOT read it all — follow the section table above (§3 truncation + normalized keys and §5 node sizes now; §6–§8 deferred to topics 9, 5, and 12/22)
LMDB: recovery is choosing a root pointer
LMDB is the anti-SQLite: no WAL, no page cache of its own, no free-space-
within-page — just copy-on-write pages over one big mmap, with crash recovery
reduced to picking the newer of two meta pages. This chapter reads its single
12,846-line file as a design, skimming the code (2 h); it is also the
on-disk twin of the capstone reference’s in-memory cow_btree, which is
exactly M3’s comparison exercise.
1. The commit protocol — the whole design in one sequence
- Two meta pages at file offsets 0 and 1; txn N writes meta
N % 2(comment mdb.c:1356,MDB_metastruct :1358). mdb_txn_commit→mdb_page_flush(write dirty pages) → fsync →mdb_env_write_meta(mdb.c:4847, slottxnid & 1at :4863) → fsync.
crash timeline: recovery = nothing:
write pages ─ fsync ─ write meta ─ fsync open env, read both metas,
▲crash: old meta wins ▲crash: old pick larger valid txnid
(new pages unreachable) meta wins (mdb_env_pick_meta)
No WAL, no redo, no undo. Recovery is choosing a root pointer. The price is paid elsewhere: every commit rewrites the whole root-to-leaf path.
The protocol fits on a napkin:
#![allow(unused)]
fn main() {
fn commit(env: &mut Env, txn: Txn) -> Result<()> {
write_pages(&txn.dirty)?; // COW pages at NEW page numbers —
fsync(env.fd)?; // durable before any root sees them
let meta = Meta { txnid: txn.id, root: txn.new_root };
write_meta_slot(env, (txn.id % 2) as usize, &meta)?; // toggle: never
fsync(env.fd) // overwrite live meta
}
fn open(env: &Env) -> Root {
let (m0, m1) = read_both_metas(env);
pick_valid_with_larger_txnid(m0, m1).root // recovery IS this line —
} // a crash anywhere above just
// means the old meta still wins
}
2. COW mechanics
mdb_page_touch— mdb.c:3015: first write to a clean page in a txn copies it to a fresh page number; the parent’s child pointer is updated (parent was touched first — the descent touches top-down).- Dirty pages tracked in
mt_u.dirty_list(sorted ID list; insert at mdb_page_dirty :2670) — flushed sequentially at commit. - Compare with the capstone reference’s in-memory
cow_btree: same path-copy, but Arc refcounts replace the freelist, and “commit” is an atomic root swap instead of a meta-page write. Write this comparison in notes — it’s M3’s core.
3. Page reuse — GC as a database
- Freed page IDs go into a freelist database (
FREE_DBI, mdb.c:1345) keyed by the txn that freed them. mdb_page_alloc(mdb.c:2693) reuses freed pages only if freed by a txn older than the oldest active reader (mdb_find_oldest:2640 scans the reader tablemti_readers, MDB_reader struct :869 — one slot per reader in a shared lock file, holding a frozenmr_txnid).- Consequence: a stalled reader pins EVERY page version since its snapshot — the file grows without bound. (The infamous LMDB “long-lived reader” footgun; the reference cow_btree has the same issue as Arc-pinned snapshots.)
4. Readers never block writers
- Read txn:
mdb_txn_renew0(mdb.c:3285) picks the newest meta (mdb_env_pick_meta:3296), records its txnid in a reader slot — that’s the entire read-txn setup. No locks on the data pages, ever. - Single writer at a time (writer mutex) — LMDB doesn’t pretend otherwise.
5. The mmap
mdb_env_map— mdb.c:5040: one bigPROT_READmmap; writes go throughpwrite(default) or a writable map withMDB_WRITEMAP(:5097).- Reads = pointer dereference into the map — zero-copy, no buffer pool, the OS page cache IS the cache. Topic 6’s mmap-considered-harmful paper will argue why this is dangerous for writes (no control over write-back order) — note that LMDB’s default mode avoids exactly that by using pwrite + the meta protocol, not the writable map.
6. Search/split (skim)
mdb_page_search:7535 →mdb_node_search:6689 (binary search per page).mdb_page_split:10662 — median promotion, cascading up. Simpler than SQLite’s 3-sibling balance: COW means the path is being rewritten anyway, so there’s no “redistribute in place to avoid dirtying neighbors” incentive.
Questions to answer in notes.md
- Why does LMDB’s split not bother with SQLite-style sibling redistribution? (COW already dirties the path; also no freeblocks — append-style page builds.)
- Double meta + fsync ordering: which of the two fsyncs could you drop, under what hardware assumption, and what breaks on consumer SSDs?
- Price a 1-key commit at tree height 4, 4KB pages: bytes written for LMDB vs a WAL engine (≈ record + fsync). When does LMDB’s model win anyway? (Read-heavy, batch-committed writes.)
Done when
You can narrate a crash at any point in the commit sequence and say which root survives, and you can state the reader-pins-pages problem and its capstone twin.
References
Code
- LMDB
libraries/liblmdb/mdb.c(12,846 lines, one file; local clone at~/repos/lmdb) — read it as a design, skim the code; theMDB_metacomment (:1356) and the reader table (MDB_reader:869) carry the whole model
btree.c: twenty years of production scars
You already know the format from turso — this guided skim (2 h) reads the original for the parts turso simplified and for comments that carry two decades of production experience: the balance_quick fast path, the “25% faster” right-bias tweak, pointer maps, predecessor-swap deletes. Don’t read its 11,633 lines linearly; follow the route below.
1. Start with btreeInt.h:1–215
The file-format spec as a comment: page layout diagram, cell formats, freeblock list, overflow, freelist. This is the best on-disk-format documentation in open source. Read it entire.
Key structs:
MemPage— btreeInt.h:273–303. NotexCellSize/xParseCellfunction pointers picked once per page type at init — devirtualized dispatch, 1994 style. AndnFreeis computed lazily (−1 until needed).CellInfo— btreeInt.h:480–486:nKey,pPayload,nLocal,nSize.
2. The search path
sqlite3BtreeTableMoveto— btree.c:5837–5978. Binary search :5917–5954; child descent :5965–5971 (lwr >= nCell⇒ rightmost pointer). Note the bias hint parameter — appenders skip the binary search.sqlite3BtreeIndexMoveto— btree.c:6068–6295. Uses anxRecordComparecallback specialized per key shape — same devirtualization move.
3. Balance — read for the engineering, not the algorithm
balance()dispatcher — btree.c:9162–9225.balance_quick— btree.c:8039–8150: rightmost-leaf append gets its own path (sequential inserts are THE common case — fillseq from topic 1).balance_nonroot— btree.c:8277–8826.NB = 3at :7552. Find the comment near :8738: the right-bias optimization “makes the database about 25% faster” — a one-line distribution tweak, measured. Topic-0 lesson in the wild.balance_deeper— btree.c:9081: root split = tree grows up.
4. Free space within a page
allocateSpace— btree.c:1846–1944;freeSpace— :1945–2050 (merges adjacent freeblocks!);defragmentPage— :1640–1837.- Overflow-cell trick: an overfull page keeps up to
apOvfl[]cells beside the page (insertCell :7363–7450) rather than reallocating — balance consumes them immediately. The page is never physically overfull on disk.
#![allow(unused)]
fn main() {
// insertCell's trick: a page is never physically overfull
fn insert_cell(page: &mut MemPage, i: usize, cell: Cell) {
match page.allocate_space(cell.len()) { // freeblocks → gap → defrag
Some(off) => page.write_cell(off, i, &cell),
None => {
page.ap_ovfl.push((i, cell)); // parked IN MEMORY, beside the page
// caller must run balance() before the page is released: the
// balance pool drains ap_ovfl while redistributing ≤3 siblings,
// so the on-disk format never needs an "overfull" representation
}
}
}
}
5. Two things turso doesn’t have (yet)
- Pointer maps (auto-vacuum) — btreeInt.h:653–668, btree.c:1098–1170: a reverse index (page → parent) so vacuum can relocate pages. Costs a ptrmap page every ~⌊usable/5⌋ pages.
- Interior-delete via predecessor swap — btree.c:9873–10050 (:9954 leaf check, :9956 predecessor fetch): interior deletes become leaf deletes + rebalance.
Questions to answer in notes.md
fillInCell(btree.c:7106) builds the overflow chain BEFORE the cell is inserted into the page. What crash-safety property makes that ordering safe? (Pages only become durable at commit via pager/WAL — nothing here is.)- Why does
balance_quickexist whenbalance_nonroothandles the same case? Estimate the work saved for a fillseq insert (pages touched, cells copied). - SQLite computes
nFreelazily and validates cells only underSQLITE_DEBUG. What does that say about where btree.c sits on the trust-the-page-vs-verify spectrum, and what’s the corruption story? (PRAGMA integrity_checkexists for a reason.)
Done when
You can explain why NB=3 (bounded work per split, adjacent redistribution beats cascading splits) and name the two fast paths (bias hint, balance_quick) that serve sequential inserts.
References
Code
- sqlite —
src/btree.c(11,633 lines; don’t read linearly) andsrc/btreeInt.h(746 lines) — btreeInt.h:1–215 is the best on-disk-format documentation in open source; read that comment entire before any function
The SQLite file format: decode a row by hand
The normative spec for what btree.c writes — and the one document in this topic you read with a hex dump open beside it. After two codebases’ worth of slotted pages, this chapter verifies your mental model against the official text and ends with the exercise that makes the format yours: labeling every byte of one cell in a real database file. ~1.5 h.
Read in this order
- §1 The database file — 100-byte file header: page size (offset 16), file change counter, freelist head + count (offsets 32–39), schema cookie.
- §1.6 B-tree pages — the slotted-page spec you now know from two codebases; verify your mental model against the normative text (esp. freeblock rules: min 4 bytes, fragment cap 60).
- §2 Record format — serial types table. Note types 8/9 (literal 0 and 1
— zero bytes of payload!) and the odd/even text/blob length encoding
(n−13)/2/(n−12)/2. - §1.5 Pointer maps, §4.1 WAL vs rollback journal — skim; WAL is topic 5.
Every cell starts with varints, so carry the decoder in your head into the exercise:
#![allow(unused)]
fn main() {
// SQLite varint: 7 bits/byte, BIG-endian (unlike protobuf), max 9 bytes
fn read_varint(buf: &[u8]) -> (u64, usize) {
let mut v = 0u64;
for i in 0..8 {
v = (v << 7) | (buf[i] & 0x7f) as u64;
if buf[i] < 0x80 {
return (v, i + 1); // high bit clear = last byte
}
}
((v << 8) | buf[8] as u64, 9) // 9th byte contributes all 8 bits
}
// rowid 500 = 0x83 0x74 → (0b0000011 << 7) | 0b1110100 — find it in the dump
}
The exercise (30 min, do it)
sqlite3 /tmp/t.db "create table t(a integer primary key, b text);
insert into t values (1,'hello'),(500,'world');"
xxd /tmp/t.db | head -80
Find by hand, writing offsets in notes.md:
- page size at offset 16 (big-endian u16);
- page 2’s header byte
0x0D(table leaf), cell count, content-area start; - the two cell pointers, then decode cell 1: payload-size varint, rowid varint (rowid 500 needs 2 bytes — check the 7-bit encoding), record header, serial type for ‘hello’ (text len 5 ⇒ type 2·5+13 = 23).
If you can decode a row from a hex dump, the format is yours.
Questions to answer in notes.md
- Why does the format store the cell CONTENT area offset in the header instead
of deriving it from the cell pointers? (Cheap free-space check:
content_start − ptr_array_endwithout scanning.) - INTEGER PRIMARY KEY tables store the key only as the rowid varint — the column itself is NULL in the record. What does this alias buy in bytes/row and what does it forbid? (WITHOUT ROWID tables exist for the other case.)
- The change counter (offset 24) and version-valid-for (92) — how do they let a reader detect a stale in-memory schema without locks?
Done when
Your notes contain the annotated hex dump with every byte of one cell labeled.
References
Papers
- SQLite team — “The SQLite Database File Format” (official documentation) — https://www.sqlite.org/fileformat2.html — the normative spec for what btree.c writes; read side-by-side with a real database file and a hex dump
Inside the slotted page: freeblocks, overflow, balance
Topic 1’s turso chapter traced the cursor/seek/insert surface; this one
descends into the page mechanics that surface glossed over — the freeblock
chain, the exact overflow-spill formulas, the resumable balance state
machines, varints, and the whole-page freelist. Budget: 2–3 h across
core/storage/btree.rs, sqlite3_ondisk.rs, and pager.rs.
1. Slotted page operations
- Header parsing:
btree.rs:76–124(offsets in README §1). - Free-slot search:
btree.rs:7592–7680—find_free_slot()walks the freeblock chain (each freeblock: 2B next-ptr + 2B size, threaded through the content area). Minimum slot 4 bytes; smaller leftovers become the header’sfragmented_bytescounter. - Defragment:
btree.rs:8273–8444— fast path when ≤2 freeblocks, slow path compacts everything. Question while reading: what triggers defrag, and why is it correct to move cells but never the pointer array?
The freeblock walk, distilled — first-fit through a linked list threaded through the dead space itself:
#![allow(unused)]
fn main() {
fn find_free_slot(page: &mut Page, need: usize) -> Option<u16> {
let mut prev = FREEBLOCK_HEAD; // header bytes 1–2
let mut off = page.first_freeblock();
while off != 0 {
let (next, size) = page.freeblock_at(off); // 2B next-ptr + 2B size
if size as usize >= need {
let rest = size as usize - need;
if rest < 4 { // leftover can't hold a freeblock:
page.unlink(prev, next); // take it all, book the scraps
page.add_fragmented(rest as u8); // (header cap: 60)
return Some(off);
}
page.set_size(off, rest as u16); // carve the tail, keep the block
return Some(off + rest as u16);
}
prev = off; off = next;
}
None // nothing fits: allocate from the middle gap, or defragment
}
}
2. Overflow — the exact SQLite formulas
- Thresholds:
btree.rs:9019–9042—max_local(index) = (usable−12)·64/255 − 23,max_local(table) = usable − 35,min_local = (usable−12)·32/255 − 23. - Spill rule:
sqlite3_ondisk.rs:2130–2148— keepmin_local + (payload − min_local) % (usable − 4)bytes local. - Chain format:
sqlite3_ondisk.rs:951–961— last 4 local bytes = next overflow page number (0 terminates). - Why 64/255 and 32/255? Work it out: they bound local payload so a page always fits ≥4 cells — fanout survives fat keys.
3. Balance — the state machines
Turso’s twist on SQLite: balancing is a resumable state machine (IOResult)
instead of synchronous recursion, because every page touch may yield for async IO.
balance_root()—btree.rs:4774–4852: root overflow ⇒ copy root into a new child, root becomes interior pointing at it (tree grows up).balance_non_root()—btree.rs:2995–4087: the ≤3-sibling pool-and- redistribute. Sibling pick at :3305–3375 (left preferred, dividers pulled from parent into the pool); redistribution + new-sibling creation at :3430–3680.- Trigger: insert overflows the page (
btree.rs:2903— split path aftersplit_cell()can’t fit).
balance_non_root, 2 siblings + overfull page:
parent: [ ... D1 ... D2 ... ] D = divider cells
│ │ │
[sib L] [OVERFULL] [sib R]
└──────── pool: L + D1 + full + D2 + R ────────┘
redistribute evenly ⇒ 2–4 pages, new dividers up
4. Varints and the record format
read_varint/write_varint—sqlite3_ondisk.rs:1304–1336 / 1379–1421: 7 bits/byte big-endian, 9th byte carries a full 8 bits (max 9 bytes for u64).- Record: header-size varint, then per-column serial type varints, then the
values (
sqlite3_ondisk.rs:1101–1237). Serial types encode type AND length in one number — schema-less pages.
5. Freelist (whole-page recycling)
- Trunk page:
sqlite3_ondisk.rs:89–93— next-trunk u32, leaf-count u32, then leaf page numbers. Leaves are just free page IDs. allocate_page()—pager.rs:5250–5448: pop a leaf; if trunk empty, the trunk page ITSELF becomes the allocated page.add_page_to_freelist()—pager.rs:5101–5145.
6. Cell formats
Table interior child u32 ∥ rowid varint; table leaf size ∥ rowid ∥ payload;
index interior child ∥ size ∥ payload; index leaf size ∥ payload
(structs sqlite3_ondisk.rs:775–812, parsing :826–930). Note: no prefix/suffix
truncation anywhere — turso (like SQLite) stores full keys. That’s your
experiment’s opening.
Questions to answer in notes.md
- Why do table-btree interior cells store only rowids (no payload) while index-btree interior cells carry the full key? What does that do to fanout?
- The freeblock minimum is 4 bytes and
fragmented_bytescaps at 60 in SQLite — what goes wrong without defragmentation? When mustallocateSpacedefrag even though total free space suffices? - Turso’s balance yields mid-operation for IO. What invariant must hold at every yield point so a concurrent reader (or a crash) never sees a broken tree? (Hint: WAL — pages aren’t durable until commit; in-memory the cursor holds refs.)
Done when
You can write the byte layout of a table-leaf page containing two cells and one freeblock, from memory, and explain what balance_non_root pools and why ≤3.
References
Code
- turso —
core/storage/btree.rs(slotted-page ops, balance state machines),core/storage/sqlite3_ondisk.rs(overflow, varints, cell formats),core/storage/pager.rs(freelist) — local clone at~/repos/turso; line numbers drift, navigate by symbol name. Extends topic 1’s reading-turso-btree.md
Topic 3 — notes
Predictions (fill BEFORE running)
| Bench | my btree | redb |
|---|---|---|
| point lookup 1M, warm (ns/op) | ||
| range scan 1K rows (µs) | ||
| long-key (32B, shared prefix) height / lookup | — | |
| after suffix truncation: height / fanout / lookup | — |
Fanout arithmetic check (before measuring): 4KB page, 8B key + 2B ptr + 4B lens ⇒ leaf holds ~N cells; interior fanout ~M ⇒ predicted height at 1e6 keys = ___.
Reading answers
turso deep (reading-turso-btree-deep.md)
- Table vs index interior cell contents / fanout:
- Why defrag is needed despite freeblocks:
- Yield-point invariant in async balance:
SQLite btree.c (reading-sqlite-btree.md)
- fillInCell overflow-first ordering safety:
- balance_quick savings for fillseq:
- Trust-vs-verify position:
LMDB (reading-lmdb.md)
- Why no sibling redistribution on split:
- Which fsync could go, on what hardware:
- 1-key commit cost LMDB vs WAL engine; where LMDB still wins:
Graefe survey
- Suffix (interior) vs prefix (leaf) truncation asymmetry:
- Is SQLite right to skip both?
- The one-sentence dense-filter principle:
File format doc
- Annotated hex dump (paste here):
Experiment findings
- Warm-cache caveat: at 1M keys everything fits in the OS page cache — this benches CPU + page format, not IO. (Buffer pool + cold runs = topic 6.)
- redb comparison, explained in fanout/height terms:
- Truncation result — fanout before/after, height change, lookup delta:
M3 log
- Page format designed before peeking; diffs vs reference cow_btree noted:
- Disk vs Arc-COW writeup (free-space mgmt, splits, checksums vs refcounts):
- Range-index smoke bench in workload generator:
Topic 4 — LSM-Tree Deep Dive
You know the memtable (topic 2) and the B-tree alternative (topic 3). This topic is the rest of the LSM machine: SST anatomy, bloom filters, and compaction — a scheduling problem wearing a storage-engine costume.
Outcomes
By the end you can:
- Draw an SST from blocks to trailer and explain restart points + prefix truncation.
- Derive write amplification for leveled vs tiered compaction and check it by measurement on your own mini-LSM.
- Explain Monkey’s bloom-bit allocation argument in one paragraph.
- Say when a write stalls in RocksDB and why stalls are load-shedding, not bugs.
1. The lifecycle (the map for everything below)
flowchart LR
W["write"] --> MT["memtable<br/>(topic 2 skiplist)"]
MT -- full --> SEAL["sealed memtable"]
SEAL -- flush --> L0["L0: overlapping runs"]
L0 -- "compact (pick by score)" --> L1["L1: disjoint, 10x"]
L1 --> L2["L2: 100x"] --> LN["…Lmax: ~90% of data,<br/>tombstones die here"]
Reads run the same path in reverse: memtable → sealed → L0 (every run!) → one segment per deeper level (disjoint ⇒ binary search by key range). Every skipped disk probe is a bloom filter earning its bits.
2. Inside an SST
┌─────────────┬─────────────┬──────┬─────────────┬────────┬─────────┐
│ data block │ data block │ … │ filter block│ index │ trailer │
│ (~4KB, LZ4) │ │ │ (bloom) │ block │ /meta │
└─────────────┴─────────────┴──────┴─────────────┴────────┴─────────┘
inside a data block (restart interval 16):
[FULL key ∥ v][shared=5,rest ∥ v][shared=7,rest ∥ v]…[FULL key]…[restart offsets]
▲ binary search over restart points, linear decode between them
Prefix truncation inside blocks (vs topic 3’s B-tree pages which stored full keys) works because blocks are immutable — write once, no in-place updates to break the delta chain. Immutability is the LSM superpower: checksums per block, whole-file bloom filters, compression — all trivial when nothing mutates.
3. Compaction — the actual design space
| Strategy | Merge trigger | Write amp | Read amp | Space amp |
|---|---|---|---|---|
| Leveled | level size > target (10x ratio) | high: ~10 per level ⇒ O(10·L) | low: 1 run/level | low (~1.1) |
| Tiered | K runs of similar size | low: ~1 per level | high: K runs/level | high (~K) |
| FIFO | size cap | 1 | n/a | 1 |
| Lazy hybrids (Dostoevsky) | tiered upper, leveled last | between | between | between |
RocksDB’s leveled score: L0: files/trigger; L1+: level_bytes/target_bytes
(compaction_picker_level.cc:229–233) — highest score compacts first. Write
stalls are the back-pressure valve: L0 ≥ 20 files ⇒ slowdown, ≥ 36 ⇒ stop
(column_family.cc:1019–1043). No stall mechanism ⇒ unbounded compaction debt ⇒
reads degrade forever. Stalls are the honest choice.
write amp intuition, leveled, ratio T=10:
a key is rewritten ~once per level it descends through, but each merge into
level i drags ~T bytes of level-i data per byte of level-(i-1) data:
WA ≈ T/2 · levels ≈ 5 · log_T(n/memtable) ← measure this in your mini-LSM
4. Filters: paying DRAM to skip IO
- Classic rule of thumb: 10 bits/key ⇒ ~1% false positives, k≈7 hashes.
- Monkey’s insight: uniform bits/key is suboptimal — a false positive at a big bottom level costs the same IO as one at a tiny upper level, but the bottom level has ~T× more keys per filter bit. Optimal: more bits/key at smaller levels, exponentially decreasing down the tree; same total DRAM, ~2× fewer false positives.
- RocksDB ships bloom (cache-local, FastLocalBloom) and ribbon filters (~30% smaller, slower to build — CPU-for-DRAM trade; filter_policy.cc:658).
5. Code reading (5–7 h)
- lsm-tree crate (the engine under fjall — read it all, it’s small).
→
reading-lsm-tree.md— An LSM you can read whole: the lsm-tree crate - RocksDB
db/compaction/+table/block_based/— the industrial version. →reading-rocksdb-compaction.md— RocksDB compaction: scores, stalls, and the manifest
6. Papers (4–6 h)
- “Monkey: Optimal Navigable Key-Value Store” (SIGMOD ’17).
→
reading-monkey.md— Monkey: bloom bits where they pay - “Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores
via Adaptive Removal of Superfluous Merging” (SIGMOD ’18).
→
reading-dostoevsky.md— Dostoevsky: merge lazily, except at the last level - “RocksDB: Evolution of Development Priorities in a Key-value Store Serving
Large-scale Applications” (TODS ’21).
→
reading-rocksdb-tods.md— RocksDB’s decade: write amp → space amp → CPU - “Constructing and Analyzing the LSM Compaction Design Space” (VLDB ’21).
→
reading-compaction-design-space.md— Compaction is four axes, not two strategies
7. Experiments (in experiments/)
Build a mini-LSM (scaffold compiles; todo!() where the learning is).
Optionally follow skyzh/mini-lsm alongside — but the point here is the measurement:
src/memtable.rs— wrap your topic-2 skiplist (or BTreeMap to start).src/sst.rs— block-based SST writer/reader: 4KB blocks, restart points every 16, per-block xxhash, whole-file bloom (10 bits/key).src/lsm.rs— put/get/scan, flush at 1MB, pluggable compaction:Leveled(ratio 10) andTiered(K=4).- The experiment (
src/bin/write_amp.rs): load 10M keys (uniform random overwrite, 3 passes), count bytes written to disk / bytes of user data — write amp per strategy. Also record: read amp (segments probed per get, bloom hits/misses), space amp (dir size / live data). Fill the RUM table with MEASURED numbers.
8. Capstone milestone M4 (in ../../capstone/)
- LSM-backed persistence alternative: graph snapshots/deltas as SSTs behind the M1 storage trait.
- Bench B+tree (M3) vs LSM backends: graph-mutation stream (edge inserts, property updates) and bulk-load; report write amp + p99 with tail-latency discipline (topic 0 rules).
- Note where FalkorDB’s actual persistence (redis RDB/AOF fork-snapshot) sits relative to both — neither B-tree nor LSM; that comparison seeds topic 5.
Done when
Mini-LSM passes tests; the write-amp table (leveled vs tiered, measured vs
predicted) is in notes.md; you can explain Monkey’s allocation and the stall
triggers without looking.
Compaction is four axes, not two strategies
“Leveled vs tiered” is a false binary: a compaction policy is an independent choice on four design axes — trigger, layout, granularity, movement — and every system you’ve read in this topic sits somewhere in that grid. This is the taxonomy chapter; read it LAST of the four papers, because it organizes the other three.
The four axes (§3 — the contribution)
a compaction policy = choice on each axis:
1. TRIGGER when? level saturation / #runs / staleness / space amp
2. DATA LAYOUT what shape? leveling / tiering / 1-leveling / L-leveling / hybrid
3. GRANULARITY how much at once? whole level / one file (RocksDB) / few files
4. DATA MOVEMENT who moves? full merge / trivial move (relink non-overlapping)
Your mini-LSM: trigger = level size, layout = leveled or tiered, granularity =
whole level, movement = full merge (+ trivial move if you stole lsm-tree’s
Choice::Move). Locate every system you’ve read on these axes — RocksDB
leveled is (saturation, leveling, one-file, merge+trivial-move).
Reading order
- §3 — the taxonomy. Make the table for: your mini-LSM, lsm-tree crate, RocksDB leveled, RocksDB universal, FIFO.
- §4 — the benchmark methodology: they implement the design space inside one engine to compare fairly. This is the Fair Benchmarking paper’s lesson (topic 0) applied — same engine, one variable.
- §5 findings — the ones worth keeping:
- file-granularity compaction (RocksDB style) smooths write stalls vs whole-level (spikes) — granularity is a tail latency knob, not a throughput knob;
- trigger choice dominates point-lookup latency more than layout at low write rates;
- no policy wins everywhere (the RUM conjecture, empirically, again).
- Skim the workload sensitivity plots — note which finding you’ll test.
Questions to answer in notes.md
- Your write_amp experiment compacts whole levels. Predict, then measure if time allows: what does per-insert p99.9 look like vs a per-file granularity variant? (This is topic 2’s rehash-spike lesson at LSM scale.)
- Which axis does Dostoevsky’s lazy leveling move on? (Layout only — trigger/ granularity/movement orthogonal.) Which does Monkey move on? (None — it’s a filter-memory axis the taxonomy doesn’t cover; where would you add it?)
- For M4’s graph-snapshot SSTs: bulk-loading a snapshot is one giant sorted run. Which axis choices make ingest cheap? (Trivial move into the bottom level — no merge at all.)
Done when
Your notes contain the 5-system × 4-axis table and one prediction you could test with the mini-LSM.
References
Papers
- Sarkar, Papon, Staratzis, Athanassoulis — “Constructing and Analyzing the LSM Compaction Design Space” (VLDB 2021) — §3 taxonomy and §5 findings are the keepers; §4’s one-engine methodology is the Fair Benchmarking lesson applied
Dostoevsky: merge lazily, except at the last level
Monkey optimized the filters; Dostoevsky optimizes the merging itself — by noticing that most of leveled compaction’s work is “superfluous” (the paper’s word). Point lookups and space amp are dominated by the largest level, write amp by the upper levels, so the right move is to tier the top and level the bottom. This chapter builds that argument and the Fluid-LSM dial that generalizes it.
The insight in one table
Merging at different levels buys different things:
| Level | What merging there improves | Who cares |
|---|---|---|
| upper (small) levels | almost nothing — they’re small, probes are filtered | nobody |
| largest level | space amp (dead versions live here) + zero-result reads | everybody |
Leveled compaction merges eagerly everywhere — most of that work is “superfluous” (the paper’s word). Tiered merges lazily everywhere — cheap writes but the largest level fragments into K runs, wrecking space amp and zero-result lookups.
Lazy Leveling (the contribution)
tiered: leveled: lazy leveled (Dostoevsky):
L1: ▧▧▧▧ K runs L1: ▧ 1 run L1: ▧▧▧▧ K runs ← tiered on top
L2: ▧▧▧▧ L2: ▧ L2: ▧▧▧▧ (writes cheap)
L3: ▧▧▧▧ L3: ▧ L3: ▧ 1 run ← leveled at bottom
(space + reads OK)
WA: O(L) WA: O(T·L) WA: O(L + T) ← T paid once, at bottom
Point lookups and space amp are dominated by the largest level; write amp is dominated by the upper levels (data passes through them repeatedly). So: tier the top, level the bottom. Fluid LSM generalizes with two knobs (K = runs allowed at upper levels, Z = runs at the largest) and picks them per workload — leveled (K=Z=1) and tiered (K=Z=T−1) become endpoints of a dial.
The whole family is one compaction chooser with two thresholds:
#![allow(unused)]
fn main() {
// K = max runs at upper levels, Z = max runs at the largest level.
// K=Z=1 ⇒ leveled; K=Z=T−1 ⇒ tiered; K=T−1, Z=1 ⇒ lazy leveling.
fn choose(&self, v: &Version) -> Choice {
for lvl in 0..v.last_level() {
if v.runs(lvl) > self.k { // upper levels: tolerate K runs
return Choice::MergeRunsInto(lvl + 1);
}
}
if v.runs(v.last_level()) > self.z { // largest level: tolerate Z
return Choice::MergeLastLevel; // T paid once, here
}
Choice::DoNothing
}
}
Reading order
- §2 — the cost table (Table 1). Reproduce it for yourself for T=10, L=3: write cost, point read (zero/non-zero result), range, space. This table IS the paper.
- §3 — Lazy Leveling analysis. Check the claim: same point-read + space complexity as leveled, write cost close to tiered.
- §4 — Fluid LSM + the tuning section (skim the solver, keep the knobs).
- Evaluation — find the throughput-vs-skew plots.
Questions to answer in notes.md
- Your mini-LSM implements leveled and tiered. Using its measured write amp and read amp: on YOUR numbers, what would lazy leveling have scored? (Compute — upper levels tiered cost + bottom leveled cost.)
- Why do range scans not benefit from lazy leveling the way point reads do? (Every run at every level must be merged into the scan regardless.)
- RocksDB never shipped lazy leveling as such — universal compaction covers part of the space. From reading-rocksdb-compaction.md, which universal knobs approximate K and Z?
Done when
You can reproduce Table 1 from memory for the three strategies (writes, point reads, space) and say in one sentence why “merge lazily except the last level” dominates.
References
Papers
- Dayan & Idreos — “Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores via Adaptive Removal of Superfluous Merging” (SIGMOD 2018) — §2’s cost table (Table 1) IS the paper; §3 for the lazy leveling analysis, §4 for Fluid LSM (skim the solver, keep the knobs)
An LSM you can read whole: the lsm-tree crate
Every LSM concept in this topic — restart-point block encoding, bloom-gated
point reads, versioned level metadata, pluggable compaction — exists as a few
hundred readable lines in fjall’s lsm-tree crate. Topic 1 read fjall’s
keyspace layer; everything LSM-shaped delegates here, and the crate is small
enough to read completely. This chapter orders that read so each layer lands
before the one that uses it.
1. Block encoding — src/table/block/
encoder.rs:61–151— restart intervals + prefix truncation: a FULL item everyrestart_intervalitems; between restarts, items storeshared_prefix_len + rest(longest_shared_prefix_length, :142). Optional hash index per block (:148–154) — a tiny SwissTable-ish shortcut inside each block; topic 2 pattern at yet another scale.header.rs:49–60— per-block header: type, xxh3 checksum (u128), on-disk- uncompressed sizes; the header itself gets a u32 checksum (:109).
mod.rs:60–70, 111–120— LZ4 per block.- Index blocks:
index_block/block_handle.rs:20–43— varint offset+size handles.
2. Segment writer/reader — src/table/
writer/mod.rs:40–95— buffer KVs, flush block at size threshold, feed the filter + index writers, trailer/metadata last (single forward pass — an SST is written append-only, like everything else in an LSM).- Read path with filter:
mod.rs:245–290— filter loaded lazily; ifmaybe_contains_hashsays no, the point read never touches a data block (:281–288).
3. Bloom filter — src/table/filter/standard_bloom/
builder.rs:55–127—with_fp_ratecomputes m,k from−n·ln(fpr)/ln²2(:58);with_bpkdirect (:93).- Double hashing —
builder.rs:10–13+mod.rs:102–129: k probes from two hashes viah1 += h2; h2 *= i— k memory probes but only ONE real hash computation. Compare with RocksDB’s cache-local bloom (all k bits in one cache line — topic 0 priced why).
4. Version + levels — src/version/
mod.rs:42–114+run.rs:51–103— levels are runs; a run is disjoint by key range, soget_for_key(:99–103) binary-searches segment ranges: one segment probed per run. L0 = many runs (each flush is one); L1+ = one run.- Persistence:
persist.rs:9–45— new version file written, checksummed,rewrite_atomicon CURRENT_VERSION_FILE + fsync. This is RocksDB’s MANIFEST in miniature: compaction commits by publishing a new version, never by mutating the old one. COW again, at the metadata level. - Recovery:
recovery.rs:34–95.
5. Compaction — src/compaction/
- Trait:
mod.rs:87–98—choose(version, config, state) → Merge | Move | Drop | DoNothing. NoteMove: a segment that doesn’t overlap the next level is relinked, zero IO — find where leveled uses it (leveled/mod.rs:19pick_minimal_compaction). - Leveled:
leveled/mod.rs:113–143— L0 trigger 4 runs, ratio 10. - Worker:
worker.rs:382–389— tombstones evicted only when the output is the last level (evict_tombstones(is_last_level)). Dropping one earlier would resurrect older versions below. Same reasoning you’ll need for M4. - Merge:
merge.rs:35–99— k-way merge on an interval heap (double-ended, so reverse scans work too).
6. Read path end-to-end — src/tree/mod.rs
get:639–643 →get_internal_entry:696–750: active memtable → sealed (newest first) → levels; seqno filtering at every step (:701/707/730) — MVCC reads pick the newest version ≤ snapshot seqno.- Hash computed once and shared across all segment filter checks (:721–723) — the SipHash-cost lesson from topic 0 applied.
The whole path, compressed to its shape:
#![allow(unused)]
fn main() {
fn get(&self, key: &[u8], snapshot: SeqNo) -> Option<Value> {
if let Some(v) = self.active.get(key, snapshot) { return live(v); }
for mt in self.sealed.iter().rev() { // newest sealed first
if let Some(v) = mt.get(key, snapshot) { return live(v); }
}
let h = hash(key); // hashed ONCE for all filters
for run in self.version.runs() { // L0: run per flush; L1+: one
let Some(seg) = run.get_for_key(key) else { continue }; // disjoint ⇒ binary search
if !seg.filter_maybe_contains(h) { continue; } // bloom: skip the IO
if let Some(v) = seg.point_read(key, snapshot) { return live(v); }
}
None // live(): tombstone ⇒ None
}
}
- Tombstones:
value_type.rs:8–27; hidden at read time (tree/mod.rs:67–72), dropped at bottom-level compaction.
Questions to answer in notes.md
- Why can L0 not be a disjoint run, and what does that cost a point read? (Flushes overlap arbitrarily ⇒ probe every L0 run ⇒ the stall trigger.)
- Restart interval 16: derive the trade (space saved by truncation vs linear decode cost per lookup). Why don’t B-tree pages (topic 3) do this?
- The version file is rewritten whole on every compaction. RocksDB instead appends VersionEdits to a MANIFEST log. When does lsm-tree’s simpler choice break down? (Huge segment counts; crash mid-rewrite handled by atomic rename.)
Done when
You can trace one get from tree/mod.rs:639 to a data-block binary search,
naming every filter/index consulted, and explain why tombstones die only at
the bottom.
References
Code
- fjall-rs/lsm-tree — the engine under
fjall; read it all (~3 h):
src/table/block/,src/table/,src/table/filter/standard_bloom/,src/version/,src/compaction/,src/tree/mod.rs. Local shallow clone at~/repos/lsm-tree.
Monkey: bloom bits where they pay
“10 bits/key everywhere” was folklore; Monkey turned bloom-filter sizing into an optimization problem and won ~2× fewer wasted IOs from the same DRAM. This chapter derives the allocation rule — FPR proportional to level size, so bits/key decrease toward the bottom — and sets up the per-level-bits experiment in the mini-LSM.
The setup
An LSM with L levels, size ratio T, total filter memory budget M. Every level gets a bloom filter. Question: how should M be divided among levels?
The one picture
uniform (state of practice): Monkey (optimal):
L1 (small) 10 bits/key L1 ~14 bits/key (FPR tiny)
L2 10 bits/key L2 ~12 bits/key
L3 (huge) 10 bits/key L3 ~8 bits/key (FPR larger, but
fewer probes land here anyway)
total FPR cost: sum of per-level expected wasted IOs: MINIMIZED —
FPRs, dominated by... all equally exponentially decreasing FPR up the tree
Key observation: expected wasted IO for a zero-result lookup = sum of the
per-level FPRs (each level is one potential false probe). But bits-per-key
buys FPR exponentially (fpr ≈ e^(−bits·ln²2)), while level sizes grow by T.
Spending a bit at a small level buys the same FPR drop for T× fewer keys —
i.e., T× cheaper. Optimum: FPRs proportional to level size ⇒ bits/key
decreasing geometrically toward the bottom; the bottom level may get ~0
(its “filter” is the fact that every lookup ends there anyway).
The whole allocation, as the closed form your mini-LSM can call:
#![allow(unused)]
fn main() {
// Pick a total zero-result FPR budget; hand each level a share
// PROPORTIONAL TO ITS SIZE, then convert fpr → bits/key.
fn monkey_alloc(level_keys: &[u64], total_fpr: f64) -> Vec<f64> {
let n: u64 = level_keys.iter().sum();
level_keys.iter().map(|&nk| {
let fpr = total_fpr * nk as f64 / n as f64; // p_i ∝ level size
-fpr.ln() / (LN_2 * LN_2) // bits/key: fpr ≈ e^(−bits·ln²2)
}).collect() // small levels get MORE bits/key
}
}
Reading order
- §1–2 — the LSM cost model (worth it alone: R/W/M costs as formulas in T, L). Map each symbol to your mini-LSM’s knobs.
- §4 — the allocation argument (above). Follow the Lagrange-multiplier sketch once; then re-derive the “FPR ∝ level size” conclusion informally yourself.
- §5 — merging co-tuning (T as a continuum from leveled to tiered). Skim — Dostoevsky does this better.
- §6 evaluation — look for the ~2× lookup improvement at equal memory.
Questions to answer in notes.md
- In your mini-LSM (3 levels, T=10, 10M keys), compute uniform-vs-Monkey expected false probes per zero-result get at 10 bits/key average. Then measure zero-result gets both ways (the experiment supports per-level bits-per-key for exactly this).
- Monkey assumes point lookups dominate. What breaks for range scans? (Filters don’t help ranges at all — prefix blooms exist for a subset.)
- FalkorDB angle: an attribute store doing existence checks before edge insertion is a zero-result-heavy workload — where would Monkey’s argument apply outside an LSM?
Done when
You can state the allocation rule (“equal marginal IO saved per bit ⇒ FPR proportional to level size”) and back it with the measured table from your mini-LSM.
References
Papers
- Dayan, Athanassoulis, Idreos — “Monkey: Optimal Navigable Key-Value Store” (SIGMOD 2017) — §1–2 for the LSM cost model, §4 for the allocation argument; skim §5 (Dostoevsky does the merging co-tuning better) and §6 for the ~2× lookup improvement at equal memory
RocksDB compaction: scores, stalls, and the manifest
The lsm-tree crate gave you the clean shape; RocksDB is what a decade of production adds on top — score-driven compaction picking, write stalls as back-pressure, partitioned indexes, ribbon filters, and a MANIFEST that does MVCC for metadata. This chapter is a guided skim of exactly those additions.
1. Leveled picking — db/compaction/compaction_picker_level.cc
- Score formulas in comments :229–233 —
L0: num_files / level0_file_num_compaction_trigger;L1+: level_bytes / MaxBytesForLevel. Highest score wins. LevelCompactionBuilder::PickCompaction:531 — setup inputs, expand to clean key boundaries, grab the overlapping next-level files.- :596 — score recomputed accounting for in-flight compactions — the picker is a scheduler; double-booking a level wastes IO.
The scoring loop, reduced to its logic:
#![allow(unused)]
fn main() {
// Compact the level with the highest score ≥ 1.0; the picker is a
// scheduler, so bytes already being compacted don't count twice.
fn pick_compaction_level(&self, v: &Version) -> Option<usize> {
(0..v.num_levels())
.map(|lvl| {
let score = if lvl == 0 {
v.num_l0_files() as f64 / self.l0_file_trigger as f64
} else {
(v.level_bytes(lvl) - v.bytes_being_compacted(lvl)) as f64
/ self.max_bytes_for_level(lvl) as f64
};
(lvl, score)
})
.max_by(|a, b| a.1.total_cmp(&b.1))
.filter(|&(_, score)| score >= 1.0) // below 1.0: no debt, do nothing
.map(|(lvl, _)| lvl)
}
}
2. The merge itself — compaction_job.cc:1904
ProcessKeyValueCompaction: a k-way merge (like lsm-tree’s) plus production
concerns — compaction filters (user callbacks), snapshot lists (which old
versions must survive), sub-compaction splitting for parallelism. Skim for
shape; the interesting part is how much of it is not the merge.
3. Stalls — db/column_family.cc:1019–1043
GetWriteStallConditionAndCause:
- L0 files ≥
level0_stop_writes_trigger→ stop - pending compaction bytes ≥ hard limit → stop
- L0 files ≥
level0_slowdown_writes_trigger→ delayed - pending bytes ≥ soft limit → delayed
Compaction debt is measured in bytes not yet merged; stalls convert an
unbounded read-amp problem into a bounded write-latency problem. Compare
fjall’s version: a spin-loop delay at 20–30 L0 runs (fjall
src/keyspace/write_delay.rs:8–16) — same valve, 100× simpler.
4. SST building — table/block_based/block_based_table_builder.cc
- Restart interval + delta encoding :1096–1097 (default 16 — same constant as lsm-tree; convergent evolution or shared ancestry? LevelDB is the ancestor).
- Block flush policy :1127 (~4KB).
- Index entry = last key of each block, written on flush :1908–1912; SQLite’s interior separators, rediscovered — and RocksDB DOES shorten them (FindShortestSeparator), the truncation topic 3 experimented with.
5. Read path — block_based_table_reader.cc
-
Get:3010 — whole-table filter check first :3040, then index iterator- 3044–3053, then data block :3071–3096, block cache probe in
GetDataBlockFromCache:2345.
- Partitioned index :1778 +
partitioned_index_reader.h:15— the index itself becomes a 2-level B-tree when tables are huge: top level pinned in cache, partitions loaded on demand. No fractional cascading in practice — plain binary search per level won.
6. Filters — table/block_based/filter_policy.cc
FastLocalBloomBitsBuilder:365–376 —millibits_per_key; probes stay within one cache line per key (contrast lsm-tree’s double hashing across the whole bit array — k cache lines).- Ribbon :658–686 — ~30% smaller for the same FPR, costlier to build; falls back to bloom if banding fails after 256 seed attempts. CPU-for-DRAM knob.
7. The manifest — db/version_set.cc
LogAndApply:6778 — compaction output replaces inputs by appending aVersionEdit(version_edit.h:37–77, 705–744) to the MANIFEST log, then pointing CURRENT at it. Readers keep iterating their old Version (refcounted) — MVCC for metadata. lsm-tree rewrites the whole version file instead; same atomicity, different scale point.
Questions to answer in notes.md
- Why does leveled compaction pick by score rather than round-robin? Construct a workload where round-robin lets one level grow unboundedly.
- Partitioned index vs lsm-tree’s per-block hash index — both attack “index too big for cache”. Which helps point reads, which helps scans, why?
- FastLocalBloom does k probes in one cache line — what does that cost in FPR vs a classic bloom at equal bits/key? (Blocked blooms have slightly worse FPR — the locality is paid for in statistics.)
Done when
You can list the three stall triggers from memory and explain LogAndApply’s refcounted-Version scheme as “MVCC for metadata”.
References
Code
- facebook/rocksdb —
db/compaction/compaction_picker_level.cc,db/compaction/compaction_job.cc,db/column_family.cc(stalls),table/block_based/block_based_table_builder.cc,table/block_based/block_based_table_reader.cc,table/block_based/filter_policy.cc,db/version_set.cc(MANIFEST). Local clone at~/repos/rocksdb. - fjall-rs/fjall
src/keyspace/write_delay.rs— the 100×-simpler stall valve, for contrast.
RocksDB’s decade: write amp → space amp → CPU
Not a data-structures chapter — a 10-years-of-production one. RocksDB’s development priorities shifted three times in a decade, and every shift was driven by hardware economics rather than better algorithms. Read this for what benchmarks don’t show: the failure modes, API regrets, and configuration sprawl that only appear at fleet scale.
The arc (what to extract)
The paper’s history of what RocksDB optimized for, in order:
2012 ───────► 2015 ───────► 2018 ───────► 2021
write amp space amp CPU disaggregated / remote storage
(SSD wear, (SSDs got (storage got (storage moves off-box;
fillrandom cheaper — fast enough topic 28 territory)
benchmarks) $/GB rules) that CPU is
the bottleneck)
Each shift happened because the hardware economics moved, not because the algorithms improved. Leveled compaction’s high write amp was acceptable the moment space amp mattered more — the RUM triangle steered by procurement.
Read in this order
- §1–2 — background + the resource-priority history (the arc above).
- §3 — lessons on compaction: why leveled won at Facebook (space), universal kept for ingest-heavy; the tiered-vs-leveled discussion with production numbers instead of asymptotics.
- §4 — large-scale lessons. The best section:
- failure handling: silent corruption found by checksums at every layer (block, file, WAL record) — corruption rates at fleet scale make “unlikely” a certainty;
- the timestamp/seqno API regrets;
- configuration sprawl (hundreds of knobs) as an acknowledged failure.
- §5 — future directions (2021 vintage): remote compaction, tiered storage — check which happened (topic 28 will).
Questions to answer in notes.md
- The paper says CPU became the bottleneck once NVMe arrived. Reconcile with your topic-0 finding (SipHash 21%, memory stalls dominant): which CPU costs does an LSM add on top of a hash table’s? (Comparisons in merges, block decode/decompress, filter hashing per level.)
- Why does RocksDB checksum at block AND file AND WAL-record level rather than trusting the filesystem? What’s the FalkorDB/redis equivalent story? (RDB has a CRC; AOF… check.)
- Pick the lesson from §4 most relevant to the capstone and write one paragraph on how it changes your M4 design.
Done when
You can narrate the write-amp → space-amp → CPU priority arc with the hardware reason for each transition.
References
Papers
- Dong, Kryczka, Jin, Stumm — “RocksDB: Evolution of Development Priorities in a Key-value Store Serving Large-scale Applications” (ACM TODS 2021) — §4 (large-scale lessons) is the best section; §5’s 2021-vintage future directions are checkable predictions for topic 28
Topic 4 — notes
Predictions (fill BEFORE running write_amp)
At 10M ops, 3.3M distinct keys, 100B values, 1MB memtable, ratio 10 / K=4: predicted levels = ___, so:
| Metric | leveled (predict) | leveled (measured) | tiered (predict) | tiered (measured) |
|---|---|---|---|---|
| write amp | ~ratio/2 × levels = | ~levels = | ||
| read amp (segs/get) | ||||
| space amp | ~1.1 | ~K | ||
| load Kops/s |
Reading answers
lsm-tree crate
- Why L0 can’t be disjoint / what it costs:
- Restart interval 16 trade; why B-tree pages don’t:
- Whole-version rewrite vs MANIFEST log breakdown point:
RocksDB
- Score vs round-robin — adversarial workload:
- Partitioned index vs per-block hash index:
- Blocked bloom FPR cost:
Monkey
- Uniform vs Monkey expected false probes (computed, then measured):
- What breaks for range scans:
- Zero-result-heavy workloads outside LSMs:
Dostoevsky
- Lazy-leveling score on MY measured numbers:
- Why ranges don’t benefit:
- Universal-compaction knobs ≈ K and Z:
TODS ’21
- CPU costs an LSM adds over a hash table:
- Checksums-at-every-layer; FalkorDB/redis story:
- The §4 lesson I’m applying to M4:
Design space (VLDB ’21)
- 5-system × 4-axis table:
- Prediction to test (granularity vs p99.9):
Experiment findings
- Measured RUM table above; explain each gap between prediction and measurement:
- Bloom saved __% of probes; at what level did misses concentrate:
- Tombstone test: what compaction bug did the tests catch first try (log it):
M4 log
- LSM backend behind the M1 trait; snapshot-as-SST bulk load uses trivial move:
- B+tree (M3) vs LSM bench on mutation stream + bulk load; p99 discipline:
- Where redis RDB/AOF sits vs both (seeds topic 5):
Topic 5 — Durability: WAL, fsync, Crash Recovery
The hardest part to get right, because the failure you’re defending against deletes the evidence. Four systems, four durability designs: postgres (ARIES- style redo), turso/SQLite (WAL with checksum chain), LMDB (topic 3: no log at all), redis (AOF command log + fork snapshots).
Outcomes
By the end you can:
- State the WAL rule (log reaches disk before the page it protects) and derive why each system’s recovery works from it.
- Explain torn pages and the two industrial fixes (full-page writes, double-write).
- Measure the fsync ladder on your own SSD and design group commit from it.
- Ship a WAL + crash recovery for your topic-3 B+tree that survives
kill -9.
1. The problem and the rule
A 4KB page write is not atomic (power loss mid-sector-train = torn page), and the kernel lies about write completion until fsync. The fix is one invariant:
WAL rule: log record describing a change is durable BEFORE the changed page.
commit rule: commit record durable before acknowledging the client.
write path: crash recovery:
1. append log record 1. find last valid log record (checksums!)
2. fsync log 2. redo forward from last checkpoint
3. ack client 3. undo losers (if update-in-place; ARIES)
4. page write LATER — or skip undo entirely (COW/append-only designs)
2. Four designs on one axis
no log ◄──────────────────────────────────────────────► log is the database
LMDB turso WAL postgres redis AOF
COW + meta flip frames = page imgs ARIES redo + the command
(topic 3) appended, checkpoint FPI after ckpt, stream itself,
moves them home undo via MVCC replayed on boot
- turso/SQLite WAL: every commit appends whole page images as frames;
a frame with
db_size != 0marks a commit. Reads check the WAL first (page→frame map), the DB file second. Checkpoint = copy frames back. Recovery = scan frames, verify the checksum chain, stop at the last valid commit. No undo, ever — uncommitted frames are simply ignored. - postgres: logical/physiological records, not page images — except the first touch of each page after a checkpoint writes a full-page image (torn-page defense). Recovery = redo from checkpoint; undo is MVCC’s job (dead tuples), not the log’s.
- redis AOF: log = the commands.
appendfsync everysectrades ≤1s of acknowledged writes for throughput — a policy choice the others don’t offer. RDB = fork + COW snapshot (durability by checkpoint only). - RocksDB WAL: LevelDB record format — 32KB blocks, records fragmented
as FULL/FIRST/MIDDLE/LAST with per-fragment CRC (
db/log_format.h:22,54,log_writer.cc:87 AddRecord → EmitPhysicalRecord). Block-aligned so a torn tail never hides an earlier record. Memtable + WAL replaces undo entirely.
3. The fsync ladder (measure it — experiment 1)
| Call | Guarantees | Typical cost (SSD) |
|---|---|---|
write() | nothing (page cache) | ~µs |
fdatasync() | data + size metadata | ~50–500µs consumer, ~10µs enterprise |
fsync() | + all metadata | ≥ fdatasync |
macOS fsync() | drive cache NOT flushed | fast and weak |
macOS F_FULLFSYNC | drive cache flushed | ms-scale — measure it! |
O_DIRECT + own buffering | bypass page cache | topic 6 |
Group commit exists because of this ladder: if fsync costs 1ms, one fsync per commit caps you at 1K commits/s — but one fsync can cover N commits.
flowchart LR
A["N threads commit<br/>concurrently"] --> B["leader takes lock,<br/>writes ALL queued<br/>records, fsyncs once"]
B --> C["followers recheck<br/>flushed-LSN: already<br/>covered? return"]
Postgres does exactly this: XLogFlush rechecks LogwrtResult.Flush after
acquiring the lock (xlog.c:2885) — most backends find their work already done.
4. Code reading (5–7 h)
- postgres
xlog.c(10K lines — guided skim). →reading-postgres-xlog.md— postgres xlog: reserve-then-copy and the flush recheck - turso WAL — frame format, checksum chain, checkpoint, recovery.
→
reading-turso-wal.md— Turso’s WAL: recovery is finding where the log ends - redis
aof.cvsrdb.c— command log vs fork snapshot (the FalkorDB reality today). →reading-redis-aof-rdb.md— Redis AOF & RDB: the command stream is the log
5. Papers (3–5 h)
- Mohan et al., “ARIES” (TODS ’92) — summary first, then selected sections.
→
reading-aries.md— ARIES: recovery when you escape nothing - “Aether: A Scalable Approach to Logging” (VLDB ’10) — group commit +
log-contention analysis on multicore.
→
reading-aether.md— Aether: one log, no bottleneck
6. Experiments (in experiments/)
src/bin/fsync_ladder.rs(provided, runs now) — measure write/ fdatasync/fsync/F_FULLFSYNC latency on YOUR disk. HdrHistogram; the numbers feed every design decision below.src/wal.rs— WAL for the topic-3 B+tree: record format (LSN, page_no, before/after or page image — your call, justify in notes), CRC per record, group-commit API (commit_many).src/bin/crash_test.rs— crash injection: child process inserts keys, parentkill -9s it at a random moment, reopens, verifies: every acknowledged key present, no torn state, unacked keys either fully in or out. Run 100 rounds.benches/commit_throughput.rs— fsync-per-commit vs group commit (batch 8/64/512) vsappendfsync everysec-style. Plot commits/s vs durability window.
7. Capstone milestone M5 (in ../../capstone/)
- WAL + crash recovery for graph mutations (node/edge/property ops as logical records) behind the storage trait.
- Crash-injection suite (the
crash_testharness, pointed at the graph). - Contrast written up: your WAL vs FalkorDB-on-redis (RDB fork snapshot + AOF command replay) — what’s the durability window of each config?
Done when
100/100 crash rounds pass; the fsync ladder table and commit-throughput plot
are in notes.md; you can explain why turso needs no undo and postgres needs
no logged undo either (MVCC), while ARIES needs both.
Aether: one log, no bottleneck
On a multicore, the log is ONE shared object every transaction must append to and flush — so how does it not become the bottleneck? Aether’s answer is four independent fixes that compose, and one of them (consolidation arrays) is the ancestor of how postgres inserts WAL today. This chapter maps each fix to its modern descendant.
The four bottlenecks (§1–2)
txn commits ──► [A] contend on log-buffer insert (one mutex around append)
──► [B] wait for fsync (I/O latency per commit)
──► [C] hold locks WHILE waiting for [B] (lock contention amplified)
──► [D] context switches around the wait
Aether attacks each separately — read the paper as four independent fixes that compose:
| Bottleneck | Fix | Modern descendant |
|---|---|---|
| B: fsync per commit | group commit | postgres XLogFlush recheck |
| C: locks held across flush | Early Lock Release (ELR) | controversial; see Q2 |
| D: scheduling | flush pipelining (async commit queues) | redis everysec (cruder) |
| A: buffer insert mutex | consolidation array | postgres reserve-then-copy |
1. Early Lock Release (§3)
Release locks at commit-record creation, not commit-record durability. Dependent transactions may read your uncommitted-but-logged data — safe IF they can’t acknowledge before you do (their commit record follows yours in the log, so log order enforces the dependency). Serial log = free dependency tracking.
2. Flush pipelining (§4)
Worker threads never block on fsync: they enqueue the commit and detach, picking up new work; a daemon acks completed commits after the flush lands. Throughput of async commit, durability of sync commit — the cost is latency and a more complex scheduler, not a loss window.
3. Consolidation arrays (§5) — the part that shipped everywhere
The insight: even with group commit, every append still serializes on the buffer mutex. Fix: threads combine their requests before hitting the lock.
naive: T1 ─lock─ memcpy ─unlock─ T2 ─lock─ memcpy ─unlock─ T3 …
consolidated:
T1,T2,T3 meet in a slot array, add up sizes (CAS, no lock),
ONE of them acquires the lock, reserves sum(bytes) once,
each thread memcpys into its own slice IN PARALLEL.
Decouples sequencing (must be serial, make it tiny) from copying (can be
parallel, make it so). Postgres’s ReserveXLogInsertLocation (spinlock for 3
arithmetic ops) + 8 parallel insertion locks is this idea in production —
read reading-postgres-xlog.md §2 side by side with §5.
The slot dance, in code:
#![allow(unused)]
fn main() {
// Combine appends BEFORE the lock; only sequencing stays serial.
fn append(&self, rec: &[u8]) -> Lsn {
let slot = self.slots.join(); // CAS onto an open slot
let my_off = slot.size.fetch_add(rec.len()); // add my bytes — no lock
if my_off == 0 { // first in = group leader
let total = slot.close(); // no more joiners
let base = {
let _g = self.buffer_lock.lock(); // tiny critical section:
self.reserve(total) // ONE reservation for all
};
slot.publish(base);
}
let base = slot.wait_for_base();
self.buf_write(base + my_off, rec); // everyone copies IN PARALLEL
Lsn(base + my_off)
}
}
Read in this order
- §1–2 for the bottleneck taxonomy (the table above).
- §5 consolidation arrays — the durable contribution.
- §3 ELR — for the argument about log order as dependency tracking.
- Skim §4 + evaluation (§6): note which fix buys what at which core count.
Questions to answer in notes.md
- Why does ELR NOT violate durability for the dependent transaction? (Its commit record is behind yours; a crash that loses yours loses its too.)
- ELR hazard: what if the dependent txn’s result escapes to the user by a channel other than its own commit ack (e.g. a read-only txn that never logs)? This is why real systems mostly didn’t ship it.
- Consolidation vs postgres’s 8 insert locks: both parallelize the copy — what’s the difference in HOW threads find a slot? (CAS-combining into a shared slot vs hashing onto a fixed lock array; contrast under 8 vs 80 writers.)
- Which bottleneck does your M5 group-commit design leave unfixed? (Likely A — a single mutex around the WAL buffer is fine at graph-workload commit rates; say at what commits/s it wouldn’t be.)
Done when
You can name the four bottlenecks from memory, sketch a consolidation array, and point at the postgres code that embodies it.
References
Papers
- Johnson, Pandis, Stoica, Athanassoulis, Ailamaki — “Aether: A Scalable Approach to Logging” (VLDB 2010) — ~12 pages; §1–2 for the bottleneck taxonomy, §5 (consolidation arrays) is the durable contribution, §3 for the ELR argument, skim §4 and the evaluation
ARIES: recovery when you escape nothing
Postgres escapes undo via MVCC, SQLite-WAL escapes redo via page images, LMDB escapes logging via COW — ARIES is the recovery method for engines that escape nothing: update-in-place, steal, no-force. It is the most-cited recovery paper and the vocabulary every other design in this topic is defined against; reading it tells you exactly what each escape hatch is worth.
Why read it when postgres/turso don’t do full ARIES
ARIES is the recovery design for update-in-place + steal/no-force engines (InnoDB, SQL Server, Db2). Postgres escapes undo via MVCC; SQLite-WAL escapes redo via page images; LMDB escapes logging via COW. ARIES is what you need when you escape nothing — reading it tells you exactly what those escapes are worth.
Vocabulary (the paper is unreadable without these)
| Term | Meaning |
|---|---|
| steal | dirty pages may hit disk BEFORE commit (⇒ need undo) |
| no-force | pages need NOT hit disk at commit (⇒ need redo) |
| LSN | log sequence number; every page stamps the LSN of its last change |
| CLR | compensation log record — undo work is itself logged, redo-only |
| DPT | dirty page table (checkpointed) — which pages might need redo |
| ATT | active transaction table (checkpointed) — who needs undo |
The three passes
log: …──[ckpt: DPT+ATT]────────────────────────► crash
1. ANALYSIS ────────────────► rebuild DPT/ATT from ckpt forward
2. REDO ────────────────► repeat HISTORY (even losers!) from
min(recLSN in DPT) — page LSN ≥ record LSN ⇒ skip
3. UNDO ◄──────────────── roll back losers, writing CLRs;
CLR.undoNext skips already-undone work
Repeating history is the counterintuitive core: redo replays everything, including doomed transactions, to restore the exact crash-moment state — THEN undo runs as ordinary, loggable transaction rollback. This is what makes recovery-during-recovery safe (crash during undo ⇒ CLRs ensure no double-undo).
The three passes, as one function:
#![allow(unused)]
fn main() {
fn recover(log: &Log, ckpt: &Checkpoint) {
let (dpt, att) = analysis(log, ckpt); // 1. who was dirty, who was active
for rec in log.from(dpt.min_rec_lsn()) { // 2. REDO: repeat history —
if page_lsn(rec.page_id) < rec.lsn { // even losers' updates.
apply_redo(rec); // pageLSN ≥ recLSN ⇒ skip:
} // idempotence by LSN compare
}
for txn in att.losers() { // 3. UNDO: ordinary rollback,
for rec in txn.updates_newest_first() { // but each undo is LOGGED
let clr = log.append_clr(rec); // as a redo-only CLR
clr.undo_next = rec.prev_lsn; // crash mid-undo? restart
apply_undo(rec); // resumes at undo_next —
} // no double-undo, ever
}
}
}
Read in this order
- A summary (above) until the three passes + CLRs feel obvious.
- Paper §3 (“the problem”): why the naive undo-then-redo attempts fail — the best catalog of recovery bugs ever assembled.
- §6: the passes in detail — read for
pageLSN ≥ recLSN ⇒ skip redo(idempotence via LSN comparison) and the CLR undoNext chain. - Skim §10 (nested top actions — how B-tree splits survive rollback: the split stays even if the insert that caused it aborts).
Map to what you’ve read
- postgres: ANALYSIS+REDO yes (xlogrecovery.c), UNDO replaced by MVCC vacuum; FPIs make redo idempotent even without perfect LSN discipline.
- turso WAL: no passes at all — commit boundary detection only.
- Your M5 WAL: if you chose logical records (reading-turso-wal.md Q3), you owe ARIES-style idempotent redo: stamp pages with LSNs, skip if page is newer.
Questions to answer in notes.md
- Why must CLRs be redo-only (never undone)? Walk a crash-during-undo.
- Nested top action for a B-tree split: why is letting the split survive an aborted insert both correct and necessary? (Other txns may already use the new page; physical consistency ≠ logical visibility.)
- Which of steal/no-force does YOUR topic-3 B+tree + WAL implement? Derive which passes your recovery needs. (Likely no-steal/no-force at first ⇒ redo-only — say so explicitly.)
Done when
You can fill the 2×2 steal/force matrix with (undo?, redo?) from memory and explain repeating history in two sentences.
References
Papers
- Mohan, Haderle, Lindsay, Pirahesh, Schwarz — “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging” (ACM TODS 1992) — 70 pages; read a summary first (Franklin’s “Crash Recovery” chapter or CMU 15-445 recovery notes), then dip into §3 and §6, skim §10
postgres xlog: reserve-then-copy and the flush recheck
Postgres’s WAL is 10,000+ lines of C, but it earns its keep with five mechanisms: the back-linked record format, the reserve-then-copy insertion trick, group commit via a flush recheck, full-page writes after checkpoints, and fuzzy checkpointing with redo-only recovery. This chapter skims exactly those five — do NOT read the file linearly.
1. The record — xlogrecord.h:41–53
XLogRecord: xl_tot_len, xl_xid, xl_prev (back-pointer — the log is
a backward-linked list; recovery validates forward, xl_prev catches missequenced
segments), xl_crc. Block references (:103) attach page references; full-page
images ride in XLogRecordBlockImageHeader (:141) — note hole_offset: the
free space in the middle of a page is elided from the FPI.
2. Insertion — the scalability trick
ReserveXLogInsertLocation— xlog.c:1149–1193: a spinlock held for ~3 arithmetic ops (:1172–1180) hands out byte ranges in the log. Reservation is serial (tiny), copying is parallel.CopyXLogRecordToWAL— xlog.c:1266: copy into the reserved slice of WAL buffers under one ofNUM_XLOGINSERT_LOCKS = 8(xlog.c:157) insertion locks.- This is Aether’s insight shipped: separate sequencing from copying. Compare topic-2’s incremental rehash — same move, amortize/parallelize the heavy part, keep the critical section O(1).
3. Group commit — XLogFlush, xlog.c:2800–2891
The heart: after acquiring the write lock, recheck LogwrtResult.Flush
(:2885) — another backend probably flushed past your LSN while you waited;
return without an fsync. commit_delay/commit_siblings (:2901–2906) add an
optional pre-flush sleep to grow the batch. Your experiment reimplements this.
Group commit is just that recheck:
#![allow(unused)]
fn main() {
fn xlog_flush(&self, upto: Lsn) {
if self.flushed_lsn() >= upto { return; } // cheap check, no lock
let _g = self.write_lock.lock(); // maybe wait behind a flusher…
if self.flushed_lsn() >= upto { return; } // …RECHECK: their fsync already
// covered our LSN — free ride
self.write_out_buffers_through(upto);
self.wal_file.fdatasync(); // ONE fsync for every backend
self.advance_flushed_lsn(); // that queued behind the lock
}
}
4. Full-page writes — xloginsert.c:621–700
XLogRecordAssemble: needs_backup = (page_lsn <= RedoRecPtr) (:694) — first
modification of a page after a checkpoint logs the whole page (hole elided).
Cost: WAL volume spikes after every checkpoint (the famous sawtooth). This is
the torn-page defense; InnoDB solves the same problem with a double-write
buffer instead; LMDB and SQLite-WAL solve it by never overwriting.
5. Checkpoint + recovery
CreateCheckPoint— xlog.c:7400–7560: set redo point under the insert lock (:7561), then flush dirty buffers while WAL keeps rolling — fuzzy: the checkpoint is a starting point, not a consistent snapshot.PerformWalRecovery— xlogrecovery.c:1612–1806: read from the redo point,ApplyWalRecord(:1782) dispatches to per-resource-manager redo handlers. Per-record CRC in xlogreader.c:1207–1227 — an invalid CRC means “end of log”, not “corruption error”. The log’s tail is expected to be garbage after a crash; checksums are how you find the cliff edge.- No undo pass: postgres MVCC never overwrites tuples in place, so losers are just dead tuples awaiting vacuum. ARIES’s undo machinery (CLRs, rollback) isn’t needed. Read reading-aries.md for what postgres is not doing.
6. Sync methods — issue_xlog_fsync, xlog.c:9361–9410
wal_sync_method: fsync / fdatasync / open_datasync (O_DSYNC at open — no
separate sync call). Your fsync_ladder experiment measures exactly these.
Questions to answer in notes.md
- Why is
xl_prevneeded when records are read forward anyway? (Detects a valid-looking record left over from a recycled segment file.) - FPI sawtooth: checkpoint_timeout ↑ ⇒ WAL volume ↓ but recovery time ↑. Write the trade as a formula in (dirty rate, checkpoint interval).
- The 8 insertion locks: what workload would make you raise the number, and what does postgres pay for each extra lock at flush time? (Flush must wait for all in-progress copies below the target LSN — WaitXLogInsertionsToFinish.)
Done when
You can explain reserve-then-copy, the flush recheck, and needs_backup in three sentences total — those three lines are the file.
References
Code
- postgres/postgres —
src/backend/access/transam/xlog.c(10,196 lines — do NOT read linearly),src/backend/access/transam/xloginsert.c,src/backend/access/transam/xlogrecovery.c,src/include/access/xlogrecord.h. Local clone at~/repos/postgres.
Redis AOF & RDB: the command stream is the log
Redis logs the commands themselves (AOF) and checkpoints by forking (RDB) — and since a graph module’s data lives inside redis’s keyspace, this is the durability FalkorDB actually has today. Read it as the incumbent your M5 design competes with: what everysec’s ack-before-durability really promises, and why AOF rewrite is an LSM compaction in disguise.
1. AOF = the command stream is the log
feedAppendOnlyFile— aof.c:1409–1448: every write command appended (as RESP text!) toserver.aof_buf(:1444).flushAppendOnlyFile— aof.c:1147–1355: buffer → write() (:1218), then the policy (:1330–1354):AOF_FSYNC_ALWAYS(:1337) — fsync before ack. Durable, slow.AOF_FSYNC_EVERYSEC(:1350) — fsync on the bio background thread (aof_background_fsync, :983): main thread never blocks on the disk; window = up to ~2s of acked writes.AOF_FSYNC_NO— kernel decides. Window = unbounded.
- Group commit comparison: everysec is group commit with a time batch and the ack BEFORE the flush — postgres groups the flush but never acks early. Different contract, not just different tuning.
The three contracts, side by side:
#![allow(unused)]
fn main() {
// flushAppendOnlyFile: the client was ACKED before any of this runs.
fn flush_aof(&mut self, policy: Fsync) {
self.file.write_all(&self.aof_buf); // into page cache only
self.aof_buf.clear();
match policy {
Fsync::Always => self.file.fdatasync(), // durable before next ack: slow
Fsync::EverySec => {
if self.last_fsync.elapsed() >= Duration::from_secs(1) {
self.bio.submit(FsyncJob); // background thread — main
} // thread never touches the disk;
} // window: up to ~2 s of ACKED writes
Fsync::No => {} // kernel decides; window unbounded
}
}
}
2. AOF rewrite — compacting a command log
A command log grows without bound (1M INCRs = 1M records for one key).
rewriteAppendOnlyFileBackground— aof.c:2652–2720: fork (:2689); the child serializes current state as a fresh BASE file; the parent keeps serving and accumulates new commands into a new INCR file.- Multi-part AOF (aof.c:45–71): a manifest lists BASE + INCR files — recovery = load BASE, replay INCRs. This is an LSM in disguise: BASE = the bottom level, INCR = L0, rewrite = full compaction, manifest = MANIFEST. (Topic 4’s vocabulary transfers wholesale.)
3. RDB — checkpoint by fork
rdbSaveBackground— rdb.c:1859–1892: fork (:1868), child walks the keyspace and writes the snapshot; parent’s writes COW pages away. CRC64 trailer (rdb.c:1702–1706).- The COW cost is why topic-2’s dict disables rehashing during BGSAVE (dict.c:1655) — a rehash would touch every bucket and copy the whole table. Durability policy reaching down into data-structure design.
4. The FalkorDB angle (write this up in notes)
A graph module’s data lives inside redis’s keyspace, so its durability is this file: RDB serializes matrices via module callbacks; AOF logs the GRAPH.QUERY commands. Questions that matter for M5:
- Replaying
GRAPH.QUERYcommands re-executes parsing and planning — what’s recovery time for 10M mutations vs replaying logical records? - An RDB snapshot of a multi-GB graph forks + COWs the whole matrix set under write load — measure-or-estimate the stall.
Questions to answer in notes.md
- everysec acks before durability. State the exact loss window and why redis considers delaying writes (not acks) when the bio fsync falls behind (:1147 area — the “postpone” logic).
- AOF-as-LSM: map BASE/INCR/rewrite/manifest onto topic-4 terms. What’s the “write amp” of an AOF rewrite?
- Command-log vs page-image vs logical-record WAL: rank recovery speed and log volume for a graph-mutation workload; justify your M5 choice.
Done when
You can state each appendfsync policy’s durability window from memory and explain AOF rewrite as compaction.
References
Code
- redis —
src/aof.c(feed, flush policies, rewrite, multi-part manifest) andsrc/rdb.c(fork + COW snapshot, CRC64 trailer). Local clone at~/repos/redis.
Turso’s WAL: recovery is finding where the log ends
This is SQLite’s WAL mode in Rust: commits append whole page images as frames, a chained checksum makes the log’s valid prefix self-evident, and recovery has no redo or undo at all — it just decides where the log ends. Of the four durability designs in this topic, this is the one your experiment should steal most from.
1. The format — page images, not operations
WalHeader— sqlite3_ondisk.rs:411–443: magic, version, two salts, header checksum.WalFrameHeader— :477–495: 24 bytes — page_no, db_size (non-zero ⇒ this frame commits), the salts (copied from the header), checksum_1/2.- Frame write — :2058–2090: checksums are cumulative: each frame’s checksum seeds the next (:2080–2088). One flipped bit invalidates the entire suffix — exactly what you want: recovery can trust everything before the break.
WAL file: [hdr] [frame p5][frame p2][frame p9*] [frame p5][frame p1*] …
── txn 1, *commit (db_size≠0) ── ── txn 2 ──
checksum: c0 ──► c1 ──────► c2 ─────► c3 ─────────► c4 ──────► c5 (chained)
salts: change on WAL reset — a stale frame from a previous WAL generation
fails the salt check even if its checksum chain looks plausible
2. Commit + sync
prepare_wal_finish— wal.rs:4130–4145: fsync after the commit frame;FileSyncType(io/mod.rs:128–134) distinguishesFsyncfromFullFsync(macOS F_FULLFSYNC) — on your Mac, plain fsync does NOT flush the drive cache. Your fsync_ladder experiment will show the gap; it’s not subtle.
3. Reads check the WAL first
find_frame— wal.rs:3335–3404: in-memory page→latest-frame map; hit ⇒read_frame(:3409) from the WAL file; miss ⇒ read the main DB.- Consequence: an uncheckpointed WAL makes every read do a map lookup, and a HUGE WAL makes reads slower (more frames to cover) — checkpointing is a read optimization, not just space reclamation.
4. Checkpoint — moving frames home
CheckpointMode— wal.rs:160–183: Passive / Full / Restart / Truncate.checkpoint_inner— wal.rs:4594–4672: backfill loop copies frames [nbackfills+1 … max_frame] into the DB file, sorted by frame for locality.- Restart/Truncate change the salts — that’s how old frames die without being erased.
5. Recovery — find the cliff edge
WalScan— sqlite3_ondisk.rs:1426–1932: validate header checksum (:1727), then walk frames verifying salt + chained checksum (:1830–1831); remember the last frame withdb_size > 0(:1844–1855); final state = last valid COMMIT (:1923), not last valid frame — a half-written transaction’s frames are present but unreachable.
No undo, no redo logic — recovery is deciding where the log ends. Compare postgres (redo required: its log holds deltas, not page images) and LMDB (nothing at all: the meta flip made commit atomic).
The whole recovery, in one loop:
#![allow(unused)]
fn main() {
// Walk frames; the answer is the last valid COMMIT, not the last valid frame.
fn recover(frames: &[Frame], hdr: &WalHeader) -> u64 {
let mut c = hdr.checksum;
let mut last_commit = 0;
for (i, f) in frames.iter().enumerate() {
if f.salts != hdr.salts { break; } // stale frame from an old WAL generation
c = chain(c, f); // cumulative: one bad bit ends the log
if c != f.checksum { break; } // torn frame ⇒ the cliff edge
if f.db_size != 0 { // commit frame
last_commit = i as u64 + 1; // a half-written txn's frames stay
} // present but UNREACHABLE
}
last_commit
}
}
Questions to answer in notes.md
- Why do frames carry whole page images instead of deltas? Name the two things this buys (no redo logic; torn-page immunity — a torn frame fails its checksum and everything after is discarded) and the one it costs (WAL volume ∝ pages touched, not bytes changed).
- Why salts AND checksums? Construct the failure that checksums alone miss. (WAL reset reuses the file; an old frame at the right offset can have a valid internal checksum — but chains from stale salts.)
- For your experiment’s WAL: page images or logical records? Decide and justify with the M5 workload (small graph mutations ⇒ logical records win on volume, but then you owe idempotent redo — LSN-stamped pages).
Done when
You can narrate recovery over a WAL containing a torn frame mid-transaction and a complete-but-uncommitted transaction, and say what survives (everything up to the last valid commit frame; both damaged suffixes vanish).
References
Code
- tursodatabase/turso —
core/storage/wal.rs,core/storage/sqlite3_ondisk.rs,core/io/mod.rs. Local clone at~/repos/turso.
Topic 5 notes — durability, WAL, crash recovery
Predictions (fill BEFORE running fsync_ladder)
| Rung | Predicted p50 | Measured p50 | Measured p99 |
|---|---|---|---|
write() only | |||
fdatasync | |||
fsync (macOS — weak!) | |||
F_FULLFSYNC |
Predicted max commits/s at 1 fsync/commit: ______ Predicted group-commit speedup at batch 64: ______
fsync_ladder results
(paste table from cargo run --release --bin fsync_ladder)
Surprises vs predictions:
WAL design decisions (src/wal.rs)
- Page images vs logical records — chose: ______ because:
- Group-commit trigger (size / time / both): ______
- Why replay needs no LSN-idempotence check here (and when it would):
crash_test log
- Rounds passed: ___/100
- Failures seen while developing (torn tail? lost ack? partial txn?) and the bug behind each:
commit_throughput results
| Policy | commits/s | durability window |
|---|---|---|
| fsync per commit | 0 | |
| group 8 | 0 | |
| group 64 | 0 | |
| group 512 | 0 |
Reading-guide questions
postgres xlog (reading-postgres-xlog.md)
- Why xl_prev when reading forward:
- FPI sawtooth formula in (dirty rate, checkpoint interval):
- Raising NUM_XLOGINSERT_LOCKS — when, and the flush-time cost:
turso WAL (reading-turso-wal.md)
- Page images vs deltas — two buys, one cost:
- The failure salts catch that checksums alone miss:
- My experiment’s format choice + justification:
redis AOF/RDB (reading-redis-aof-rdb.md)
- everysec loss window + the write-postpone logic:
- AOF-as-LSM mapping + rewrite write amp:
- Command-log vs page-image vs logical-record ranking for graph mutations:
ARIES (reading-aries.md)
- Why CLRs are redo-only (crash-during-undo walkthrough):
- Nested top action for a B-tree split — why correct AND necessary:
- My topic-3 B+tree + WAL: steal? force? ⇒ which passes needed:
Steal/force 2×2 (from memory):
| force | no-force | |
|---|---|---|
| no-steal | undo: __ redo: __ | undo: __ redo: __ |
| steal | undo: __ redo: __ | undo: __ redo: __ |
Aether (reading-aether.md)
- Why ELR preserves durability for dependents:
- The ELR hazard (non-logging escape channel):
- Consolidation array vs postgres’s 8 insert locks:
- Which bottleneck my M5 design leaves unfixed, and at what commits/s it bites:
M5 log (capstone)
- WAL + recovery for graph mutations behind the storage trait
- crash_test harness pointed at the graph — rounds: ___/100
- Contrast vs FalkorDB-on-redis: durability window of RDB-only, RDB+AOF everysec, AOF always:
Topic 6 — Buffer Pool & Memory Management
Who decides which pages live in RAM? Four answers: postgres (CLOCK over a shared array), DuckDB (approximate-LRU queue with lazy purging), LeanStore (pointer swizzling — no mapping table at all on the hot path), and mmap (let the kernel decide — the CIDR ’22 paper on why that’s usually wrong). Plus redis’s answer to a different question: not page caching but allocator accounting (zmalloc + jemalloc + active defrag).
Outcomes
By the end you can:
- Walk a page request through hash-lookup → pin → CLOCK victim search and say where every atomic and lock is.
- Explain why mmap loses to a buffer pool (TLB shootdowns, no write ordering, page-fault stalls you can’t schedule around) and when it’s fine.
- Explain pointer swizzling and the cooling stage — how LeanStore makes an in-memory hit cost ~0 extra instructions.
- Build a CLOCK buffer pool for your topic-3 B+tree and beat mmap on a larger-than-RAM workload (or measure exactly why you don’t).
1. The translation problem
Every buffer pool is a map page_id → frame, and every design is a stance on
who pays for the translation:
lookup cost per hot-page hit
hash table (postgres, DuckDB) ~1 hash probe + partition lock/atomics
swizzling (LeanStore) 0 — the parent's pointer IS the frame ptr
page table (mmap/OS) 0-ish until a TLB miss / minor fault
... then the kernel takes over your latency
flowchart LR
A["request page P"] --> B{"in pool?"}
B -- "hit" --> C["pin (CAS refcount++)<br/>usage_count↑"]
B -- "miss" --> D["find victim:<br/>CLOCK sweep"]
D --> E{"victim dirty?"}
E -- "yes" --> F["write it out FIRST<br/>(WAL rule: log already flushed)"]
E -- "no" --> G["evict, relabel frame,<br/>read P from disk"]
F --> G
G --> C
2. Eviction: three shapes of approximate-LRU
- postgres CLOCK — one
nextVictimBufferatomic ticks around a fixed array; each buffer has a 4-bitusage_count(max 5). Sweep decrements; a buffer survives up to 5 laps. Pinned buffers are skipped. No linked lists, no per-hit list surgery — a hit is just a saturating increment. - DuckDB eviction queue — unpinning enqueues
(weak_ptr, seq_num)into a concurrent FIFO. Re-pinning doesn’t remove the entry (too expensive); it bumps the handle’s sequence number so the stale entry becomes a dead node, purged in bulk every 4096 insertions. Approximate LRU where the cleanup is amortized, not per-op — same move as topic 2’s incremental rehash. - LeanStore cooling stage — no global order at all: random buffer frames get unswizzled into a cooling FIFO (~10% of pool). A cool page touched again is re-swizzled cheaply (second chance); reach the FIFO’s end and you’re evicted. Randomness replaces bookkeeping.
3. Pointer swizzling (LeanStore) in one diagram
swip = one u64 in the PARENT node (bit 63: evicted, bit 62: cool)
┌─────────────────────────────────────────────────────────┐
│ HOT 00…pointer… direct BufferFrame* — deref it │
│ COOL 01…pointer… frame in cooling FIFO — CAS back │
│ to HOT, done (no I/O, no map) │
│ EVICTED 10…page id… page fault: alloc frame, read, │
│ swizzle pointer │
└─────────────────────────────────────────────────────────┘
Consequence: a page can only be referenced by ONE parent (else two swips to re-swizzle) — fine for B-trees, awkward for arbitrary graphs. Worth pondering for the capstone: matrix blocks form a tree of tiles, so swizzling applies.
4. Why mmap is (usually) wrong — the CIDR ’22 checklist
- No write ordering — the kernel flushes dirty pages whenever; WAL’s
“log before page” needs
msyncgymnastics or is simply unenforceable. - TLB shootdowns — evicting a page means IPIs to every core that might cache the mapping; scales worse with more cores.
- Page-fault stalls — a fault blocks the thread; no async I/O, no admission control, no prefetch you control.
- Error handling — I/O errors arrive as SIGBUS mid-instruction.
But: LMDB (topic 3) ships on mmap happily — read-mostly, single-writer, COW keeps ordering trivial. The paper’s “usually” is doing real work. vmcache (SIGMOD ’23) is the synthesis: virtual memory assisted — mmap the address space, but the DB keeps explicit control of residency and eviction.
5. redis: the other memory management
No pages — redis manages allocations. zmalloc wraps jemalloc with
per-thread cache-line-aligned used_memory counters (the maxmemory
enforcement input), and active defrag literally re-allocates values whose
jemalloc bins are underutilized and updates every pointer. FalkorDB’s
matrices live inside this world: GraphBLAS blocks are zmalloc’d, counted
against maxmemory, and opaque to redis defrag.
6. Code reading (5–7 h)
- postgres
bufmgr.c+freelist.c— packed atomic state, CLOCK, buffer rings. →reading-postgres-bufmgr.md— postgres bufmgr: a buffer’s life in one atomic word - DuckDB buffer manager — eviction queue with dead nodes, memory
reservations, spill-to-temp. →
reading-duckdb-buffer.md— DuckDB’s buffer pool: eviction by queue of hints - LeanStore — swips, cooling stage, hybrid latches.
→
reading-leanstore.md— LeanStore in code: swips, cooling, hybrid latches - redis
zmalloc.c(+ turso’s CLOCK page cache as a bonus). →reading-redis-zmalloc.md— zmalloc: memory management when there are no pages
7. Papers (4–6 h)
- “Are You Sure You Want to Use MMAP in Your DBMS?” (CIDR ’22).
→
reading-mmap-paper.md— mmap is not a buffer pool - “LeanStore: In-Memory Data Management Beyond Main Memory” (ICDE ’18) +
vmcache (SIGMOD ’23) as the sequel. →
reading-leanstore-paper.md— LeanStore & vmcache: pay only on the miss
8. Experiments (in experiments/)
src/buffer_pool.rs— CLOCK buffer pool: fixed frame array,page_id → framemap, pin/unpin with usage counts, dirty-page write-back on eviction. Tests fix the contract (pinned pages never evicted, dirty pages written before reuse, capacity respected).src/bin/pool_vs_mmap.rs— same random-read workload over a file 4× larger than the pool/RAM budget: your pool vs mmap. HdrHistogram — compare p50 AND p99.9 (the mmap story is in the tail).benches/eviction.rs— CLOCK vs strict-LRU (linked list) vs FIFO on Zipf-skewed access: hit rate AND ns/lookup. Shows why nobody ships strict LRU (per-hit list surgery costs more than the hit-rate gain).
9. Capstone milestone M6 (in ../../capstone/)
- Buffer pool under the persistent backends — graphs larger than RAM.
- Decide: per-backend pools or one shared pool with MemoryTag-style accounting (DuckDB)? Write the tradeoff down.
- Reproduce mmap write-back unpredictability once, on your Mac, with numbers in notes.
Done when
Your pool beats mmap at p99.9 on the larger-than-RAM benchmark (or you can explain the exact kernel behavior that prevented it); the eviction bench table is in notes.md; you can explain a swip and a dead node from memory.
DuckDB’s buffer pool: eviction by queue of hints
The interesting contrast with postgres: no fixed frame array, no CLOCK —
blocks are heap-allocated, tracked by shared_ptr, and eviction is a
concurrent FIFO queue of hints that are allowed to go stale. Re-pinning
never removes a queue entry; it invalidates one, and dead nodes get swept in
bulk. Mark now, collect later — the amortization move again, this time inside
the replacement policy itself.
1. BlockHandle — the unit of residency
- block_handle.hpp:
BlockState(BLOCK_LOADED/BLOCK_UNLOADED, :62–71), atomicreaderspin count (:73–87),CanUnload(:208). - A
BufferHandle(RAII) holds a pin; destruction decrements readers and the block becomes evictable. Rust translation: this is exactly a guard object — your buffer pool’sPageGuardshould work the same way.
2. The eviction queue — buffer_pool.cpp
BufferEvictionNode— :42: a weak_ptr to the block memory + thehandle_sequence_numberat enqueue time.- Unpin ⇒
BufferPool::AddToEvictionQueue— :271: bump the handle’s eviction sequence number, enqueue a fresh node; the OLD node for this block (still in the queue!) is now a dead node (:284, IncrementDeadNodes). - Eviction —
EvictBlocks/EvictBlocksInternal(:377+):IterateUnloadableBlockspops nodes; a node whose seq_num ≠ the handle’s current one is dead — skip; whose weak_ptr won’t lock — dead; elseUnload(:38 in that loop) frees the memory. - Cleanup is amortized:
PurgeIteration(:104 hpp) runs everyINSERT_INTERVAL = 4096insertions (:116) and bulk-removes dead nodes.
re-pin doesn't REMOVE the queue entry (that needs a lock or O(n) search);
it INVALIDATES it with a seq bump and re-enqueues later.
→ same amortization move as topic 2's incremental rehash and topic 4's
tombstones: mark now, collect in bulk later.
The eviction loop is mostly corpse-skipping:
#![allow(unused)]
fn main() {
fn evict_until(&self, needed: usize) -> bool {
let mut freed = 0;
while freed < needed {
let Some(node) = self.queue.pop() else { return false };
let Some(block) = node.block.upgrade() else { continue }; // weak_ptr: block
// already gone
if node.seq != block.eviction_seq.load() { continue; } // DEAD: re-pinned
// since enqueue
if !block.can_unload() { continue; } // pinned right now
freed += block.unload(); // write to temp file if no disk home
}
true
}
}
3. Memory reservations — standard_buffer_manager.cpp
EvictBlocksOrThrow— :126: every allocation first evicts until the reservation fits, else throws “could not allocate block of size…” (:155). Memory accounting is a gate in front of malloc, not an after-the-fact counter — compare redis, which counts after and evicts keys asynchronously.Pin— :333/:337: loaded ⇒ readers++; unloaded ⇒ reserve memory (evicting), reload from disk or temp file.- Multiple queues by buffer type — buffer_pool.hpp:116–122
(
EVICTION_QUEUE_TYPES, priority order): managed buffers vs external files don’t compete in one queue.
4. Spilling — WriteTemporaryBuffer, standard_buffer_manager.cpp:501
Evicted temporary data (hash tables, sorts — no disk home) goes to the temp file manager (:508). This is why DuckDB joins bigger than RAM work: the buffer pool doubles as the spill mechanism. Postgres spills per-operator (work_mem) instead — two philosophies of the same fallback.
Questions to answer in notes.md
- Why weak_ptr in the queue node? What breaks with shared_ptr? (Queue would keep every block alive — the cache becomes a leak.)
- Dead-node ratio: worst-case queue length for a workload that re-pins the same block N times between purges. When is CLOCK’s fixed array strictly better?
- DuckDB throws on memory pressure; postgres errors only when all buffers are pinned. Trace where each behavior comes from and which your capstone pool should adopt (server vs embedded assumptions).
Done when
You can explain a dead node, the 4096-insert purge cadence, and why re-pin never touches the queue — and name the postgres structure each replaces.
References
Code
- duckdb/duckdb —
src/storage/buffer/buffer_pool.cpp,src/storage/standard_buffer_manager.cpp,src/include/duckdb/storage/buffer/buffer_pool.hpp,src/include/duckdb/storage/buffer/block_handle.hpp. Local clone at~/repos/duckdb.
LeanStore & vmcache: pay only on the miss
Two papers, one arc: how to make a buffer-managed system as fast as an in-memory one. LeanStore (ICDE ’18) eliminates the per-access costs with pointer swizzling, a cooling stage, and optimistic latches; vmcache (SIGMOD ’23), from the same group, is “what we’d do differently five years later” — same goal, mechanism moved into the MMU. Read LeanStore first, then vmcache as the retraction-and-fix.
LeanStore: the problem statement (§I–II)
A traditional buffer pool costs a hash lookup + latch + pin per page access even when everything is in RAM. In-memory systems (HyPer) skip all of it. LeanStore’s goal: pay for translation and replacement only on misses — the hot path should look like an in-memory system.
Three ingredients:
1. pointer swizzling translation cost → 0 (parent holds raw pointer)
2. cooling stage replacement cost → 0 (no per-access bookkeeping;
random candidates + second-chance FIFO)
3. optimistic latches pinning cost → 0 (readers validate versions,
hold nothing)
You’ve read the code (reading-leanstore.md) — in the paper focus on:
- §III.B: the one-swip-per-page ownership rule and why eviction is bottom-up.
- §III.D: cooling-stage sizing (the 10% heuristic) and the hit/second-chance probabilities — Fig. 6 shows random+FIFO tracks LRU closely on Zipf.
- §V: evaluation — in-memory TPC-C at parity with a no-buffer-manager build; the graceful degradation curve as data exceeds RAM (the money plot).
vmcache: the retraction and the fix
Swizzling works but infects the whole codebase: every data structure must know swips, honor one-parent, cooperate with cooling. vmcache keeps the goal and drops the mechanism:
- mmap an anonymous (or exmap) virtual range:
page(pid)is justvirt + pid * 4096— the MMU is the translation layer, for free. - BUT the DB — not the kernel — decides residency: explicit per-page state
word (Evicted/Marked/Locked/Unlocked + version counter), explicit reads
into the fixed virtual address,
madvise(DONTNEED)on evict. - The page-state word doubles as the hybrid latch (optimistic version).
- Any page can have any number of references — the one-parent rule dies; arbitrary graphs are fine (relevant to a graph-store capstone!).
- The exmap kernel module fixes the syscall/TLB costs of madvise-heavy eviction; without it, plain vmcache still beats classic pools.
The whole design fits in one state machine:
#![allow(unused)]
fn main() {
// Translation is the MMU's job; RESIDENCY is the DB's.
fn page(&self, pid: u64) -> *mut u8 { unsafe { self.virt.add(pid as usize * 4096) } }
fn fix(&self, pid: u64) {
loop {
let s = self.state[pid].load(); // Evicted/Marked/Locked/Unlocked + version
match s.kind() {
Evicted => if self.state[pid].cas(s, s.locked()) {
pread(self.fd, self.page(pid), 4096, pid * 4096); // into the FIXED addr
self.state[pid].store(s.unlocked_bumped()); // word doubles as
return; // the hybrid latch
},
Marked | Unlocked => if self.state[pid].cas(s, s.locked()) { return; },
Locked => core::hint::spin_loop(), // someone else is faulting it in
}
}
}
// evict: write back if dirty, madvise(DONTNEED, page(pid)), state → Evicted
}
LeanStore: translation in POINTERS (swips, invasive, tree-shaped data)
vmcache: translation in the MMU (virt addressing, any ref-graph)
both: replacement + residency decided by the DB, never the kernel
The CIDR ’22 mmap paper (reading-mmap-paper.md) is the missing middle: mmap with kernel-controlled residency is the trap; vmcache is mmap-style addressing with DB-controlled residency.
Questions to answer in notes.md
- Reproduce LeanStore Fig. 1’s argument as arithmetic: hash probe (topic-0 DRAM numbers) + latch CAS per access, × accesses per TPC-C txn — what fraction of in-memory runtime is the classic pool?
- Why does the cooling stage need to be a FIFO and not a stack? (Second chance requires time between cool and evict.)
- vmcache’s page-state word vs postgres’s packed buffer state (buf_internals.h) — same bits, different home. What does colocating state-with-translation (vmcache) buy over a separate descriptor array?
- For the capstone: GraphBLAS matrix tiles referenced by row and column indexes = a DAG, not a tree. Which of the two designs is even admissible, and what would the swizzling workaround cost?
Done when
You can state what each of the three LeanStore ingredients eliminates, and explain in two sentences why vmcache can drop swizzling without giving back the hot-path win.
References
Papers
- Leis, Haubenschild, Kemper, Neumann — “LeanStore: In-Memory Data Management Beyond Main Memory” (ICDE 2018) — focus on §III.B (one swip per page, bottom-up eviction), §III.D (cooling-stage sizing, Fig. 6), §V (the graceful-degradation money plot)
- Leis, Alhomssi, Ziegler, Loeck, Dietrich — “Virtual-Memory Assisted Buffer Management” (vmcache/exmap, SIGMOD 2023) — read after LeanStore, as the retraction and the fix
LeanStore in code: swips, cooling, hybrid latches
The paper claims a hot page access can cost zero atomics; this chapter walks the classic ICDE ’18 codebase to see how — a u64 that is either a pointer or a page id, a background thread that cools random frames, and latches whose readers hold nothing. Read the paper guide (reading-leanstore-paper.md) first for the why; this is the how.
1. Swip — Swip.hpp:17–67
One u64 that is EITHER a pointer or a page id:
evicted_bit = 1<<63,cool_bit = 1<<62(:21–26).isHOT()(:45) — both bits clear ⇒ it’s a rawBufferFrame*.isCOOL()(:46),isEVICTED()(:47);warm()clears the cool bit (:62),cool()sets it (:65),evict(pid)stores a page id + bit 63 (:67).
The buffer pool’s mapping table is distributed into the parent nodes: no hash lookup, no partition lock, on any hot access. The price: exactly one swip may reference a page (else un/re-swizzling can’t find all pointers).
2. resolveSwip — BufferManager.cpp:281–330
isHOT (:283) → return the pointed-to frame. Done. ~0 overhead.
isCOOL (:287) → frame exists but sits in the cooling FIFO:
latch parent, clear cool bit (second chance), return.
EVICTED → page fault: grab free frame, readPageSync (:317),
swizzle the swip, return.
The same three arms, as code:
#![allow(unused)]
fn main() {
// The hot path is a pointer dereference — nothing else.
fn resolve(&self, parent: &HybridGuard, swip: &mut Swip) -> &BufferFrame {
if swip.is_hot() { return swip.frame(); } // raw pointer: ~0 overhead
if swip.is_cool() {
parent.upgrade_exclusive(); // touched while cooling ⇒
swip.warm(); // second chance: clear the
return swip.frame(); // bit, dodge the FIFO
}
let frame = self.free_frames.pop(); // EVICTED ⇒ page fault:
self.read_page_sync(swip.pid(), frame); // the ONLY case that pays
swip.swizzle(frame); // pid → pointer, in place —
frame // next access is hot
}
}
Note the latching order comment — BufferManager.hpp:67–68: swizzle vs coolPage acquire latches in conflicting order; the fix is jump-and-retry (optimistic abort) instead of blocking. Deadlock avoidance by restart — the same philosophy as optimistic latches below.
3. The cooling stage — PageProviderThread.cpp
Background thread keeps ~10% of frames “cool”:
- Pick a random buffer frame (:44) — no LRU bookkeeping at all.
- Phase 1 (:52): unswizzle it — but only if all its children are evicted
(:90–91,
iterateChildrenSwips): evict leaves before parents, bottom-up. - Cool frames enter a per-partition FIFO (Partition.hpp:65+). Touched while cool ⇒ resolveSwip warms it (cheap save). Reaches FIFO head ⇒ written back if dirty (AsyncWriteBuffer) and evicted.
Random + second-chance approximates LRU with zero per-access cost — compare postgres (per-access usage bump) and DuckDB (per-unpin enqueue). LeanStore pays nothing per access; that’s the whole point of the paper.
4. Hybrid latches — Latch.hpp
HybridLatch— :26–41: a version word;LATCH_EXCLUSIVE_BITin the low bit (:41).Guard— :51+: OPTIMISTIC state reads the version, proceeds without writing anything, revalidates at the end; version changed ⇒ jump (longjmp -style unwind) and retry. Writers CAS the version odd.BufferFrame— BufferFrame.hpp:18–99:latchsits in the header (:27, “NEVER DECREMENT” — versions only grow);isDirty()=page.PLSN != last_written_plsn(:84) — dirtiness derived from LSNs, not a flag. Nice WAL-integration detail for your M6.
This is topic 9’s main subject making an early appearance — for now, note that optimistic readers are what make swizzling safe: a reader holding no pin can’t block eviction, it just fails validation and retries.
Questions to answer in notes.md
- The one-parent constraint: why exactly does swizzling forbid two swips to the same page? Walk the eviction of a doubly-referenced page. Then decide: do FalkorDB’s tensor/matrix blocks form a tree or a DAG?
- Bottom-up eviction (children before parents): what breaks top-down? (An evicted parent’s swip can’t hold a hot child’s pointer — the child would be unreachable.)
- Random candidate selection: estimate hit-rate loss vs true LRU on a Zipf workload (then measure — experiments/benches/eviction.rs has a FIFO arm you can extend with random-cooling).
- vmcache (SIGMOD ’23) removes swizzling — pages live at
virt[pid], the mapping is the MMU’s problem, explicit state machine per page. What of LeanStore survives in it? (Cooling idea stays; swips go; one-parent constraint gone — that’s the headline win.)
Done when
You can draw the swip state machine (HOT/COOL/EVICTED with transitions and who performs each) and explain why a hot hit costs zero atomics.
References
Code
- leanstore/leanstore (the
classic ICDE ’18 codebase) —
backend/leanstore/storage/buffer-manager/:Swip.hpp,BufferManager.cpp,BufferFrame.hpp,PageProviderThread.cpp,Partition.hpp; latches inbackend/leanstore/sync-primitives/Latch.hpp. Local clone at~/repos/leanstore.
mmap is not a buffer pool
mmap looks like a free buffer pool, and a famous position paper says that for a general-purpose write-heavy DBMS every apparent win reverses. It is short, punchy, and deliberately provocative — so read it adversarially, then construct the counter-evidence yourself (LMDB exists and is excellent). The payoff is knowing precisely which property of a workload makes mmap wrong.
The temptation (§1–2)
mmap looks like a free buffer pool: no copies, no eviction code, pointer access, the kernel’s page cache does the work. Systems that tried: MongoDB (MMAPv1 — abandoned), LMDB (kept it, happily), SQLite (optional), RavenDB… The paper’s claim: for a general-purpose write-heavy DBMS, every apparent win reverses.
The four problems (§3) — memorize these
1. Transactional safety kernel may flush a dirty page ANY time
──────────────────── ⇒ can't order page-write after log-write
⇒ WAL rule unenforceable without COW tricks
2. I/O stalls page fault = your thread stops; no async,
──────────────────── no prefetch you control, no admission control
3. Error handling disk error = SIGBUS in the middle of a memcpy,
──────────────────── not an error code at a syscall boundary
4. Performance (§4) the surprise: even READ-ONLY mmap loses at scale
§4 — why read-only mmap still loses (the part worth re-reading)
Three kernel bottlenecks, measured:
- page table contention — single-threaded page-fault handling paths.
- TLB shootdowns — evicting a mapping ⇒ IPI every core that may have the TLB entry: eviction cost scales with core count.
- 4KB granularity + page-table walk overhead vs one big explicit read.
Result (their fio experiment): explicit pread/O_DIRECT sustains device
bandwidth; mmap plateaus far below on NVMe arrays and degrades over time
once eviction starts.
The rebuttal you must construct (LMDB, topic 3)
LMDB is mmap-based and wins its niche. Why it dodges each bullet: COW pages never overwrite (1: ordering is a non-problem — the meta-page flip IS the commit); read-mostly workloads fault once, then it’s just memory (2); a read-only mmap can’t SIGBUS on writes (3); and its scale target is “fits mostly in RAM” (4). The paper’s own Table 1 concedes designs like this. The honest conclusion: mmap is wrong when the DB must control WRITE-BACK. Read-only/COW designs escape most of it.
Map to what you know
| System | Uses | Escapes the trap because |
|---|---|---|
| LMDB | mmap everything | COW + read-mostly + single writer |
| SQLite | optional mmap for reads | WAL still explicit; mmap read-only |
| postgres | no mmap; shared_buffers | needs write ordering (FPIs, ckpts) |
| LeanStore/vmcache | anonymous mem / virt mapping | explicit residency control |
Questions to answer in notes.md
- Your topic-3 B+tree used explicit I/O. If you’d mmap’d it, which of your
topic-5 WAL guarantees break, concretely? (Which test in
crash_test.rswould start failing and why.) - TLB shootdowns: why does eviction trigger them but faulting-in not?
- The paper measures read-only workloads losing. Reconcile with LMDB’s read benchmarks winning — what’s different in the setups (working set vs RAM, single NVMe vs array, pointer-chase vs scan)?
- vmcache’s answer: keep virtual-memory addressing, add explicit state. Which of the four problems does it solve, which does it merely soften?
Done when
You can argue both sides for five minutes each — “never mmap” and “LMDB is right” — and state precisely which property of your workload picks the side.
References
Papers
- Crotty, Leis, Pavlo — “Are You Sure You Want to Use MMAP in Your DBMS?” (CIDR 2022) — short position paper; memorize the four problems of §3, re-read §4 (why even read-only mmap loses at scale)
postgres bufmgr: a buffer’s life in one atomic word
Postgres packs everything CLOCK needs to know about a buffer — refcount, usage count, flags — into a single atomic u64, so the hit path is one CAS and the sweep hand reads victims without locks. This chapter skims the four mechanisms that make the classic shared-buffers design work: the packed state, the hit path, the miss path with foreground dirty-victim flushes, and the background writer that exists to hide them.
1. The packed state — buf_internals.h:49–147
Everything about a buffer fits in ONE atomic u64 (BufferDesc.state, :344):
┌──────────── 64-bit state ────────────┐
│ lock bits │ flags │ usage(4) │ refcount(18) │
└───────────────────────────────────────┘
BUF_REFCOUNT_BITS 18 (:49) BUF_USAGECOUNT_BITS 4 (:50)
BM_MAX_USAGE_COUNT 5 (:144) — CLOCK survives ≤5 sweeps
Why packed: pin/unpin/usage-bump are single CAS ops — no spinlock on the hit path. Same trick as topic-2’s SwissTable metadata byte: cram the hot-path-decidable state into one word.
2. The hit path — PinBuffer, bufmgr.c:3295
CAS loop on state: refcount+1, usage_count+1 if < BM_MAX_USAGE_COUNT
(:3338–3352). Lookup before that: BufTableLookup under one of
NUM_BUFFER_PARTITIONS = 128 partition locks (lwlock.h:83,
buf_internals.h:244–250) — the map is sharded so lookups don’t serialize.
Read PinBufferForBlock (:1223) → ReadBuffer_common (:1276) →
StartReadBuffersImpl (:1371) for how reads became a vectored/async
ReadBuffersOperation (v17+) — the miss path now streams.
3. The miss path — BufferAlloc + GetVictimBuffer
BufferAlloc— bufmgr.c:2197: lookup (:2224), and on miss callGetVictimBuffer(:2548).GetVictimBuffer:StrategyGetBufferpicks a candidate; if it’s dirty, the backend writes it out itself —FlushBufferright there in the foreground (:2584 onward). Every eviction of a dirty page is a user-visible latency spike; this is what BgBufferSync exists to prevent.- Note the WAL-rule cameo:
XLogNeedsFlush(BufferGetLSN(...))before a ring buffer flush (~:2633) — can’t write a page whose log isn’t durable.
4. CLOCK — freelist.c
StrategyControl->nextVictimBuffer— :42, one atomic for the whole pool.ClockSweepTick— :104–160:fetch_add(1) % NBuffers, with a CAS-based modular wraparound so the counter never overflows mid-flight.StrategyGetBuffer— :184: loop at :246–290 — pinned (refcount ≠ 0) ⇒ skip; usage_count > 0 ⇒ decrement, keep sweeping; both zero ⇒ victim. “no unpinned buffers available” error if everything’s pinned (~:274).
The sweep, distilled:
#![allow(unused)]
fn main() {
// One shared clock hand; a buffer survives ≤5 sweeps untouched.
fn get_victim(&self) -> BufId {
loop {
let id = self.clock_tick(); // fetch_add(1) % NBuffers
let s = self.desc[id].state.load();
if s.refcount() != 0 { continue; } // pinned: invisible to CLOCK
if s.usage_count() > 0 { // recently used: spend a life
let _ = self.desc[id].state.cas(s, s.dec_usage());
continue;
}
if self.desc[id].state.cas(s, s.pinned()) { // both zero ⇒ victim; pin it
return id; // caller flushes it if dirty —
} // in the FOREGROUND
}
}
}
- Buffer rings —
GetAccessStrategy:426: a seqscan gets a 256KB private ring (BAS_BULKREAD, :442–459) so oneSELECT count(*)on a huge table can’t flush the whole pool. Eviction policy as admission policy.
5. Background writer — BgBufferSync, bufmgr.c:3854
Runs the same clock ahead of the sweep hand, writing dirty buffers so
GetVictimBuffer finds clean ones. Pace: bgwriter_lru_maxpages (:190,
default 100 pages/round) + a moving average of recent allocation rate
(:3877–3911). It’s an estimator — read the long comment.
Questions to answer in notes.md
- Why 18 bits of refcount but only 4 of usage count? What failure does each cap produce and which is graceful? (usage saturates harmlessly; refcount overflow would be corruption — hence StaticAssert vs MAX_BACKENDS :130.)
- A client pins a page and crashes mid-query — who unpins? (Resource owner machinery: ReservePrivateRefCountEntry in GetVictimBuffer :2559.)
- Buffer rings vs LeanStore’s cooling stage: both defend against scans. Which defends at admission and which at eviction? What does each miss?
- Postgres double-buffers (shared_buffers + OS page cache). What does
O_DIRECT(topic 6’s io story, debug_io_direct) buy and cost here?
Done when
You can narrate a miss on a dirty victim end-to-end — every lock, atomic, and I/O in order — and say which step BgBufferSync moves off the hot path.
References
Code
- postgres/postgres —
src/backend/storage/buffer/bufmgr.c,src/backend/storage/buffer/freelist.c,src/include/storage/buf_internals.h,src/include/storage/lwlock.h. Local clone at~/repos/postgres.
zmalloc: memory management when there are no pages
Redis has no buffer pool — no pages, no frames, no eviction hand. What it has
instead is an allocation ledger: every malloc accounted on per-thread
padded counters, maxmemory enforced against an allocator statistic, and
key-level eviction after the fact. This chapter reads that ledger, plus a
bonus: turso’s CLOCK page cache in Rust, the closest existing code to your
experiment.
1. zmalloc — allocation accounting, not caching
PREFIX_SIZE— zmalloc.c:39–46: with jemalloc (HAVE_MALLOC_SIZE) the allocator can report a pointer’s size ⇒ prefix is 0; with libc malloc, redis prepends an 8-byte size header to every allocation. The entire used-memory ledger depends on being able to answer “how big is this ptr?”used_memory— :86–92: per-thread, cache-line-aligned counters (aligned(CACHE_LINE_SIZE), MAX_THREADS array) — summed on read. False sharing on a global counter would tax every malloc on every thread; same diagnosis as topic 0’s cache-line experiments.update_zmalloc_stat_alloc— :105–145: bump my thread’s counter, and only occasionally (peak-check throttle :109–118) pay for the full sum.zmalloc— :161–193:malloc_usable_sizepath vs prefix path.
The ledger, in miniature:
#![allow(unused)]
fn main() {
// One counter per thread, each on its own cache line — a single global
// fetch_add would put coherence traffic on EVERY malloc on EVERY core.
#[repr(align(64))]
struct Padded(AtomicI64);
static USED: [Padded; MAX_THREADS] = /* … */;
fn zmalloc(size: usize) -> *mut u8 {
let p = unsafe { malloc(size) };
let real = malloc_usable_size(p); // jemalloc answers "how big is p?"
USED[thread_id()].0.fetch_add(real as i64, Relaxed); // uncontended bump
p
}
fn used_memory() -> i64 {
USED.iter().map(|c| c.0.load(Relaxed)).sum() // the SUM is paid on read,
} // and reads are rare
}
This ledger is what maxmemory compares against — eviction (LRU/LFU over
keys, not pages) triggers on an allocator statistic. The buffer-pool
analogue: DuckDB gates allocations up front; redis counts and evicts after.
2. Active defrag — defrag.c
activeDefragAlloc— defrag.c:177 (+ :142 comment): jemalloc tells redis which allocations sit in sparse bins; redis re-allocates them (new ptr, same bytes) and rewrites every reference. Defragmentation in userspace, cooperatively, because the allocator can’t move memory it handed out.- FalkorDB angle: GraphBLAS matrices are big opaque zmalloc blobs — redis can count them but not defrag them, and one matrix can blow the maxmemory budget in a single GrB call. Your capstone owns its allocations; decide what “maxmemory” should even mean for a graph store.
3. Bonus: turso’s page cache — ~/repos/turso core/storage/page_cache.rs
A real Rust CLOCK implementation to compare with your experiment after you build it (don’t copy first):
PageCache— :99–116: intrusive circular list +clock_handraw pointer (:107); comment :95–98 states the discipline (insert behind the hand).advance_clock_hand— :174;insert— :204.- Note what’s unsafe (
Send/Syncimpls :115–116, raw pointers) and what your Rust version can do differently with indices into aVec<Frame>instead of pointers (safe, and the array is exactly postgres’s layout).
Questions to answer in notes.md
- Why per-thread counters instead of one atomic? Estimate the cost of a
shared
fetch_addon every malloc at 8 threads (topic-0 numbers). - Redis evicts keys; a buffer pool evicts pages. Which gets better hit rates for the same RAM and why is the comparison unfair? (Keys are variable-size and complete — no partial residency of a value.)
- After building your CLOCK pool: diff your design against turso’s — hand placement on insert, where usage bits live, pin representation.
Done when
You can explain PREFIX_SIZE, why the counters are padded, and what active defrag can’t touch — and you’ve compared your finished pool to turso’s.
References
Code
- redis —
src/zmalloc.c(the ledger) andsrc/defrag.c(cooperative userspace defragmentation). Local clone at~/repos/redis. - tursodatabase/turso —
core/storage/page_cache.rs, a real Rust CLOCK to diff against your experiment after you build it (don’t copy first). Local clone at~/repos/turso.
Topic 6 notes — buffer pool & memory management
Predictions (fill BEFORE running)
- pool_vs_mmap p50: mmap ___ ns vs pool ___ ns (who wins the median and why?)
- pool_vs_mmap p99.9: mmap ___ vs pool ___ (who wins the tail?)
- CLOCK hit rate vs strict LRU on Zipf(0.99), 16× universe: gap of ___ %
- strict LRU ns/access vs CLOCK: ___× slower
pool_vs_mmap results
(paste; run warm and note whether the file fit the OS page cache)
| p50 | p99 | p99.9 | max | |
|---|---|---|---|---|
| mmap | ||||
| pool (CLOCK) |
Pool hit rate: ___ %. Explanation of the tail difference:
eviction bench results
| policy | hit rate | ns/access |
|---|---|---|
| CLOCK | ||
| strict LRU | ||
| FIFO |
Verdict — is strict LRU’s hit-rate edge worth its per-hit cost here?
Buffer pool build log (src/buffer_pool.rs)
- Where the WAL rule hooks in (which write, which LSN check):
- What a background writer would take off the miss path:
- What a 6-page scan did to usage counts (hot_page_survives_scan_pressure):
Reading-guide questions
postgres bufmgr (reading-postgres-bufmgr.md)
- 18 refcount bits vs 4 usage bits — which cap fails gracefully:
- Who unpins after a crashed query:
- Buffer rings (admission) vs cooling stage (eviction) — what each misses:
- What O_DIRECT buys/costs given double buffering:
DuckDB buffer manager (reading-duckdb-buffer.md)
- Why weak_ptr in eviction nodes:
- Worst-case dead-node ratio; when CLOCK’s fixed array is strictly better:
- Throw-on-pressure vs error-when-all-pinned — capstone choice:
LeanStore (reading-leanstore.md)
- One-parent constraint walkthrough; are FalkorDB matrix blocks a tree or DAG:
- Why eviction must be bottom-up:
- Random-cooling hit-rate loss vs LRU (estimate, then measure):
- What survives in vmcache, what dies:
mmap paper (reading-mmap-paper.md)
- Which topic-5 crash_test would fail under mmap and why:
- Why eviction (not fault-in) triggers TLB shootdowns:
- Reconciling the paper with LMDB’s wins:
- vmcache vs the four problems — solved vs softened:
LeanStore/vmcache papers (reading-leanstore-paper.md)
- Classic-pool overhead as arithmetic (topic-0 numbers):
- Why cooling is a FIFO, not a stack:
- vmcache state word vs postgres packed state — what colocation buys:
- Which design is admissible for matrix-tile DAGs:
redis zmalloc (reading-redis-zmalloc.md)
- Cost of a shared fetch_add per malloc at 8 threads:
- Key eviction vs page eviction — why the comparison is unfair:
- My pool vs turso’s page_cache.rs — three design diffs:
M6 log (capstone)
- Buffer pool under the persistent backends
- Per-backend pools vs shared pool + MemoryTag accounting — decision + why:
- mmap write-back unpredictability reproduced (numbers):
Topic 7 — Networking, Protocols & Event Loops
Redis’s speed is as much about
ae.cand RESP as about data structures. You know FalkorDB’s module side — this topic is about owning the server side: one loop, many sockets, and a protocol designed to be parsed withmemchr.
Outcomes
By the end you can:
- Parse and generate RESP2/RESP3 from memory, and explain each design choice (length prefixes, CRLF, type-first bytes) in parser terms.
- Narrate one full redis event-loop iteration: poll → read → parse → execute → queue reply → write, and say what beforeSleep does.
- Explain the three threading models (single loop, io-threads, thread-per- core) and what each serializes.
- Ship a tokio RESP server that survives
redis-benchmark, and read its flamegraph.
1. RESP: a protocol optimized for the parser
client: *2\r\n $3\r\n GET\r\n $3\r\n foo\r\n
│ │ └ bulk string, length-prefixed: read(3)
│ └ each arg: $<len> — NO scanning for terminators
└ array header: argc up front — allocate argv once
server: $3\r\n bar\r\n +OK\r\n :42\r\n -ERR msg\r\n
bulk simple integer error
Why it’s fast to parse:
- Length prefixes everywhere — the parser never scans payload bytes; it
reads a small integer, then
memcpys exactly that many. Binary-safe for free. (Contrast: HTTP/1 header parsing scans for\r\n\r\n.) - argc first —
*Nlets the server sizeargv[]before reading args. - First byte = type — a one-byte dispatch, no lookahead.
- RESP3 adds typed replies (maps
%, sets~, doubles,, push>) so clients stop guessing structure from context — same wire discipline.
Inline commands (PING\r\n) exist purely so you can debug with nc.
2. The event loop
flowchart TB
A["aeMain: while !stop"] --> B["beforeSleep():<br/>flush AOF, handle<br/>PENDING WRITES first"]
B --> C["aeApiPoll (kqueue/epoll)<br/>wait for readable/writable"]
C --> D["for each ready fd:<br/>readQueryFromClient"]
D --> E["parse RESP →<br/>execute command →<br/>addReply to OUTPUT BUFFER"]
E --> B
The two non-obvious moves:
- Replies are buffered, not written —
addReplyappends to a per-client buffer and the next beforeSleep writes everything with onewrite()per client (handleClientsWithPendingWrites). Batching by loop iteration. - Pipelining falls out for free — the input buffer may hold 100 commands;
processInputBufferloops until the buffer is drained, and all 100 replies coalesce into one write. This is whyredis-benchmark -P 64is ~10× -P 1: same work, 1/64th the syscalls.
3. Three threading models, one question: what’s serialized?
| Model | Command execution | I/O + parsing | Example |
|---|---|---|---|
| single loop | serial | serial, same thread | redis ≤5, our M7 v1 |
| io-threads | serial (main thread) | parallel | redis 6+, valkey 8 (rewritten) |
| thread-per-core | parallel (keyspace sharded) | parallel, no cross-core locks | DragonflyDB, ScyllaDB, Glauber’s essays |
io-threads keep redis’s contract (commands are atomic, no locks in data structures) and parallelize only the syscall+parse layer — valkey 8’s rework made the main thread hand batches over SPSC queues and prefetch dict entries before executing (memory stalls, topic-0 style, hidden by batching). Thread-per-core abandons the shared keyspace instead: hash-partition keys to cores, cross-slot ops become messages. FalkorDB inherits redis’s model: one graph = one keyspace entry ⇒ module-level locking is the concurrency story.
4. Backpressure — the part everyone forgets
- Input side:
PROTO_IOBUF_LEN16KB reads (server.h:188), max query buffer size; a client streaming faster than execution growsquerybuf→ kill. - Output side:
PROTO_REPLY_CHUNK_BYTES16KB chunks (server.h:189); a slow reader (or aKEYS *on 10M keys) grows the reply list → client-output-buffer-limit → kill. A database is a flow-control device: a graph query returning 1M rows must either stream with backpressure (pgwire’s row-at-a-time) or buffer-and-die (redis’s approach). - pgwire contrast: postgres streams
DataRowmessages inside a Simple/Extended Query dance with per-portal row limits — the protocol itself has backpressure hooks RESP lacks.
5. Bolt: the third answer (RESP vs pgwire vs Bolt)
Three protocols, three answers to the same three questions:
| framing | typing | streaming | |
|---|---|---|---|
| RESP | length-prefixed text markers | strings + ints (client re-parses) | none — full reply or die |
| pgwire | typed binary messages | per-column OIDs, text/binary | portal row limits (pull-ish) |
| Bolt | chunked messages of PackStream structs | full type system incl. Node/Relationship/Path on the wire | explicit pull: client sends PULL {n} / DISCARD |
Bolt is what a protocol looks like when the data model lives in the
protocol: PackStream has markers for maps, lists — and graph types
(Node 0x4E, Relationship 0x52, Path 0x50), so a driver hands you a graph
object, not a string table. And streaming is client-driven: after RUN,
records flow only when the client asks (PULL {n:1000}) — backpressure
designed in, not bolted on (section 4’s problem, solved at the protocol
layer). Versioned handshake: 4 bytes magic 0x6060B017 + four proposed
versions; the server picks. →
reading-bolt-packstream.md — Bolt &
PackStream: the graph in the type system
6. Code reading (5–7 h)
- redis
ae.c+networking.c— the loop, the parse path, pending writes. →reading-redis-ae-networking.md— The redis event loop: pipelining for free - valkey io-threads rework — SPSC job queues, command-batch prefetch.
→
reading-valkey-iothreads.md— valkey io-threads: parallelize the majority, nothing else - pgwire (Rust) + qdrant’s tonic setup — what a protocol crate looks
like; gRPC as the anti-RESP. →
reading-pgwire-qdrant.md— pgwire & tonic: sessions, portals, and protocols you don’t write - FalkorDB’s removed Bolt server —
git show 0b11a00b3^:src/bolt/(it was deleted in #2170; the tree one commit back is a complete, compact Bolt 5.x implementation). →reading-bolt-packstream.md— Bolt & PackStream: the graph in the type system
7. Reading (2–3 h)
- “The C10K problem” (Kegel) — the historical why of event loops.
→
reading-c10k-thread-per-core.md— C10K to thread-per-core: what is a server thread for? (covers Glauber Costa’s thread-per-core essays + valkey multithreading blog posts in the same guide)
8. Experiments (in experiments/)
src/resp.rs— RESP2 parser/encoder (your build; tests fix the format incl. partial-input resumption — the hard part of any wire parser).src/bin/server.rs— tokio GET/SET/PING/DEL server over your parser- a sharded
HashMap. Compiles against your resp.rs.
- a sharded
- Bench protocol (in notes.md):
redis-benchmark -t get,set -P 1and-P 64against (a) your server, (b) real redis on this Mac. Flamegraph your server under load; name the top 3 entries.
9. Capstone milestone M7 (in ../../capstone/)
- RESP server exposing
GRAPH.QUERY/GRAPH.RO_QUERY, wire-compatible with falkordb-py (the client must not know it’s not FalkorDB). - Bench falkordb-py against yours vs real FalkorDB; document the gap.
- Decide + write down: single loop, io-threads, or thread-per-core — and what your choice serializes.
- Stretch: a Bolt listener on a second port so neo4j drivers connect — PackStream encoding of the graph result types (Node/Relationship/Path).
Done when
Your server handles redis-benchmark -P 64 without protocol errors; you can
write *3\r\n$3\r\nSET\r\n… from memory; you can explain why pipelining
multiplies throughput without touching command execution.
Bolt & PackStream: the graph in the type system
RESP encodes a node as nested arrays the client must re-interpret; Bolt puts
Node, Relationship, and Path on the wire as first-class types, and makes
result streaming client-driven — backpressure IS the protocol. The reference
implementation here is FalkorDB’s own Bolt 5.x server, complete until #2170
removed it (2026-07-08): read it frozen in time with
git show 0b11a00b3^:src/bolt/<file> in ~/repos/FalkorDB.
The shape of a Bolt session
client server
│ 0x60 0x60 0xB0 0x17 + 4 versions │ handshake: bolt_api.c:803,
├────────────────────────────────────►│ version pick :845-864
│◄──────────────── chosen version ────┤ (5.1..5.7 accepted)
│ HELLO {auth...} 0x01 │
│◄─────────────── SUCCESS 0x70 ──────┤
│ RUN "MATCH..." {} {} 0x10 │ bolt_api.c:721
│◄─────────────── SUCCESS {fields} ───┤ (query ran; no rows sent!)
│ PULL {n: 1000} 0x3F │ bolt_api.c:726
│◄─────────────── RECORD × n 0x71 ───┤ client-driven streaming:
│◄─────────────── SUCCESS {has_more}──┤ backpressure IS the protocol
│ DISCARD 0x2F │ (or: stop paying for rows)
Every message and value is one PackStream structure: a marker byte
0xB0+size (bolt.c:36), a tag byte (the BST_* enum, bolt.h:27), then
fields. The whole message vocabulary — HELLO/RUN/PULL/DISCARD, and the
data types Node 0x4E / Relationship 0x52 / Path 0x50 — lives in one
enum. RESP has no equivalent: a FalkorDB RESP reply encodes a node as
nested arrays the client library must re-interpret; Bolt puts the graph
in the type system.
PackStream in one sitting (bolt.c)
- Markers: NULL 0xC0 (bolt.c:11), tiny-string base 0x80 (:21), structure base 0xB0 (:36) — high nibble = type, low nibble = size for “tiny” variants.
bolt_reply_int(bolt.c:133) picks tiny-int/int8/16/32/64 by value — varint-by-cases, biased so -16..127 costs one byte.bolt_reply_structure(:250), lists (:198), maps (:225): everything nests; a Path is a structure of lists of Node/Relationship structures.- Compare topic 7 §1: RESP optimizes the parser (scan for \r\n); PackStream optimizes the type round-trip (marker dispatch table).
The marker scheme, as an encoder:
#![allow(unused)]
fn main() {
// High nibble = type, low nibble = size for "tiny" variants; ints are
// varint-by-cases, biased so -16..127 costs exactly one byte.
fn write_int(out: &mut Vec<u8>, v: i64) {
match v {
-16..=127 => out.push(v as u8), // tiny
_ if i8::try_from(v).is_ok() => { out.push(0xC8); out.push(v as u8); }
_ if i16::try_from(v).is_ok() => { out.push(0xC9); out.extend((v as i16).to_be_bytes()); }
_ if i32::try_from(v).is_ok() => { out.push(0xCA); out.extend((v as i32).to_be_bytes()); }
_ => { out.push(0xCB); out.extend(v.to_be_bytes()); }
}
}
fn write_struct_header(out: &mut Vec<u8>, n_fields: u8, tag: u8) {
out.push(0xB0 + n_fields); // marker: tiny structure of n fields
out.push(tag); // 0x4E Node, 0x52 Relationship, 0x50 Path, 0x10 RUN…
} // then the fields follow, each PackStream-encoded
}
Server-side mechanics worth stealing
BoltRequestHandler(bolt_api.c:670): one dispatch switch overBST_*— the protocol state machine is ~10 cases.- RUN executes the query but replies only metadata (:467-482); records flow in the PULL handler (:504-521) — result materialization and result transport are decoupled server-side too.
ws_handshake(bolt_api.c:831): the same port sniffs and upgrades WebSocket — that’s how browser clients speak Bolt.- It’s all inside a Redis module: the Bolt socket bypasses RESP entirely and injects work into the same executor — two protocols, one engine.
Questions
- RUN/PULL splits “execute” from “fetch”. What does the server have to hold between the two, and what does that cost under 10K idle cursors? (Compare pgwire portals, topic 7 §4.)
- PackStream has no length prefix on messages — chunking (2-byte chunk headers, 0x0000 terminator) wraps it. Why chunk instead of length-prefixing the whole message, for a server that streams records as it produces them?
- The handshake proposes four versions, server picks one (bolt_api.c:845-864 clamps to 5.1..5.7). Compare RESP’s HELLO 2/3. Which design lets a proxy transparently downgrade, and why?
- Node/Relationship on the wire carry element ids + property maps. What does this rule out that RESP’s “everything is arrays” allows — and which side of the trade does a new graph database want?
- Why might FalkorDB have removed Bolt (#2170)? List the real costs a second protocol imposes on an engine (state machines, result encoders, auth, tests, fuzz surface) — then what you’d need to keep it cheap.
- M7 mapping: the stretch goal is a Bolt listener beside RESP. Which
pieces of your M7 RESP server are protocol-neutral (executor,
result set) and which need a Bolt twin? Sketch the
bolt_reply_*-equivalent trait your result set must implement.
References
Papers
- Neo4j — Bolt Protocol + PackStream specifications (https://neo4j.com/docs/bolt/current/) — the normative source for markers, messages, and the handshake
Code
- FalkorDB/FalkorDB
src/bolt/(bolt.c,bolt.h,bolt_api.c) — removed by #2170; read it frozen in time withgit show 0b11a00b3^:src/bolt/<file>in~/repos/FalkorDB
C10K to thread-per-core: what is a server thread for?
Three readings spanning 1999→2024, one thread: what should a server thread be responsible for? Kegel’s C10K catalog explains why event loops became the default, valkey’s 8.0 posts show disciplined Amdahl analysis parallelizing exactly the profiled majority, and Glauber Costa’s thread-per-core essays take the radical endpoint — share nothing between cores. Together they span the shared↔sharded plane M7 must position itself in.
1. “The C10K problem” — Dan Kegel (kegel.com/c10k.html)
Read it as history that explains present defaults. 1999’s question: how do you serve 10,000 sockets when a thread per connection costs a stack + a scheduler slot each?
The menu it catalogs (skim the I/O-strategy section, skip the dated driver patches):
- thread per connection — dies at ~10K in 1999 (stacks, context switches)
- select/poll — O(n) scans of the fd set per wakeup
- readiness notification (epoll/kqueue) — O(ready) not O(registered): this line wins; ae.c is exactly this + a dispatch table
- async I/O (POSIX aio) — stillborn on Linux for sockets; the idea returns as io_uring twenty years later
What changed since: threads got cheaper (10K threads is fine now), cores multiplied (one loop can’t fill a 64-core box), and NICs got faster than a single core’s syscall budget. Hence the two modern answers below.
2. Valkey’s multithreading blog posts (valkey.io/blog — the 8.0
performance series)
Read after reading-valkey-iothreads.md; the posts give the measured story:
- redis-6-style io-threads: threads spin-wait, main thread coordinates every batch — modest gains, high CPU burn.
- valkey 8 rework: SPSC handoff, threads own the whole read→parse and write path, main thread prefetches for batches ⇒ ~1M+ ops/s/node claims, ~2–3× over redis 7 on the same box.
- The reasoning to internalize: they profiled first — parse+syscall was the majority of CPU at high op rates; commands themselves were ~30%. The rework parallelizes exactly the majority and nothing else. (Amdahl, applied with discipline.)
3. Glauber Costa on thread-per-core (the Seastar/ScyllaDB and later
Glommio essays: “The reactor pattern is dead, long live the reactor”, shard-per-core posts)
The radical position: don’t share ANYTHING between cores.
- One reactor per core, connections pinned, data sharded by core — a request for shard 7 arriving on core 2 is forwarded as a message (cross-core SPSC again), never locked.
- No locks ⇒ no lock contention, but also: no work stealing ⇒ a hot shard is a hot core; tail latency now depends on your partitioning function.
- Rust incarnation: Glommio (io_uring + thread-per-core executors) vs tokio’s work-stealing multi-thread runtime. Tokio moves tasks to idle workers (great for uneven load, pays cross-core cache traffic); Glommio never moves them (great cache locality, pays imbalance).
shared keyspace ◄──────────────────────► sharded keyspace
redis/valkey: 1 exec thread DragonflyDB/Scylla: N exec threads,
+ io threads, zero data locks keyspace hash-partitioned per core,
cross-shard ops = messages/transactions
Questions to answer in notes.md
- Which C10K strategy is tokio’s multi-thread runtime? (Careful: it’s readiness-based mio underneath + work-stealing tasks on top — two layers, name both.)
- A graph database’s unit of work is a query (ms-scale), not a GET (µs-scale). Redo valkey’s Amdahl analysis for M7: what fraction of a GRAPH.QUERY round-trip is parse+I/O, and does ANY threading of the network layer matter? Where do the threads belong instead (M9)?
- Thread-per-core for a graph: matrices don’t hash-partition like a keyspace. What’s the sharding unit — graph? subgraph? matrix tile? What does a BFS crossing shards cost in messages?
- io_uring (the C10K “async I/O” line resurrected): what changes in ae.c’s design if poll+read+write become submission-queue entries? (Topic 6’s O_DIRECT thread rejoins here.)
Done when
You can place redis, valkey 8, tokio, and DragonflyDB in the shared↔sharded / loop↔threads plane and argue M7’s position in it.
References
Papers
- Dan Kegel — “The C10K problem” (kegel.com/c10k.html, 1999–2003) — skim the I/O-strategy section, skip the dated driver patches
- Valkey blog — the 8.0 performance/multithreading series (valkey.io/blog) — read after reading-valkey-iothreads.md for the measured story
- Glauber Costa — thread-per-core essays (“The reactor pattern is dead, long live the reactor”; the Seastar/ScyllaDB shard-per-core posts and later Glommio writing)
pgwire & tonic: sessions, portals, and protocols you don’t write
Two contrasts with RESP: a protocol with stateful sessions and streaming (postgres wire, via the pgwire Rust crate), and a protocol you don’t write at all (gRPC, via qdrant’s tonic setup). Together they bracket RESP’s design point — no handshake, no cursors, buffer-or-die — and fill in the design-space table M7 has to take a position on.
1. pgwire — ~/repos/pgwire
The crate structure IS the protocol lesson:
src/messages/— every frontend/backend message as a typed struct with encode/decode. Postgres framing: 1 type byte + i32 length + payload — like RESP’s type-first byte but with binary length (RESP: ASCII digits).src/api/query.rs— the two query protocols:SimpleQueryHandler— :48: oneQuerymessage in, a stream ofRowDescription+DataRow* +CommandCompleteout. RESP-like.ExtendedQueryHandler— :174: Parse → Bind → Execute → Sync, five round-trips of state. Prepared statements, parameter binding, binary result formats, and portals — a suspended query you pull N rows from. This is protocol-level backpressure and cursoring; RESP has neither (a module either buffers the whole reply or blocks the loop).
src/api/auth.rs—StartupHandler(see api/mod.rs:555): the connection is a state machine from byte 0 — startup params, auth exchange, then ready-for-query. RESP connections have no handshake at all (HELLO is optional) — count what that costs postgres in connection setup and what it buys (per-session GUCs, tx state, cancel keys).
Read it asking: where does session state live? — pgwire forces a
ClientInfo through every call; your RESP server keeps per-connection state
implicitly in the task. Both are answers to “protocol = state machine”.
The extended-query state machine, distilled:
#![allow(unused)]
fn main() {
// The protocol IS a session state machine; portals are protocol-level
// backpressure — a suspended query the client pulls N rows at a time.
match msg {
Parse { name, sql } => { self.stmts.insert(name, prepare(sql)?); }
Bind { portal, stmt, args } => { self.portals.insert(portal, cursor(stmt, args)?); }
Execute { portal, max_rows } => {
let cur = self.portals.get_mut(&portal)?;
for row in cur.take(max_rows) { send(DataRow(row))?; }
if cur.done() { send(CommandComplete)?; }
else { send(PortalSuspended)?; } // client decides when to pull more
}
Sync => { self.close_txn_if_failed(); send(ReadyForQuery)?; }
_ => { /* Describe, Close, Flush … */ }
}
}
2. qdrant — ~/repos/qdrant/src/tonic/
src/tonic/mod.rs:138and:277—Server::builder()twice: separate internal (peer-to-peer raft) and public gRPC servers. Protocol surface split by trust domain — compare redis exposing admin + data on one port.- The services are generated from
.proto(seeapi/crate: qdrant’s protos) — the parser, framing, streaming, and backpressure (HTTP/2 flow control windows) are inherited, not written. The cost: every message is protobuf — field tags, varints, no zero-copy into your value types; and HTTP/2 framing means you can’t debug withnc. - Note the middleware layers in mod.rs (auth :138 area, logging, telemetry) — tower’s onion model vs redis’s “check ACL inside processCommand”.
3. The design-space table (fill the last row yourself)
| RESP | pgwire | gRPC | |
|---|---|---|---|
| framing | ASCII len prefixes | type byte + i32 len | HTTP/2 frames |
| parse cost | memchr + atoi | fixed header read | protobuf decode |
| streaming | no (buffer all) | portals, row-at-a-time | HTTP/2 streams |
| backpressure | output-buffer kill | portal suspend | flow-control windows |
| debuggability | nc works | needs a tool | needs grpcurl |
| your GRAPH.QUERY | ? | ? | ? |
Questions to answer in notes.md
- FalkorDB result sets ride RESP arrays — huge ones buffer entirely in the module. What would a portal-style GRAPH.QUERY cursor look like as RESP commands? (FalkorDB actually has one — recall GRAPH.QUERY’s timeout + result-set config; design the missing GRAPH.CURSOR anyway.)
- Why does qdrant run TWO tonic servers instead of one with authz? What attack/ops story does the split simplify?
- Extended query’s 5 messages cost a round-trip each unless pipelined — how does pgwire’s async design let Parse/Bind/Execute/Sync coalesce, and what’s the RESP equivalent? (MULTI? No — pipelining itself.)
Done when
You can fill the table’s last row with committed answers for M7 and defend “RESP + explicit cursor commands” against “just use gRPC” for a graph DB.
References
Code
- sunng87/pgwire —
src/messages/,src/api/query.rs,src/api/auth.rs; the crate structure IS the protocol lesson. Local clone at~/repos/pgwire. - qdrant/qdrant —
src/tonic/mod.rs(two servers, tower middleware) plus the generated services in theapi/crate. Local clone at~/repos/qdrant.
The redis event loop: pipelining for free
One thread, one poll syscall per iteration, and two buffering decisions —
parse everything the read buffer holds, write nothing until beforeSleep —
give redis pipelining and reply batching without any dedicated machinery.
ae.c is ~500 lines, so read it fully (a rare luxury); networking.c is
huge, so read only the five functions this chapter walks.
1. ae.c — the whole loop
aeCreateEventLoop— ae.c:47: arrays indexed by fd (events[fd]), not a hash — fds are small dense integers, the OS hands you the perfect array index.setsize= maxclients + headroom.aeProcessEvents— :360: the core. beforesleep callback (:377–378), thenaeApiPoll(:398) — ONE syscall per iteration collects all ready fds.- Backend selection:
ae_kqueue.c(your Mac),ae_epoll.c(Linux), compile-time — the abstraction is 4 functions (add/del/poll/name). - Timers ride the same loop: poll timeout = time to nearest timer.
2. The read path — networking.c
readQueryFromClient— :3715: connection is readable ⇒ read up to 16KB (PROTO_IOBUF_LEN, server.h:188) intoquerybuf, then parse.processInputBuffer— :3529: loop — parse as many complete commands as the buffer holds, executing each. This loop IS pipelining: 100 commands in one read = 100 executions, zero extra syscalls.processMultibulkBuffer— :3117: the RESP parser. Read*argc(:3123– 3157), then per arg read$lenthen exactly len bytes. NotePROTO_MBULK_BIG_ARG(server.h:191, 32KB): big args get the querybuf repositioned so the arg can become an sds object without a copy — zero-copy for large SETs.processInlineBuffer— :2968: thenc-friendly fallback — scan for newline (:2975), split on spaces. The ONLY scanning parser in the path.- Incomplete input ⇒ return, keep bytes in querybuf, wait for the next
readable event. State lives in
multibulklen/bulklen(:184–185) — your Rust parser’s resumption test mirrors exactly this.
The read path’s shape, in one loop:
#![allow(unused)]
fn main() {
// processInputBuffer: drain every COMPLETE command the buffer holds.
// This loop IS pipelining: 100 commands in one read() = 100 executions,
// zero extra syscalls.
fn process_input(&mut self, c: &mut Client) {
loop {
match parse_multibulk(&c.querybuf[c.pos..]) { // *argc, then $len + bytes per arg
Parsed { cmd, consumed } => {
c.pos += consumed;
execute(&cmd, c); // addReply BUFFERS, never writes
}
Incomplete => break, // keep the bytes; multibulklen/bulklen remember
} // where we were — resume on the next readable event
}
c.querybuf.drain(..c.pos);
c.pos = 0;
}
}
3. The write path — the part that surprises people
addReply— :572: does NOT write to the socket. Appends to the client’s reply buffer/list and flags the client as pending-write.handleClientsWithPendingWrites— :2802: called from beforeSleep — walk pending clients,writeToClienteach (one write() syscall for ALL replies accumulated this iteration). Socket buffer full ⇒ install a write handler and let the loop wake us when writable (the only time redis uses write events).- Reply buffering structure: fixed 16KB buffer first (
PROTO_REPLY_CHUNK_ BYTES), overflow into a list of blocks — small replies never allocate.
4. Backpressure
Find closeClientOnOutputBufferLimitReached (grep it): a slow consumer or
a huge reply grows the list until the configured limit kills the client.
Trace what happens when GRAPH.QUERY returns 1M rows through a module:
module → RedisModule_ReplyWith* → these same buffers → possibly the axe.
Questions to answer in notes.md
- Why write in beforeSleep rather than in addReply? Count syscalls for a pipeline of 100 GETs both ways.
events[fd]arrays vs aHashMap<fd, handler>: why is the array not just faster but correct here? (fd reuse semantics after close.)- The big-arg zero-copy: what property of sds + querybuf repositioning makes it safe? When does it fail (arg spans two reads)?
- Your tokio server does a write per response future by default — what’s the tokio equivalent of pending-writes batching? (Hint: buffered writer + flush on yield, or explicit corking.)
Done when
You can narrate one loop iteration with 3 pipelined clients — every syscall, every buffer — and explain where a 101st slow client changes the story.
References
Code
- redis —
src/ae.c(read fully),src/networking.c(the five functions above), plus the buffer-size constants insrc/server.h. Local clone at~/repos/redis.
valkey io-threads: parallelize the majority, nothing else
Valkey 8 rewrote redis 6’s io-threads and roughly doubled throughput — while commands still execute on one thread with zero locks in the data structures. Read it as a case study in what to parallelize when you refuse to lock the data structures: SPSC handoff, batch commit, and a prefetcher that turns pointer chases into a pipeline. This is the “great perf PRs to study” item.
0. The contract
Commands still execute ONLY on the main thread — single-threaded semantics, zero locks in dict/rax/etc. What moves to threads: read(), RESP parsing, write(), and (new) memory prefetching. Amdahl says: that’s worth it exactly when parse+I/O dominates — i.e., small commands, many clients. GRAPH.QUERY with 50ms of matrix math? io-threads buy ~nothing. GET/SET at 1M ops/s? 2×.
1. The plumbing — io_threads.c
IOThreadMain— :293: each thread’s loop. Priority 1: drain its private SPSC queue in batches ofBATCH_SIZE(:320–321,spscDequeueBatch), jobs are tagged pointers (untagJob:333 — job type in the pointer’s low bits, topic-2’s bit-smuggling again).io_private_inbox[IO_THREADS_MAX_NUM]— :23: one SPSC queue per thread. Single-producer (main thread), single-consumer (that io thread) ⇒ no CAS contention at all; compare redis 6’s design where threads spun on a shared list with a busy-wait fence.spscCommit(:61) — the producer batches enqueues and commits once: amortizing the release-store, same group-commit shape as topic 5.trySendReadToIOThreads— :514 /trySendWriteToIOThreads— :550: main thread offloads a client if threads are enabled + client is eligible; networking.c calls these at :2313, :3043, :6408 — note every call site has a same-thread fallback.initIOThreads— :489; threads can be resized at runtime (:476).
2. The clever part — memory_prefetch.c
Commands parsed by io threads sit in a batch; before the main thread executes them, it prefetches the dict entries the batch will touch:
PrefetchCommandsBatch— :26–33: keys of up tomax_prefetch_sizecommands from multiple clients.- The file comment (:7) states the idea: walk each key’s lookup path
(hash → bucket → entry → value) issuing
__builtin_prefetchat each level, interleaved across keys — while key A’s bucket line is in flight, compute key B’s hash. This is software memory-level parallelism: topic 0’s MLP finding (hash lookups are flat because loads overlap) engineered deliberately.
without: exec(A): miss…wait 100ns… exec(B): miss…wait… serial misses
with: prefetch A.bucket, B.bucket, C.bucket (overlap!)
exec(A) hit, exec(B) hit, exec(C) hit misses paid once
The interleaving, made explicit:
#![allow(unused)]
fn main() {
// Walk every key's lookup path LEVEL BY LEVEL across the batch:
// while A's bucket line is in flight, compute B's hash — the pointer
// chase becomes a pipeline of overlapping misses, not a chain.
fn prefetch_batch(dict: &Dict, batch: &[Command]) {
let hashes: Vec<u64> = batch.iter().map(|c| hash(c.key())).collect();
for &h in &hashes {
prefetch(dict.bucket_addr(h)); // level 1: all bucket lines
}
for &h in &hashes {
prefetch(dict.entry_addr(h)); // level 2: entries (buckets now warm)
}
// main thread then executes the batch: every lookup hits warm lines
}
}
3. What to steal for M7
- SPSC per worker beats MPMC when you can dedicate pairs — in tokio terms: per-connection tasks already give you this shape for free; the lesson applies when you add a worker pool for query execution (M9).
- Batch handoff + commit, not per-item signaling.
- Prefetch only helps when execution is memory-bound on predictable pointer chains — matrix kernels are already streaming; the graph-store analogue is prefetching node/edge attribute blocks for a batch of lookups.
Questions to answer in notes.md
- Why SPSC queues instead of one MPMC queue? What does the redis-6 design (shared list + busy spin) pay per job that SPSC doesn’t?
- Tagged job pointers: why smuggle the type in low bits instead of a struct { type, ptr }? (Queue slot stays one word ⇒ one cache line moves per batch of 8.)
- Amdahl accounting for FalkorDB: measure (or estimate) parse+I/O share of a GRAPH.QUERY round-trip; at what query cost does io-threading stop mattering?
- Why must prefetch batches span multiple clients to work? (One client’s pipeline is sequential in the buffer, but its keys are independent — what actually limits batch depth?)
Done when
You can explain what valkey parallelized, what it deliberately didn’t, and why the prefetcher is the same insight as topic 0’s MLP experiment.
References
Code
- valkey-io/valkey —
src/io_threads.c,src/memory_prefetch.c(the file comment at :7 states the whole idea), plus the grep points insrc/networking.c. Local clone at~/repos/valkey.
Topic 7 notes — networking, protocols & event loops
Predict FIRST, then measure. Numbers without predictions are just trivia.
Predictions (fill in BEFORE running anything)
| Measurement | Prediction | Actual | Surprised? |
|---|---|---|---|
| redis-benchmark GET -P 1 vs real redis (yours/redis, req/s) | |||
| redis-benchmark GET -P 64 vs real redis | |||
| SET -P 64 (write lock on shard shows up?) | |||
| -P 64 with SHARDS=1 (RwLock contention) | |||
| Removing “flush only when drained” — -P 64 penalty | |||
| Flamegraph top-3 under -P 64 | 1. 2. 3. |
Reasoning space (why did you predict those numbers?):
- At -P 1 the bottleneck is syscalls + RTT, not parsing — both servers should be within ~2× of each other.
- At -P 64 parse+execute per syscall dominates; where does the re-parse-from- buffer-start simplification in resp.rs cost show up, if anywhere?
Bench protocol
# your server
cd experiments && cargo run --release --bin server
redis-benchmark -p 7379 -t get,set -n 1000000 -P 1
redis-benchmark -p 7379 -t get,set -n 1000000 -P 64
# real redis (brew services or redis-server), port 6379
redis-benchmark -p 6379 -t get,set -n 1000000 -P 1
redis-benchmark -p 6379 -t get,set -n 1000000 -P 64
# flamegraph (cargo install flamegraph; needs sudo for dtrace on macOS)
cargo flamegraph --release --bin server
# then drive load with redis-benchmark -P 64 from another terminal
Record: req/s for each cell, and the top-3 flamegraph entries with rough %.
Questions — reading-redis-ae-networking.md
- Why does the RESP parser never scan payload bytes? What property of the wire format makes that possible, and what does the inline protocol lose?
- Trace one GET at the function level: aeApiPoll → readQueryFromClient → processInputBuffer → processMultibulkBuffer → addReply → handleClientsWithPendingWrites. Where is the syscall boundary crossed (exactly twice — where)?
- What do multibulklen/bulklen (server.h:184–185) buy over re-parsing from the buffer start (what your resp.rs does)? Estimate the cost difference for a 1MB bulk arriving in 16KB reads.
- PROTO_MBULK_BIG_ARG (:191): what copy does the zero-copy path avoid, and why does it only matter above 32KB?
Questions — reading-valkey-iothreads.md
- What exactly does an io thread own in valkey 8 (read side, write side), and what remains main-thread-only? Why is that split Amdahl-optimal for GET-shaped workloads?
- How does the tagged-pointer inbox (untagJob :333) avoid a second queue — and where have you seen bit-smuggling like this before (topics 0, 6)?
- memory_prefetch.c batches dict lookups to overlap cache misses. What is the equivalent MLP opportunity in a GraphBLAS BFS step?
- Redo the Amdahl analysis for FalkorDB: GRAPH.QUERY spends how much in parse+I/O vs execution? Does io-threading the network layer move the needle at all for ms-scale queries?
Questions — reading-pgwire-qdrant.md
- Extended query protocol: what do Parse/Bind/Execute/Sync + portal max_rows give the client that RESP fundamentally cannot? (Hint: who controls the flow of a huge result set?)
- RESP kills clients that exceed output-buffer limits; pgwire suspends portals. Which does FalkorDB inherit, and what does that mean for a query returning 10M nodes?
- Why does qdrant run two tonic servers instead of one with auth middleware? When would M7 want the same split?
Questions — reading-c10k-thread-per-core.md
- Which C10K strategy is tokio’s multi-thread runtime? (Two layers — name both.)
- A graph database’s unit of work is a query (ms), not a GET (µs). Where do threads belong: network layer (M7) or executor (M9)? Argue with the valkey numbers.
- Thread-per-core sharding unit for a graph: graph? subgraph? matrix tile? What does a BFS crossing shards cost in messages?
- io_uring: what changes in ae.c’s design if poll+read+write become submission-queue entries?
Design decisions (record as you implement resp.rs)
- Incomplete-input handling: peek-then-commit or re-parse from start? What state would you keep to make it O(new bytes) instead of O(buffered)?
- Where does Value allocate? Count allocations for
*2 $3 GET $3 foo— redis does this with zero per-arg heap allocations at steady state (sds reuse); how many do you do? - Error strategy: why kill the connection on protocol error instead of resyncing? (What would resync even mean without a framing marker?)
Threading-model placement (do after all readings)
Place on the shared↔sharded / loop↔threads plane:
single loop io threads thread-per-core
shared keyspace redis ≤6 valkey 8 (contention!)
sharded keyspace — — DragonflyDB/Scylla
Where does M7 sit, and why? (Consider: matrices don’t hash-partition, queries are ms-scale, GraphBLAS wants big parallel sections in the EXECUTOR not the network layer.)
M7 log (capstone milestone)
- resp.rs passes all 8 tests
- server survives redis-benchmark -P 64 for 1M ops
- benched vs real redis, both pipelining levels, numbers above
- flamegraph captured; top-3 named and explained
- GRAPH.QUERY wire-compat: falkordb-py client can connect to the M7 server and get a well-formed (stub) reply
- threading-model decision written down with the Amdahl argument
Topic 8 — Transactions & MVCC
The intellectual core of OLTP. Everything here is one question asked five ways: when two transactions touch the same data, who sees what, and who must die?
Budget: ~12 h. Order: §1 anomalies → §2 three concurrency schools → §3 postgres on-disk MVCC → §4 in-memory MVCC → experiments → M8.
1. Isolation levels are defined by their bugs
Read the levels bottom-up, as “which anomalies are permitted”:
| Anomaly | Shape | RC | RR/SI | Serializable |
|---|---|---|---|---|
| dirty read | read uncommitted write | blocked | blocked | blocked |
| non-repeatable read | re-read sees new commit | allowed | blocked | blocked |
| phantom | re-scan sees new rows | allowed | blocked* | blocked |
| lost update | r-m-w over another’s write | allowed | blocked | blocked |
| write skew | disjoint writes, overlapping reads | allowed | allowed | blocked |
* postgres RR = snapshot isolation, so phantoms don’t appear on re-read — but SI is not serializable, which is the whole point of Berenson ’95.
Write skew, the one everyone forgets (and your test suite will demonstrate):
invariant: at least one doctor on call T1 T2
oncall = {alice: true, bob: true} read alice,bob read alice,bob
(both true) (both true)
set alice=false set bob=false
commit ✓ commit ✓
result: oncall = {} — invariant broken, yet no write-write conflict:
the write SETS are disjoint; the danger is in the read→write overlap.
2. Three schools of concurrency control
| 2PL (pessimistic) | OCC (optimistic) | MVCC | |
|---|---|---|---|
| readers block writers | yes | no | no |
| writers block readers | yes | no | no |
| conflict handling | wait (deadlock detect) | validate at commit, abort | first-committer-wins abort |
| cost center | lock manager traffic | wasted work on abort | version storage + GC |
| shines when | high contention | low contention | read-heavy mixes |
| code you’ll read | RocksDB pessimistic txn | RocksDB optimistic txn | postgres, surrealdb |
MVCC’s bargain: writes never overwrite — they append a new version. Readers pick the version visible to their snapshot. You pay in space (dead versions) and in a background debt collector (vacuum / GC). Sound familiar? It’s the LSM bargain (topic 4) applied to time instead of keys.
3. Postgres: MVCC on disk
Every heap tuple carries its own visibility metadata (htup_details.h:124–161):
HeapTupleHeader
┌──────────┬──────────┬──────────┬─────────────────────┐
│ t_xmin │ t_xmax │ t_ctid │ infomask hint bits │
│ creator │ deleter │ next ver │ XMIN_COMMITTED etc. │
│ xact id │ (0=live) │ (chain) │ (cached clog lookups)│
└──────────┴──────────┴──────────┴─────────────────────┘
UPDATE = insert new version + set old tuple's t_xmax + link t_ctid.
DELETE = set t_xmax. Nothing is removed until VACUUM.
A snapshot is (xmin, xmax, xip[]) (snapshot.h:138–165): all xids < xmin
visible, ≥ xmax invisible, in-progress list xip[] invisible. Visibility =
pure function of (tuple header, snapshot) — HeapTupleSatisfiesMVCC.
flowchart TD
A[tuple] --> B{t_xmin committed\nbefore my snapshot?}
B -- no --> INV[invisible]
B -- yes --> C{t_xmax set and committed\nbefore my snapshot?}
C -- yes --> INV2[invisible - deleted]
C -- no --> VIS[visible]
HOT updates: if no indexed column changed and the new version fits on the same page, skip all index updates — index points at the chain head, readers walk t_ctid within the page. This is why “UPDATE = INSERT+DELETE” is only half true in postgres.
The debt: vacuum. Dead versions accumulate; heap_page_prune_opt does
opportunistic per-page cleanup during reads; heap_vacuum_rel does the
full pass. XID wraparound is the failure mode that pages DBAs at 3am.
4. In-memory MVCC (Hekaton school)
Hekaton flips the postgres layout: versions live in a chain hanging off a lock-free index; timestamps (begin_ts, end_ts) replace xmin/xmax; commit processing validates reads (serializable OCC over versions); GC is cooperative — every thread cleans as it walks.
Wu/Pavlo VLDB’17 measured the design space (version storage: append-only vs delta vs time-travel; ordering: newest-to-oldest wins; GC: cooperative wins) — read it as a menu with benchmark-backed prices.
5. Code to read (guides in this dir)
| Guide | What you’ll trace |
|---|---|
| reading-postgres-heapam.md | Postgres MVCC: every tuple carries its own visibility |
| reading-rocksdb-transactions.md | OCC and 2PL, same skeleton: RocksDB transactions |
| reading-surrealdb-tx.md | The minimal transactional KV interface: surrealdb’s kvs layer |
| reading-ansi-critique.md | Isolation levels, made rigorous: history patterns and write skew |
| reading-ssi-postgres.md | SSI: serializable snapshot isolation without blocking anyone |
| reading-inmemory-mvcc.md | In-memory MVCC: timestamps as locks, and the design-space price list |
Further references: Kung & Robinson “On Optimistic Methods for Concurrency Control” (TODS 1981) — the OCC school’s founding paper (read/validate/write phases; RocksDB’s OptimisticTransaction is this, verbatim); one of the most-cited DB papers of all time.
6. Experiments (experiments/)
src/mvcc.rs — YOU implement MVCC with snapshot isolation over an in-memory
KV store. The tests fix the contract:
- snapshots are stable; uncommitted writes invisible; read-your-own-writes
- write-write conflict → first-committer-wins abort
write_skew_happens_under_si— a test that PASSES when the anomaly occurs (you must be able to produce the bug before you prevent it)Mode::Serializableprevents it (track read sets; abort on rw-conflict)- GC drops versions older than the oldest active snapshot
src/bin/txn_bench.rs — provided; runs once mvcc.rs compiles: threaded
throughput of your MVCC vs a single Mutex<HashMap>, read-heavy (95/5) and
write-heavy (50/50) mixes. Predict the crossover in notes.md first.
7. M8 checklist (capstone)
- Design the MVCC graph: copy-on-write matrices + versioned reads. Key question: the version unit — whole matrix? tile? delta? (A graph txn touching 1% of a matrix shouldn’t copy 100% of it. Delta matrices from topic 20 ARE pending versions.)
- Single-writer/multi-reader first (FalkorDB’s actual model) — write down what that buys (no write-write conflicts, no validation) and what it costs (write throughput ceiling)
- Then study the reference’s
mvcc_graph.rs/cow.rsand diff against your design in notes.md
Isolation levels, made rigorous: history patterns and write skew
Berenson et al.’s SIGMOD ’95 critique is the paper that made isolation rigorous — and, accidentally, the paper that NAMED snapshot isolation and its flaw, seven years before anyone shipped a fix. This chapter replaces ANSI’s ambiguous prose phenomena with precise history patterns, then uses them to place SI in the hierarchy — above repeatable read, incomparable to serializable. Read it before the SSI chapter or that one won’t land.
The setup
ANSI SQL-92 defined isolation levels by three prose “phenomena” (dirty read, non-repeatable read, phantom). The authors show the prose is ambiguous: a strict reading (only forbid the exact anomaly sequence) permits histories everyone agrees are broken; a loose reading over-forbids. Section 3’s move: redefine everything as history patterns over reads/writes/commits/aborts.
The notation to internalize (worth the hour alone)
P0 dirty write w1[x] … w2[x] (both uncommitted)
P1 dirty read w1[x] … r2[x] before c1/a1
P2 fuzzy read r1[x] … w2[x] before c1
P3 phantom r1[P] … w2[y in P] predicate P, not item!
P4 lost update r1[x] … w2[x] … w1[x]
A5A read skew r1[x] … w2[x] w2[y] c2 … r1[y]
A5B write skew r1[x] r1[y] … w2[y] … w1[x] (your doctors test!)
- The P3 correction is the famous one: ANSI’s phantom was item-based; real phantoms are predicate-based. Locking a row you read doesn’t lock the rows that WOULD have matched.
- The lost-update ladder: ANSI REPEATABLE READ (as literally written) permits P4. Locking-based RR doesn’t. The prose and the implementations had diverged for a decade.
Snapshot isolation, defined and dethroned
Section 4 defines SI: reads from a snapshot, first-committer-wins on writes. Then the twist that structures your whole experiments crate:
- SI forbids P0–P2, P4, A5A — it sits ABOVE ANSI Repeatable Read.
- SI permits A5B write skew — so it’s incomparable to serializable.
- Hence the paper’s hierarchy is a partial order, not a ladder:
Serializable
/ \
SI (no P4, allows A5B) Repeatable Read (locking; no A5B via locks,
\ / allows phantoms P3)
Read Committed
|
Read Uncommitted
Oracle shipped SI as “serializable” for years. Postgres called it “repeatable read” (honest) and later added SSI on top (next guide).
Questions for notes.md
- Write the doctors-on-call write skew in the paper’s history notation, and show which forbidden phenomenon it does NOT match (that’s why SI lets it through).
- Why can’t first-committer-wins catch write skew? (One sentence: the conflict is r→w across txns, not w→w.)
- Predicate phantoms in a graph: “MATCH (n:Person) WHERE n.age > 40” ran twice in a txn while another txn CREATEs a matching node. Which structure would M8 need to lock/validate — a label matrix? an index range? Is that even expressible as key locks (recall RocksDB guide Q3)?
- Your mvcc.rs implements exactly Section-4 SI. Which tests map to which phenomena? (Label each test with its P/A number in a comment.)
Done when
You can define SI in one sentence of history notation and name the exact anomaly that separates it from serializable — without looking.
References
Papers
- Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil — “A Critique of ANSI SQL Isolation Levels” (SIGMOD 1995, arXiv:cs/0701157) — ~1.5 h; §3’s history notation and §4’s SI definition are the core
In-memory MVCC: timestamps as locks, and the design-space price list
What does MVCC look like when the disk-era assumptions are deleted? Hekaton (SIGMOD ’13) answers with one design — no locks, no latches, no pages; Wu & Pavlo’s VLDB ’17 evaluation answers with the whole design SPACE, benchmark-backed prices attached. Read Hekaton first (a design), then Wu/Pavlo (the menu).
Hekaton — MVCC with no locks, no latches, no pages
The version record is self-describing, like a postgres tuple but timestamp-based:
┌──────────┬─────────┬──────────────┬─────────┐
│ begin_ts │ end_ts │ index links │ payload │
└──────────┴─────────┴──────────────┴─────────┘
live version: end_ts = ∞
during update: end_ts = writer's txn-id (acts as the write lock!)
visibility: begin_ts ≤ my_read_ts < end_ts
Key moves to internalize:
- Txn-ids double as locks. Storing a txn-id in end_ts is the write-write conflict check: a second writer sees a txn-id there and aborts/waits. One CAS = lock + version link. (Bit-smuggling again — the id/timestamp distinction is one bit.)
- Commit processing, not commit point. At commit, get commit_ts, then validate (serializable = re-check read set unchanged + rescan scan predicates), then write log, then fix up all your begin/end_ts fields from txn-id → commit_ts. Readers who hit a txn-id must chase the txn’s state — visibility can depend on an in-flight commit (commit dependencies, taken instead of blocking).
- Indexes point at version chains; lock-free hash + Bw-tree (topic 9’s protagonists) — MVCC and lock-free structures co-designed.
- Cooperative GC: any thread that walks past a version older than the oldest active read_ts unlinks it. No vacuum process; the workload cleans itself in proportion to how much it reads.
The visibility test is one range check — except either field may still hold a txn-id, and then the reader chases the writer’s state:
#![allow(unused)]
fn main() {
fn visible(v: &Version, read_ts: u64, txns: &TxnTable) -> bool {
let begin = match v.begin_ts {
Stamp(ts) => ts,
TxnId(id) => match txns.state(id) {
Committing { commit_ts } => commit_ts, // take a commit DEPENDENCY:
_ => return false, // I abort if the writer does
},
};
let end = match v.end_ts {
Stamp(ts) => ts, // superseded at ts
TxnId(_) => u64::MAX, // being updated — still the latest for readers
};
begin <= read_ts && read_ts < end
}
}
Contrast postgres on every axis: ts vs xid+clog+hint-bits; validation vs SIREAD; cooperative GC vs vacuum; new-to-old chains vs t_ctid old-to-new.
Wu/Pavlo — the menu with prices (VLDB ’17)
They implement every combination in one system and measure. The axes:
| Axis | Options | Verdict (their workloads) |
|---|---|---|
| concurrency control | MVTO / MVOCC / MV2PL / SI+SSN | no universal winner; MVTO strong; the version machinery dominates CC choice |
| version storage | append-only / delta / time-travel | delta wins for writes (N2O append-only for reads); append-only pays full-tuple copies |
| ordering | newest-to-oldest / oldest-to-newest | N2O wins — readers want the newest; O2N walks garbage first |
| GC | tuple-level background / cooperative / txn-level / epoch | cooperative + epoch wins; background vacuum-style lags under write bursts |
| index mgmt | logical pointers / physical | logical (indirection) — physical means every version churns every index |
The meta-lesson (their words, roughly): everyone argues about CC algorithms, but version storage and GC decide throughput. Storage layer > protocol. (The RUM triangle strikes again.)
Questions for notes.md
- Hekaton’s end_ts-as-lock: write the CAS-based first-writer-wins in pseudocode. Your mvcc.rs does the same check where? (Point at the line once implemented.)
- Delta storage wins for writes; append-only N2O for reads. Which is a GraphBLAS delta matrix (topic 20)? So M8’s “copy-on-write + deltas” sits where in the Wu/Pavlo taxonomy — and what does their data predict about its read path?
- Logical vs physical index pointers: FalkorDB’s node ids ARE logical indirection into matrices. What does that make “index management” cost for a graph MVCC — which updates still have to touch indexes?
- Cooperative GC in proportion to reads: what happens to a write-only hot key that nobody reads? (Wu/Pavlo call this out — find the fix.)
- Predict, then check §6 of Wu/Pavlo: at 40 cores, high contention, what ruins MVOCC — validation aborts or timestamp allocation?
Done when
You can fill the 5-axis table from memory and place postgres, Hekaton, and your M8 design in it — one row each.
References
Papers
- Diaconu, Freedman, Ismert, Larson, Mittal, Stonecipher, Verma, Zwilling — “Hekaton: SQL Server’s Memory-Optimized OLTP Engine” (SIGMOD 2013) — ~1.5 h; the version format and commit processing sections carry it
- Wu, Arulraj, Lin, Xian, Pavlo — “An Empirical Evaluation of In-Memory Multi-Version Concurrency Control” (VLDB 2017) — ~1 h; read it as a menu with prices, the tables and §6 graphs carry the message
Postgres MVCC: every tuple carries its own visibility
Postgres stores versions IN the table: each heap tuple’s header names its creator and deleter, and visibility is a pure function of (tuple header, snapshot) — no lock manager consulted on the read path. This chapter walks the tuple header, the snapshot, the visibility function that is the spec of snapshot isolation, the write paths, and the debt collectors that clean up after all of it.
1. The tuple header IS the MVCC state (htup_details.h)
- :124–125 —
t_xmin(inserting xact),t_xmax(deleting/locking xact). - :161 —
t_ctid: chain pointer to the newer version of this row. Read the big comment at :86–111: following t_ctid requires re-checking that the next tuple’s xmin equals this tuple’s xmax — the chain can be broken by vacuum, and t_ctid is overloaded for speculative insertion tokens. - :204–208 — hint bits:
HEAP_XMIN_COMMITTED / INVALID,HEAP_XMAX_*. These are a cache of clog lookups written back into the tuple itself by readers. First reader pays the clog probe, everyone after reads a bit. (Reader-writes-metadata: same trick as topic 6’s usage counters.)
2. The snapshot (snapshot.h:138–165, snapmgr.c)
SnapshotData: xmin (:153 — everything below is decided), xmax (:154 —
everything at/above is invisible), xip[]/xcnt (:164–165 — in-progress
xids at snapshot time, invisible). GetSnapshotData (procarray.c:2114)
builds it by scanning the proc array — this scan was postgres’s scalability
wall until the 2020 rework (note GetSnapshotDataReuse :2034: if nothing
committed since, reuse the old snapshot wholesale).
XidInMVCCSnapshot (snapmgr.c:1869) — the three-way check. Note it’s a
binary search over xip for big arrays: snapshot cost scales with concurrent
write transactions.
3. The visibility function (heapam_visibility.c)
HeapTupleSatisfiesMVCC :939 — read the whole thing; it is the spec of SI:
- xmin aborted → invisible; xmin in-progress and not me → invisible
- xmin mine and cid < my command → visible (read-your-own-writes lives here, via CommandId — statement-level granularity inside a txn)
- xmin committed but
XidInMVCCSnapshot(xmin)→ invisible (committed AFTER my snapshot — this is the line that makes it “snapshot”) - then the same dance for xmax to decide “deleted yet, for me?”
The same function, minus a decade of hint-bit engineering:
#![allow(unused)]
fn main() {
fn satisfies_mvcc(t: &Tuple, s: &Snapshot) -> bool {
// "visible xid" = committed AND not still in flight at snapshot time
let vis = |xid: Xid| committed(xid) && !in_snapshot(xid, s);
if t.xmin == s.my_xid {
if t.cmin >= s.cur_cid { return false; } // later command in my own txn
} else if !vis(t.xmin) {
return false; // creator invisible to me
}
match t.xmax {
None => true, // never deleted
Some(x) if x == s.my_xid => t.cmax >= s.cur_cid,
Some(x) => !vis(x), // deleter invisible ⇒ row lives
}
}
fn in_snapshot(xid: Xid, s: &Snapshot) -> bool { // committed AFTER my snapshot?
xid >= s.xmax || (xid >= s.xmin && s.xip.binary_search(&xid).is_ok())
}
}
HeapTupleSatisfiesUpdate:511 — the OTHER visibility function, used by UPDATE/DELETE to find the latest version and report invisible/being-updated — this is where waiting-on-a-lock and the EvalPlanQual re-check originate.- SetHintBits machinery :83–112 — even hint-bit writes are batched now
(
SetHintBitsState): amortize BufferBeginSetHintBits over a page. The amortize-and-batch pattern, again. - Bonus:
HeapTupleSatisfiesMVCCBatch:1690 — visibility checks vectorized over a page. Topic 11 foreshadowing.
4. Write paths (heapam.c)
heap_insert:2004 — new tuple: xmin = my xid, xmax = 0. Note the WAL record is built AFTER the page change, inside the critical section — topic 5’s reserve-then-copy in action.heap_delete:2717 — nothing moves; set xmax, clear some flags. The “delete” is a metadata write.-
heap_update:3201 — the long one. Skim for the shape:HeapDetermineColumnsInfo:3382 (which indexed columns changed?),use_hot_update:3233/:3981 (same-page + no indexed cols changed →- 4029 mark HEAP_HOT_UPDATED, skip index inserts entirely).
HOT chain (one page): index entry ──► lp 1 (root, HOT_UPDATED)
│ t_ctid
lp 3 (HEAP_ONLY_TUPLE)
│ t_ctid
lp 5 (HEAP_ONLY_TUPLE) ◄ live
readers walk the chain under the page latch; prune collapses it later.
5. The debt collectors
heap_page_prune_opt(pruneheap.c:271) — opportunistic: any reader that notices a prunable page cleans it, no vacuum needed. HOT chains collapse to a redirect line pointer.heap_vacuum_rel(vacuumlazy.c:624) /lazy_scan_heap:1279 — the full pass: collect dead TIDs, delete index entries, then mark line pointers reusable. Two-phase because an index entry must never point at a reused slot.
Questions for notes.md
- Why must the index-entry deletion happen BEFORE line pointers are recycled? Construct the corruption if the order flipped.
- Hint bits make reads write. Which topic-6 lesson does that complicate (think: checksums, dirty buffers from SELECTs)?
- A snapshot with 10K concurrent writers makes XidInMVCCSnapshot a binary search over 10K xids per tuple. What does Hekaton’s timestamp design pay instead?
- FalkorDB angle: postgres stores versions IN the table (old versions inflate the heap). For a graph whose “table” is a sparse matrix, where would old versions live — and is that closer to append-only (postgres) or delta (Hekaton per Wu/Pavlo taxonomy)?
Done when
You can execute HeapTupleSatisfiesMVCC on paper for: (a) my own insert,
(b) a commit that landed after my snapshot, (c) a HOT-updated row mid-chain.
References
Code
- postgres —
src/backend/access/heap/heapam.c,heapam_visibility.c,src/include/access/htup_details.h,src/include/utils/snapshot.h,src/backend/utils/time/snapmgr.c,pruneheap.c,vacuumlazy.c; ~2.5 h — readHeapTupleSatisfiesMVCCin full first, it is the spec of SI, and the :86–111 comment in htup_details.h before chasing t_ctid
OCC and 2PL, same skeleton: RocksDB transactions
RocksDB ships BOTH optimistic and pessimistic transactions over the same base class — the cleanest side-by-side of the two concurrency schools you’ll find in production code. Both buffer writes privately and differ only in WHEN conflicts are detected: at access time (TryLock) or at commit time (validation).
Everything hangs off sequence numbers: a RocksDB snapshot is just “the seq at begin”. MVCC comes free from the LSM (topic 4): old versions already exist as older entries; a snapshot pins them against compaction GC.
1. Shared skeleton — transaction_base.
- Writes are buffered in a private
WriteBatchWithIndex— nothing touches the DB until commit. Reads go through the batch first (read-your-own- writes), then the DB at the snapshot. SetSnapshot(transaction_base.h:264) — notesnapshot_needed_:270: snapshots can be taken lazily on first read.- So: both flavors are “buffer writes, decide at commit”. The ONLY difference is when conflicts are detected.
2. Optimistic — optimistic_transaction.
CheckTransactionForConflicts(h:67) →TransactionUtil::CheckKeyForConflicts(transaction_util.cc:20) →CheckKey:50.- The validation trick: for each written key, ask “has this key been
written at a seq > my snapshot seq?” — answered from the memtable
only (
cache_only): if the memtable’s earliest seq is newer than my snapshot, RocksDB can’t know and conservatively aborts (TryAgain). Cheap validation, bought with spurious aborts on long transactions.
#![allow(unused)]
fn main() {
// CheckKey, conceptually: "was this key written after my snapshot?"
fn validate(&self, snap_seq: u64) -> Result<(), Abort> {
for key in self.write_batch.keys() {
if self.db.memtable_min_seq() > snap_seq {
return Err(Abort::TryAgain); // memtable too young to answer —
} // abort conservatively, retry
if self.db.latest_seq(key, /*memtable_only=*/ true) > snap_seq {
return Err(Abort::Busy); // someone committed over me
}
}
Ok(()) // batch → DB, atomically
}
}
- Commit modes (optimistic_transaction.cc:66):
CommitWithSerialValidate(h:76) — validate inside the single writer queue (correct by serialization);CommitWithParallelValidate(h:78) — take striped locks on the write set, validate, then write. Same structure as your topic-5 group commit vs per-commit trade.
3. Pessimistic — pessimistic_transaction.{h,cc} + lock/point/
- Every Put/Delete calls
TryLock(pessimistic_transaction.cc:1151) BEFORE buffering (:495 — lock first, then base-class write). GetForUpdate takes a read→write lock (:1121). PointLockManager(lock/point/point_lock_manager.h:110): striped hash of key →LockInfo(h:26),AcquireWithTimeout:208, deadlock detection via wait-for graph (h:216) with a bounded deadlock-info buffer (h:75–93).Commit:681 — locks released only after the write lands: strict 2PL.- Note what’s locked: keys, not rows — a lock manager over an
order-preserving keyspace can’t stop phantoms (no gap/range locks here;
contrast innodb). Snapshot validation (
SetSnapshotOnNextOperation) is layered on top for repeatable reads.
4. The design plane
conflict cost paid: at access time at commit time
┌────────────────┐ ┌──────────────────┐
pessimistic 2PL │ TryLock every │ │ nothing to check │
│ write (+ wait) │ │ (locks held) │
└────────────────┘ └──────────────────┘
optimistic OCC │ nothing │ │ CheckKey per │
│ (buffer only) │ │ written key │
└────────────────┘ └──────────────────┘
contention ↑ ⇒ OCC abort rate ↑ (wasted work); 2PL queue depth ↑ (waits).
Questions for notes.md
- Why can OCC validation use the memtable only? What property of LSM seq numbers makes “not in memtable ⇒ too old to conflict… unless memtable is too young” sound — and what does the TryAgain path cost a retry loop?
- The pessimistic lock manager stripes by key hash. What’s the pathology for a graph workload where every txn touches the same super-node’s adjacency entries?
- Neither flavor validates READ sets by default — so what isolation do you actually get, and where does write skew sneak in?
- FalkorDB angle: GRAPH.QUERY writes are single-threaded today (one writer). If M8 keeps single-writer, which of these two machineries do you still need? (Hint: none for w-w; what about r-w validation for serializable reads?)
Done when
You can explain, with file:line, where each school pays its conflict cost, and why both can share one write-buffering base class.
References
Code
- rocksdb —
utilities/transactions/:transaction_base.{h,cc}(shared skeleton),optimistic_transaction.{h,cc}+transaction_util.cc(OCC),pessimistic_transaction.{h,cc}+lock/point/point_lock_manager.h(2PL); ~1.5 h
SSI: serializable snapshot isolation without blocking anyone
How postgres turned SI into SERIALIZABLE with passive markers instead of blocking locks — Ports & Grittner’s VLDB ’12 account of productionizing Cahill’s dangerous-structure theorem. Prereq: the Berenson critique (reading-ansi-critique.md) — you need write skew cold.
The theory in one diagram
Every SI anomaly contains a dangerous structure: two consecutive rw-antidependencies with the pivot in the middle:
rw rw
T_in ────► T_pivot ────► T_out rw edge: T reads x, then U writes x
(U "un-reads" T's snapshot)
… and T_out commits FIRST of the three.
Cahill’s theorem: every non-serializable SI execution has this shape. So: track rw-antidependency edges; when a txn accumulates BOTH an inbound and an outbound rw edge (it became a pivot), abort somebody. This is conservative — some aborted histories were actually fine (false positives) — but it never misses a real cycle.
The whole detector, conceptually — two flags per transaction and one rule:
#![allow(unused)]
fn main() {
fn on_rw_antidependency(reader: TxnId, writer: TxnId, g: &mut ConflictGraph) {
g[reader].out_rw = true; // reader ──rw──► writer
g[writer].in_rw = true;
for t in [reader, writer] {
if g[t].in_rw && g[t].out_rw { // t became a pivot:
abort_someone(t, g); // T_in ─rw─► t ─rw─► T_out
} // conservative: false positives yes,
} // missed cycles never
}
}
Doctors write skew as the structure: T1 reads bob’s row (later written by T2) ⇒ T1 ──rw──► T2; T2 reads alice’s row (later written by T1) ⇒ T2 ──rw──► T1. A cycle of length 2 — each txn is a pivot.
What the paper adds over Cahill (§4–§7)
- SIREAD locks — not locks at all: passive markers “I read this”, at tuple/page/relation granularity with escalation under memory pressure (coarser = more false aborts, never wrong results). Predicate reads are handled by locking the read RANGE via index pages — this is the answer to phantoms that key-based OCC (RocksDB Q3) can’t give.
- Commit ordering refinement — only abort when T_out committed first (fewer false positives than raw Cahill).
- Safe snapshots & DEFERRABLE — a read-only txn can prove it can never be part of a dangerous structure and drop ALL tracking; RO backups run at serializable for free (after possibly waiting).
- Memory bounding — SIREAD state must survive commit (rw edges can form after you commit!) and is only cleaned when overlapping txns end; §7’s summarization is the price of bounded RAM.
- 2PC interaction, and why prepared transactions make everything worse.
The costs (§8, the honest part)
- ~7% overhead on their benchmarks at low contention; abort rate is the real cost and it’s workload-shaped (hot rw pairs → pivot storms).
- Retry is the application’s job: serialization_failure (40001) means “run it again”, so SSI only works if the app loops.
Questions for notes.md
- Why must SIREAD locks outlive commit? Construct the history where the dangerous structure completes after the reader committed.
- Lock escalation trades memory for false aborts. Where’s the same trade in your mvcc.rs Serializable mode (hint: your read-set granularity is whole keys — what’s the graph equivalent of escalating to a relation)?
- Read-only txns: why can they NEVER be T_pivot? (Which edge can’t they have?) How does that justify the safe-snapshot optimization?
- M8: FalkorDB is single-writer. With exactly one writer at a time, can a dangerous structure form at all between two write txns? Between a writer and concurrent readers? So is SSI machinery needed, or does single-writer + SI already equal serializable? (Prove it with the pivot definition — this is the M8 design shortcut.)
Done when
You can draw the dangerous structure from memory, place both write-skew txns on it, and answer Q4 — it decides how much of this paper M8 needs.
References
Papers
- Ports & Grittner — “Serializable Snapshot Isolation in PostgreSQL” (VLDB 2012, arXiv:1208.4179) — ~1.5 h; §4–§7 are the production engineering, §8 the honest costs
- Cahill, Röhm, Fekete — “Serializable Isolation for Snapshot Databases” (SIGMOD 2008) — the dangerous-structure theorem this paper productionizes; the theorem statement is enough
The minimal transactional KV interface: surrealdb’s kvs layer
surrealdb doesn’t implement MVCC — it abstracts over engines that do (tikv, foundationdb, rocksdb, in-memory…), which forces it to define the minimal transactional interface a multi-model DB needs. Read this one for ARCHITECTURE, not algorithms: that interface is a good checklist for M8’s storage-backend abstraction (M1).
1. The layering
Datastore (ds.rs) ── transaction() :3353 ──► Transaction (tx.rs:94)
│ caching + typed keys
▼
Transactor (tr.rs:37)
│ uniform async KV-txn API
▼
engine flavor (mem/rocksdb/tikv/fdb…)
TransactionType(tr.rs:15): justRead | Write— declared UP FRONT at begin. Compare postgres (any txn can write) — declaring intent enables single-writer engines and read-only fast paths.LockTypeonDatastore::transaction()(ds.rs:3353):Optimistic | Pessimistic— the CHOICE of school is a per-transaction parameter passed down to engines that support both. The two RocksDB flavors you just read are literally behind this flag.TransactionFactory(ds.rs:314) / builder plumbing (ds.rs:450–571): the multi-backend dispatch. M1’sStorageBackendtrait, grown up.
2. The Transactor API (tr.rs) — read the signatures
get/getm/getr/getp(:119–155) all takeversion: Option<u64>— versioned point-in-time reads are part of the public KV contract, not an engine internal. (Only some engines honor it; capability, not guarantee.)set:166 vsput:190 vsputc:202 — put fails if the key exists; putc is compare-and-set on the current value: optimistic concurrency primitives exposed as API, so upper layers can do OCC over any engine.commit:103 /cancel:95 — commit is where engine-level conflict errors surface; the query layer retries.
3. Transaction (tx.rs:94, impl :693)
Wraps Transactor with typed keys and read-through caches. The thing to notice: caching inside a transaction is trivially correct — the snapshot is immutable, so a within-txn cache never invalidates. (Topic 6’s hardest problem — invalidation — deleted by MVCC.)
Questions for notes.md
version: Option<u64>on every read: what does time-travel-as-API cost the engines that support it (GC can’t drop what an API can name)?- Read/Write declared at begin: what optimizations does that unlock for a single-writer engine? What does FalkorDB’s GRAPH.RO_QUERY vs GRAPH.QUERY split already encode?
- putc (CAS) as the portable OCC primitive: sketch how you’d build first-committer-wins snapshot isolation on top of ONLY get/putc.
- M1 retrospective: does your storage-backend trait from topic 1 admit a transactional backend, or did you bake in auto-commit? What would you change now?
Done when
You can list the 6–8 operations a transactional KV interface needs to support a multi-model DB, and say which are capabilities vs guarantees.
References
Code
- surrealdb —
surrealdb/core/src/kvs/:ds.rs,tr.rs,tx.rs; ~1 h — read the Transactor signatures in tr.rs, they ARE the interface checklist
Topic 8 notes — transactions & MVCC
Predict FIRST, then measure.
Predictions (fill in BEFORE running txn_bench)
| Measurement | Prediction | Actual | Surprised? |
|---|---|---|---|
| read-heavy 95/5, 10K keys: lock vs mvcc txn/s | |||
| write-heavy 50/50, 10K keys: lock vs mvcc | |||
| write-heavy 50/50, 64 hot keys: lock vs mvcc | |||
| abort count on the 64-key mix (out of 200K txns) | |||
| where’s the crossover (keyspace size at which the mutex wins)? |
Reasoning space:
- The mutex serializes even pure readers; MVCC readers only touch the
metadata lock briefly per op. But your MVCC pays per-version allocation
- retry loops. At what abort rate does retrying erase the win?
- 4 threads, 4 ops/txn, 64 keys, 50% writes → estimate P(two concurrent txns share a written key) before you look at the abort column.
Implementation log (mvcc.rs design decisions)
- Version storage: append-only newest-to-oldest? Where does your design sit in the Wu/Pavlo 5-axis table? (Fill the row.)
- What exactly does your metadata mutex protect, and what would it take to shard it (topic 7’s SHARDS trick — does it apply cleanly here)?
- Label each test with its Berenson phenomenon (P1/P4/A5A/A5B) in a comment — the ansi-critique guide asks for this.
- Serializable = backward read-set validation. Construct one history your validator aborts that SSI would have allowed (a false positive).
Questions — reading-postgres-heapam.md
- Index-entry deletion before line-pointer recycling: the corruption if flipped?
- Hint bits make reads write — which topic-6 consequence?
- XidInMVCCSnapshot at 10K writers vs Hekaton timestamps: costs?
- Where do old versions live in a matrix-backed graph — append-only or delta?
Questions — reading-rocksdb-transactions.md
- Why is memtable-only OCC validation sound (and when does it TryAgain)?
- Striped key locks vs a super-node’s adjacency: the pathology?
- No read-set validation by default — what isolation, where does skew enter?
- Single-writer M8: which machinery survives?
Questions — reading-ansi-critique.md
- Doctors write skew in history notation; which phenomenon does it evade?
- Why first-committer-wins can’t catch write skew (one sentence).
- Predicate phantoms for MATCH — label matrix? index range? key locks?
- Test-to-phenomenon mapping done in mvcc.rs comments?
Questions — reading-ssi-postgres.md
- History where the dangerous structure completes after the reader commits?
- Your read-set granularity vs SIREAD escalation — the graph equivalent?
- Why can a read-only txn never be the pivot?
- The M8 shortcut: single-writer + SI — prove (with the pivot definition) whether it’s already serializable. Write the argument here:
Questions — reading-inmemory-mvcc.md
- end_ts-as-lock CAS pseudocode; the equivalent check in your mvcc.rs is at line ___.
- Delta matrices in the Wu/Pavlo taxonomy; predicted read-path cost?
- Logical node-ids as indirection: which graph updates still touch indexes?
- Write-only hot key vs cooperative GC — the fix?
- MVOCC at 40 cores: validation aborts or ts allocation? (Predict, then check §6.)
The 5-axis placement (fill after all readings)
| System | CC | Version storage | Ordering | GC | Index ptrs |
|---|---|---|---|---|---|
| postgres | SI+SSI | append-only (in heap) | old-to-new (t_ctid) | vacuum+prune | physical (TID) |
| Hekaton | |||||
| my mvcc.rs | |||||
| M8 design |
M8 log (capstone milestone)
- mvcc.rs passes all 8 tests (write skew demonstrated AND prevented)
- txn_bench run; all predictions scored above
- MVCC graph design written: version unit (matrix/tile/delta), CoW granularity, reader visibility rule
- single-writer/multi-reader argument from SSI Q4 recorded — decides whether M8 needs validation at all
- reference’s mvcc_graph.rs / cow.rs studied; diff vs my design noted
Topic 9 — Concurrency: Latches, Lock-Free & Epochs
Scaling across cores is where the hardest bugs and the biggest wins live. Topic 8 asked “who sees what” for transactions; this topic asks the same question for NANOSECONDS: two threads, one cache line, who wins?
Budget: ~12 h. Order: §1 vocabulary → §2 memory ordering → §3 latch protocols → §4 reclamation → §5 code → experiments → M9.
1. Latches vs locks (say it right)
| lock (topic 8) | latch (this topic) | |
|---|---|---|
| protects | logical content (rows, predicates) | physical structure (a node, a page) |
| held for | a transaction (seconds) | a critical section (nanoseconds) |
| deadlock | detected/resolved | must be IMPOSSIBLE by ordering |
| implemented by | lock manager table | atomics in the object itself |
Everything in this topic is latches. The three escalation rungs:
mutex/rwlock → optimistic (version check) → lock-free (CAS)
block on conflict restart on conflict never block; help
postgres LWLock LeanStore HybridLatch (T6) RocksDB memtable,
+ OLC B-trees crossbeam SkipSet
2. Memory ordering in one table (Rust atomics)
| Ordering | Guarantee | When you reach for it |
|---|---|---|
Relaxed | atomicity only, no ordering | counters, stats |
Acquire (loads) | later reads/writes can’t move before it | reading a “ready” flag |
Release (stores) | earlier reads/writes can’t move after it | publishing a “ready” flag |
AcqRel | both, for RMW ops | CAS that links a node |
SeqCst | one global order of all SeqCst ops | when you can’t prove less is enough |
The publication idiom that everything below builds on:
writer: node.data = 42; (plain writes)
list.next.store(node, Release); ← publish
reader: let n = list.next.load(Acquire); ← subscribe
n.data // guaranteed to see 42
memgraph’s fully_linked.store(true, memory_order_release) and RocksDB’s
CASNext are both exactly this idiom. x86 gives you Acquire/Release for
free (TSO); ARM (this Mac!) does not — wrong orderings that “pass” on
x86 crash on the M-series. Test here.
3. Latch protocols for trees & lists
- Latch coupling (topic 3’s B-trees): hold parent, grab child, release parent. Correct, but every traversal WRITES the latch cache line — root’s line ping-pongs between all cores. Read-scaling: none.
- Optimistic latch coupling (OLC, Leis): version counter per node. Readers read version → read node → re-check version; restart if changed. Writers latch + bump. Reads write NOTHING shared. This was LeanStore’s HybridLatch (topic 6) — same trick, now you study it as a protocol.
- Lock-free: no latch at all; every mutation is one CAS that either lands or retries. The hard part is never the CAS — it’s DELETION (§4) and multi-pointer updates (the skiplist’s towers, Bw-tree’s SMOs).
4. The reclamation problem (the actual boss fight)
Lock-free reads mean a reader may hold a pointer to a node you just
unlinked. free() it and the reader explodes. Options:
epoch-based (crossbeam, this topic's build)
┌────────────────────────────────────────────────────┐
│ global epoch E ────────► 3 garbage bags: E, E-1, E-2│
│ reader: pin() → local epoch = E │
│ writer: unlink node → defer_destroy into bag E │
│ advance: only when ALL pinned locals reached E │
│ free bag E-2: nobody can still see its nodes │
└────────────────────────────────────────────────────┘
hazard pointers: per-reader "I'm reading THIS ptr" slots — O(readers)
scan per free, but bounded garbage (epochs can be wedged by one stall)
accessor ids (memgraph): each accessor gets a monotonic id; a retired
node waits until all accessors older than the retire-time are gone —
epoch flavor with txn-scoped pins
RCU/QSBR (kernel): quiescent states instead of pins
Trade to internalize: epochs make READS free (one pin per operation, no per-pointer traffic) but garbage unbounded under a stalled reader. Hazard pointers invert it. Databases almost always pick epochs — readers outnumber stalls.
5. Bw-tree: the cautionary tale
ICDE’13: a fully lock-free B-tree — updates are DELTA RECORDS prepended by CAS onto a mapping table entry; splits are multi-step state machines. SIGMOD’18 (“…More Than Just Buzz Words”) rebuilt it honestly: delta chains wreck cache locality, consolidation needs tuning, and a well-built OLC B+tree beats it on almost every workload. Lesson: optimistic latches + epochs is the pragmatic frontier; fully lock-free indexes are usually a research flex. (Read both; guide: reading-bwtree.md.)
6. False sharing (the silent 10×)
Two ATOMICS in one 64B/128B cache line = every write invalidates the other
core’s line even though the data is “independent”. redis padded its
per-thread used_memory counters (topic 6); you’ll measure the effect in
false_sharing.rs (M-series lines are 128B — check both alignments).
7. Code to read (guides in this dir)
| Guide | What you’ll trace |
|---|---|
| reading-postgres-lwlock.md | One word, one CAS, one queue: postgres’s production rwlock |
| reading-crossbeam-epoch.md | Epoch reclamation: the GC that makes lock-free reads free |
| reading-concurrent-skiplists.md | Two concurrent skiplists: CAS vs lazy locking |
| reading-bwtree.md | Bw-tree vs OLC: why lock-free lost to optimistic latches |
8. Experiments (experiments/)
src/concurrent_set.rs— YOU make topic 2’s skiplist concurrent: lock-free insert/contains/remove over crossbeam-epoch. Tests fix the contract (disjoint-key races, same-key races → exactly one winner, remove-under-readers doesn’t UAF).src/bin/scaling.rs— provided: 1→16 threads, 90/10 read/write mix:Mutex<BTreeSet>vs 16-shard mutex vs crossbeamSkipSet(reference) vs yours. The mutex line runs today; predict the shapes first.src/bin/false_sharing.rs— provided, runs now: packed vs padded atomic counters, 8 threads. Predict the ratio on this M-series Mac.
9. M9 checklist (capstone)
- threadpool.rs: fixed pool, work queue, no per-query spawn. Compare against the reference’s design (steal or not? — recall the Glommio/tokio trade from topic 7)
- single-writer/multi-reader graph: readers pin an epoch + version (M8’s snapshot), writer publishes new matrix versions with Release
- parallel query execution over read snapshots — where does GraphBLAS’s own parallelism meet the pool? (One pool, not two — decide who owns the threads)
- contention profile: Instruments “System Trace”/cachegrind stand in for perf c2c on macOS; find one false-sharing line in your code
Bw-tree vs OLC: why lock-free lost to optimistic latches
Three papers, one arc: the most radical lock-free index ever shipped (the Bw-tree, ICDE ’13), the paper that measured it honestly (SIGMOD ’18), and the modest protocol that won (optimistic lock coupling). The arc is this topic’s thesis in miniature — the memory hierarchy, not elegance, decides which concurrency scheme survives.
1. “The Bw-Tree” (Levandoski et al., ICDE ’13)
The design (Hekaton’s and DocumentDB’s index):
mapping table: PID ─► pointer update = CAS the PID slot:
┌─────┐ Δ(insert k) ──┐
│ P17 ├──► Δ(delete k₂) ─► Δ(insert k₁) ─► base node │
└─────┘ newest ◄──────────────── oldest │
CAS(P17, old_head, Δnew) — ONE atomic pointer swap per update,
no in-place writes, no latches anywhere.
- Mapping table = indirection layer: nodes are logical PIDs, so relocating/consolidating a node is a CAS, and parent pointers never change. (Wu/Pavlo’s “logical pointers” verdict, topic 8 — same lesson.)
- Delta chains: readers reconstruct the node by walking deltas until a base page; consolidation folds chains back into a base node when too long.
- SMOs (splits/merges) are multi-step: half-split posts a split-delta, then a separate CAS installs the parent entry; every THREAD that encounters a partial SMO must help complete it — cooperative state machines instead of latched critical sections.
- Epochs for reclamation (you know this now).
2. “Building a Bw-Tree Takes More Than Just Buzz Words” (SIGMOD ’18)
CMU rebuilt it (OpenBw-Tree) and measured against OLC B+tree, Masstree, ART, skiplist:
- Delta chains murder cache locality: a point read is a pointer chase through K deltas (recall topic 0’s ladder — each hop is a potential DRAM miss) vs a B+tree’s two cache-resident binary searches.
- The mapping table’s CAS becomes the contention point under skew: hot PID = hot cache line — you moved contention, not removed it.
- Consolidation policy is a whole tuning surface (their §4.2 “component breakdown” is the useful table — read it as a bill of costs).
- Verdict: OLC B+tree is 1.5–4× faster on most workloads and ~10× simpler. “Lock-free” bought worse constants, not scalability.
3. “Optimistic Lock Coupling” (Leis et al.) — what won
- Per-node: version counter + lock bit (one u64 — LeanStore’s HybridLatch, topic 6, IS this).
- Reader: read version (spin if locked) → read fields → validate version unchanged → proceed; else RESTART from a safe ancestor. Readers write no shared memory — root’s cache line stays Shared in every core’s L1.
- Writer: acquire lock bit (CAS), mutate, release = version+1.
- Coupling: validate parent’s version AFTER reading the child pointer — the pair (read child ptr, revalidate parent) replaces “hold parent latch while grabbing child”.
- Restarts need: no torn reads that can fault (reads of freed memory must be survivable ⇒ epochs again, or never-free node memory).
The entire reader protocol fits in a loop — note what it never does: write shared memory.
#![allow(unused)]
fn main() {
fn read_node<T>(n: &Node, read: impl Fn(&Node) -> T) -> T {
loop {
let v1 = n.version.load(Acquire);
if v1 & LOCKED != 0 { spin_wait(); continue; } // writer active
let out = read(n); // read optimistically...
if n.version.load(Acquire) == v1 {
return out; // ...nothing moved: done
} // else a writer intervened:
} // restart — the only cost
}
}
The arc, in one line
Indirection + deltas (Bw) lost to versions + restarts (OLC) because the memory hierarchy prices pointer chases higher than optimistic retries.
Questions for notes.md
- A Bw-tree point-read with a 6-delta chain: count likely cache misses vs an OLC B+tree of the same size (use your topic-0 numbers).
- Why must helpers complete OTHER threads’ SMOs? What deadlock/livelock does “just wait for the owner” reintroduce?
- OLC readers restart on any concurrent write to a node on their path. Estimate restart probability for a 4-level tree under 1% node-write rate — why is it negligible? When isn’t it (hot leaf)?
- Delta chains ARE topic 20’s delta matrices (pending updates folded on read, consolidated lazily). Why does the trade favor deltas for sparse matrices when it condemned them for B-tree nodes? (Hint: amortization unit — one row read vs one mxm over millions.)
- M9/M13: FalkorDB’s matrices already sit behind a “mapping table” (label → matrix pointer). Which Bw-tree lesson transfers: CAS the matrix pointer for CoW publication? Which does NOT (delta chains per node)?
Done when
You can argue both sides — why Bw-tree looked inevitable in 2013 and why OLC won by 2018 — with the cache-line-level reasons, not slogans.
References
Papers
- Levandoski, Lomet, Sengupta — “The Bw-Tree: A B-tree for New Hardware Platforms” (ICDE 2013) — the design; §II–IV
- Wang, Pavlo et al. — “Building a Bw-Tree Takes More Than Just Buzz Words” (SIGMOD 2018) — the reality check; §4.2’s component breakdown is the useful table, read it as a bill of costs
- Leis et al. — “Optimistic Lock Coupling: A Scalable and Efficient General-Purpose Synchronization Method” (IEEE Data Eng. Bulletin 2019) — short; the protocol that won
Two concurrent skiplists: CAS vs lazy locking
Same structure, two schools of coordination: RocksDB’s memtable skiplist links nodes with per-level CAS and never deletes; memgraph’s skiplist — the spine of its whole graph store — uses per-node spinlocks, state bits, and real deletion with GC. Read RocksDB first (you know this file from topic 2 — now the concurrency), then memgraph as the contrast.
1. RocksDB InlineSkipList — CAS school
~/repos/rocksdb/memtable/inlineskiplist.h
- The contract (:23):
InsertConcurrentlyis safe with concurrent reads AND writes — but the LSM makes it easier: memtable entries are never deleted (topic 4: deletion = tombstone insert; the whole memtable dies at flush). No delete ⇒ no reclamation problem ⇒ no epochs needed. Always ask “what did the workload let them NOT solve?” CASNext(:393): the linking primitive — onecompare_exchange_strongper level. Insert per level: read pred/succ, setnew->next = succ(relaxed — unpublished), CAS pred->next from succ to new; on failure re-find just that level and retry.
#![allow(unused)]
fn main() {
fn link_at_level(mut pred: &Node, new: &Node, lvl: usize) {
loop {
let succ = pred.next[lvl].load(Acquire);
new.next[lvl].store(succ, Relaxed); // unpublished yet: plain write
if pred.next[lvl]
.compare_exchange(succ, new, Release, Relaxed) // publish
.is_ok() { return; }
pred = refind_pred(new.key, lvl); // lost the race — re-find
} // ONLY this level, then retry
}
}
Splice(:64): a cached array of (pred, succ) per level — the search is the expensive part, so sequential writers reuse the previous insert’s splice (Insert(key, splice, ...):1028, hint variant :113) andRecomputeSpliceLevels(:331/:1016) repairs only the invalid levels. Amortize the O(log n) search across nearby inserts.Insert:908 vsInsertConcurrently:913 — same template,UseCASflag: single-writer mode skips atomics. The single-writer fast path is a compile-time choice. (M9 note: FalkorDB’s single writer can take exactly this door.)
2. memgraph SkipList — lazy-locking school (Herlihy et al.)
~/repos/memgraph/src/utils/skip_list.hpp
- Node (:156): per-node
SpinLock(:163),marked(:164),fully_linked(:165), flexible-array towernexts[0](:169) — the same intrusive-tower trick as RocksDB, plus TWO state bits. - Insert (:1335):
find_node(:1285) collects preds/succs, LOCK the preds bottom-up, re-validate, link all levels, then PUBLISH withfully_linked.store(true, release)(:1398). Readers ignore half-linked nodes — publication idiom with a bit instead of a CAS’d pointer. - Remove (:1655): lock,
marked.store(true, release)(:1672) — logical delete first (readers skip marked nodes), THEN unlink. Deletion exists here, so reclamation must too: - Accessor-id GC (:244–246,
SkipListGc:257,Collect:367): everyAccessor(:877) gets a monotonically increasing id; a retired node records the newest alive accessor id; free when all older accessors are gone. Epoch reclamation with transaction-scoped pins — compare crossbeam’s 3-epoch scheme; same idea, coarser pin. kSkipListGcHeightTrigger(:69) andcreate_chunks(:817–955 — chunked parallel iteration for analytics) show this is the SPINE of memgraph: vertices, edges, and indexes all live in these lists.
3. The comparison table (fill it in notes.md)
| RocksDB | memgraph | |
|---|---|---|
| writers coordinate by | CAS per level | per-node spinlocks |
| readers see partial insert? | yes — per-level linking is independent (fine for a set) | no — fully_linked gate |
| delete | never (tombstones) | marked bit + unlink |
| reclamation | none needed (arena dies at flush) | accessor-id GC |
| failure/retry | re-find level, re-CAS | unlock all, restart |
Questions for notes.md
- RocksDB dodged reclamation via arena-per-memtable. What’s the graph equivalent — arena per matrix version? Does M8’s CoW give M9 the same dodge (old version dies wholesale when last reader leaves)?
- Why does the lazy list lock preds BOTTOM-up and validate after locking? Construct the lost-insert without validation.
- A splice cache assumes locality of consecutive inserts. Does a graph bulk-load (sorted node ids) hit that path? What about random edges?
- Which school for YOUR concurrent_set.rs — and what does crossbeam-epoch give you that lets you pick CAS with deletion (the combination neither production list needed)?
Done when
You can fill the table from memory and explain what each system’s workload allowed it to NOT build.
References
Papers
- Herlihy, Lev, Luchangco, Shavit — “A Simple Optimistic Skiplist Algorithm” (SIROCCO 2007) — the lazy-locking design memgraph implements
Code
- rocksdb
memtable/inlineskiplist.h— start at the :23 contract comment - memgraph
src/utils/skip_list.hpp— one header holds the list, the accessors, and the GC
Epoch reclamation: the GC that makes lock-free reads free
Lock-free deletion’s boss fight is reclamation — when is it safe to
free() a node some reader might still hold? crossbeam-epoch answers with
three garbage bags and a global epoch counter, and it’s the crate your
concurrent_set.rs builds on — read it first so pin() isn’t magic.
1. The API surface (what you’ll actually call)
epoch::pin()(default.rs:42) →Guard(guard.rs:70). While a guard lives, no garbage from the current epoch is freed. Cost: ~one SeqCst fence + thread-local bump. Pin once per OPERATION, not per pointer.Guard::defer_destroy(ptr)(guard.rs:271) /defer(:90 — arbitrary closures, unchecked variant :189) — “free this when safe”.Atomic<T>/Shared<'g, T>: an atomic pointer whose loads are lifetime-tied to a guard — the borrow checker enforces “no pointer outlives its pin”. This is the Rust-shaped part hazard pointers lack.
2. The machinery (internal.rs)
Local(:293) — per-thread: its pinned epoch + garbage bag. Threads register into a global intrusive list.defer(:382): garbage goes into the LOCAL bag first (no contention), sealed into the global queue tagged with the current epoch when full.- The advance trigger: every
PINNINGS_BETWEEN_COLLECT = 128pins (:335, check at :454–456), the pinning thread callscollect(:208) →try_advance(:237). try_advance: scan ALL registered threads; if anyone is pinned in an OLDER epoch, bail. Otherwise bump the global epoch. Freeing is then “pop bags ≥ 2 epochs old”.
#![allow(unused)]
fn main() {
fn try_advance(global: &Global) -> Epoch {
let e = global.epoch.load(Acquire);
for thread in global.registered_threads() {
let local = thread.epoch.load(Acquire);
if local.is_pinned() && local != e {
return e; // a reader still lives in e-1:
} // its pointers may reach that garbage
}
global.epoch.store(e.next(), Release); // everyone at e ⇒ advance;
e.next() // bags two epochs back are free
}
}
global epoch: E
thread A: pinned @ E ─┐
thread B: pinned @ E ├─ all @ E ⇒ advance to E+1
thread C: unpinned ─┘
bags: [E-2: freeable] [E-1: wait] [E: filling]
one thread stuck pinned @ E-1 ⇒ epoch NEVER advances ⇒ unbounded garbage
(the epoch weakness; hazard pointers bound garbage instead)
3. Idioms for your concurrent_set.rs
- Amortize-and-batch AGAIN: local bag → sealed batch → global queue → collect every 128 pins. Compare valkey’s SPSC batches (topic 7) and redis incremental rehash (topic 2).
try_advanceis O(threads) — that’s the cost hazard pointers pay per FREE; epochs pay it per ADVANCE attempt. Amortization decides winners.- Read
Guard’s docs on repinning (repin/repin_after) — long-running readers (a full graph scan!) must repin or they wedge the collector. This is M9’s “reader holds a snapshot for 10 s” problem in miniature.
Questions for notes.md
- Why three epochs and not two? Construct the interleaving where a node retired in E is still reachable by a thread pinned in E-1.
- What does
Shared<'g, T>’s lifetime buy over C++ epoch libraries? Which bug class does it delete at compile time? - A reader pins, then blocks on disk I/O for 100 ms (topic 6’s pool does this under a miss!). What happens to memory usage? What’s the fix — repin, unpin-before-IO, or hazard pointers?
- M9: FalkorDB queries can run for seconds. Is epoch-per-operation the right granularity, or epoch-per-morsel (topic 11 foreshadowing)?
Done when
You can explain, without the source, why defer_destroy in epoch E can
free at E+2, and what single thread behavior wedges the whole scheme.
References
Code
- crossbeam —
crossbeam-epoch/src/:default.rs(pin),guard.rs(Guard, defer_destroy — read its repinning docs),internal.rs(Local, try_advance); ~1.5 h
One word, one CAS, one queue: postgres’s production rwlock
lwlock.c is the latch under every buffer, WAL insert, and proc-array scan you met in topics 5–8. One u32 of state, a CAS fast path, and an intrusive wait queue — read it as the reference answer to “how do I build a fair rwlock that doesn’t melt at 128 cores”.
1. The packed state word (:49, :96–118)
u32 state:
┌─────────────┬──────────────┬──────────────────────────────┐
│ FLAG bits │ LW_VAL_EXCLUSIVE = MAX_BACKENDS+1 │
│ HAS_WAITERS │ LW_VAL_SHARED = 1 │
│ RELEASE_OK │ → shared holders are a COUNT in the low bits│
└─────────────┴──────────────────────────────────────────────┘
exclusive = add LW_VAL_EXCLUSIVE; shared = add 1.
"is it free for X?" = (state & LW_LOCK_MASK) == 0 — one load.
Same trick as postgres’s buffer state (topic 6) and Hekaton’s end_ts-as-lock (topic 8): pack refcount + flags into one atomic word so every protocol step is a single CAS. The static assert at :117 is the kind of test bit-packing demands.
2. Fast path — LWLockAttemptLock (:764)
CAS loop: load state, compute desired (:788 exclusive add, :792 free check for shared), compare-exchange, retry on spurious/contended failure. No syscall, no queue touch. THE hot path — every buffer pin in a scan goes through here.
3. Slow path — queue then sleep
LWLockQueueSelf:1018 — add me toproclist(:680 — an intrusive list of PGPROC entries, no allocation: the waiter structure lives in the proc array, same idea as intrusive skiplist nodes).-
- The wait-list itself is protected by a SPINLOCK with backoff:
- 860–880
perform_spin_delay— spin, then sleep escalation; stats countspin_delay_count(:246) so contention is observable.
- The double-check dance in
LWLockAcquire:1150: attempt → queue self → attempt AGAIN → only then sleep. Without the second attempt, a release between attempt and enqueue leaves you sleeping forever (lost wakeup).LWLockDequeueSelf:1061 handles the “won on the recheck” undo. This pattern (test, enqueue, re-test) is THE lesson of the file.
#![allow(unused)]
fn main() {
fn acquire(lock: &LwLock, mode: Mode) {
loop {
if try_cas(lock, mode) { return; } // fast path: one CAS, no queue
queue_self(lock); // slow: enqueue FIRST...
if try_cas(lock, mode) { // ...then attempt AGAIN —
dequeue_self(lock); // a release may have slipped in
return; // between attempt and enqueue
}
sleep_until_woken(); // safe now: our queue entry is
} // visible, releaser must wake us
}
}
LWLockRelease:1767 →LWLockWakeup:904: wakes the queue head; a released shared lock wakes waiting readers as a batch, and RELEASE_OK prevents wakeup storms.
4. What to steal for M9
- one-word state + CAS fast path for your HybridLatch-style version latch
- intrusive wait queues (no allocation on the slow path)
- observable contention counters from day one
Questions for notes.md
- Why must the shared count live in the SAME word as the exclusive bit? Sketch the race if they were two atomics.
- The recheck-after-enqueue: write the lost-wakeup interleaving it prevents, as a 2-thread timeline.
- LWLocks are non-recursive and panic on double-acquire in assert builds. Why is recursion banned for latches but fine for locks?
- Compare with
std::sync::RwLockon macOS (pthread rwlock): what does postgres gain by rolling its own? (Think: fairness policy, no syscall on fast path, stats, and the queue living in shared memory.)
Done when
You can draw the full acquire path — fast CAS, queue, recheck, sleep, wakeup — from memory, and name the race each step exists to close.
References
Code
- postgres
src/backend/storage/lmgr/lwlock.c— ~1.5 h; start at the state-word definitions (:49, :96–118), thenLWLockAttemptLockandLWLockAcquire
Topic 9 notes — latches, lock-free & epochs
Predict FIRST, then measure.
Measured already (false_sharing, provided binary — this Mac, 8 threads)
| layout | time | rate |
|---|---|---|
| packed | 636 ms | 63 M inc/s |
| pad64 | 24 ms | 1697 M inc/s |
| pad128 | 11 ms | 3707 M inc/s |
- 59× packed → pad128. “Independent” counters in one line are not
independent — this is the whole reason redis pads
used_memory. - pad64 is still 2.2× slower than pad128: Apple M-series coherence
granularity is 128 B.
#[repr(align(64))], the x86 default, HALF-fixes false sharing on this machine. Check every CachePadded assumption.
Predictions (fill in BEFORE running scaling.rs)
| Measurement | Prediction | Actual | Surprised? |
|---|---|---|---|
| global mutex: shape 1→16t | |||
| sharded-16: where does it stop scaling? | |||
| crossbeam SkipSet 16t vs global 16t (×?) | |||
| my ConcurrentSet vs crossbeam at 16t | |||
| my set at 1 thread vs topic-2 sequential skiplist |
Reasoning space:
- 90/10 mix: the global mutex serializes READS too — estimate its ceiling from one uncontended lock/unlock (~20 ns?) per op.
- 16 shards, 16 threads, uniform keys: collision probability per op ⇒ expected stall fraction (birthday-ish). Where’s the knee?
- Lock-free reads scale with cores until… what? (memory bandwidth, allocator, epoch advance O(threads) scans)
Implementation log (concurrent_set.rs)
- Which school did you pick (CAS-lazy hybrid?) and what does level-0-CAS- as-linearization-point simplify vs memgraph’s lock-preds-validate?
- Where exactly is Release/Acquire load-bearing? List each ordering and
the test that fails on this ARM Mac if it were Relaxed (try it — flip
one and run
same_key_race50×). - Tag-bit marking via
Shared::with_tag: bit-smuggling ledger update — where else this repo has seen it (SwissTable meta, swips, valkey jobs). cargo miri testresult (readers_survive_concurrent_removal_churn is the UAF canary):
Questions — reading-postgres-lwlock.md
- Shared count + exclusive bit in ONE word: the race if split in two?
- Lost-wakeup timeline that recheck-after-enqueue prevents?
- Why are latches non-recursive by design?
- What does rolling their own buy over pthread rwlock?
Questions — reading-crossbeam-epoch.md
- Why 3 epochs, not 2 (interleaving)?
- What bug class does
Shared<'g>’s lifetime delete? - Reader pins then blocks on I/O 100 ms — consequence and fix?
- Epoch-per-operation vs per-morsel for second-long graph queries?
Questions — reading-concurrent-skiplists.md
- Arena-per-memtable dodge → does M8 CoW give M9 the same dodge?
- Lost-insert without validate-after-lock (construct it)?
- Splice cache: bulk-load vs random edges?
- Comparison table filled from memory?
Questions — reading-bwtree.md
- 6-delta point read: cache misses vs OLC B+tree (topic-0 numbers)?
- Why must helpers finish others’ SMOs?
- OLC restart probability, 4 levels, 1% write rate — and the hot-leaf case?
- Why do deltas win for sparse matrices but lose for B-tree nodes?
- CAS-the-matrix-pointer: which Bw-tree lesson transfers to FalkorDB?
scaling.rs results (after implementing)
| impl | 1t | 2t | 4t | 8t | 16t |
|---|---|---|---|---|---|
| global | |||||
| sharded | |||||
| crossbeam | |||||
| mine |
M9 log (capstone milestone)
- concurrent_set.rs passes all 5 tests + miri clean
- scaling table recorded; predictions scored
- threadpool.rs designed: work queue, steal or not, who owns threads when GraphBLAS is also parallel (ONE pool decision written down)
- single-writer/multi-reader graph: epoch-pinned readers + Release- published matrix versions — sketch matches M8’s CoW design
- one real false-sharing site found & padded (128 B!) in my code
- reference threadpool.rs studied; diff noted
Topic 10 — Query Engines I: Parsing, Planning, Optimization
The optimizer is the database’s brain — and the part that fails most gracefully-looking while costing 100×. Directly relevant to Cypher planning in FalkorDB.
Budget: ~12 h. Order: §1 pipeline → §2 rewrites → §3 join ordering → §4 cardinality (where it all goes wrong) → §5 architectures → code → experiments → M10.
1. The pipeline
flowchart LR
SQL[SQL text] --> TOK[tokens] --> AST[AST]
AST --> BIND["binder / analyzer\n(names→ids, types)"]
BIND --> LP[logical plan]
LP --> OPT["optimizer\n(rewrites + join order)"]
OPT --> PP["physical plan\n(topic 11 executes this)"]
Logical vs physical, the distinction everything hangs on:
- logical = WHAT:
Join(A, B, a.x = b.y)— algebra, no algorithm - physical = HOW:
HashJoin(build=B, probe=A)— algorithm + costs
One logical plan → many physical plans. The optimizer’s job is a search problem: rewrite the logical plan (safe, always-good transformations), then pick among physical alternatives with a cost model.
2. Rewrite rules (the always-wins)
- Predicate pushdown — filter as close to the scan as possible; every row filtered early is a row every later operator never sees.
- Projection pushdown / unused-column elimination — don’t carry columns nobody reads (decisive for columnar engines, topic 12).
- Constant folding, expression simplification —
1+1=2at plan time. - Cross-join → inner join — a filter mentioning both sides of a cross product IS a join predicate; recognize it or enumerate disaster.
- Subquery decorrelation — turn correlated subqueries into joins (DuckDB’s deliminator; the hardest rewrite family in the pipeline).
These are heuristic (“always good”) — no cost model needed. Join ORDER is the opposite: nothing is always good.
3. Join ordering — the combinatorial core
n tables → Catalan-many trees × orderings: 20-way join ≈ 10¹⁸ plans.
- Selinger DP (1979, still the answer): best plan for a SET of relations is composed of best plans for its subsets. DP over subsets, sized 1..n. O(3ⁿ) worst case but connected-subgraphs-only in practice. Keep “interesting orders” (sorted outputs) as separate DP entries.
- Fallbacks when n is big: postgres switches to a genetic algorithm
at
geqo_threshold(12); DuckDB exits DP for greedy when the pair count explodes (plan_enumerator.cpp:234). - Left-deep vs bushy: Selinger searched left-deep only (pipelining + smaller space); modern engines (DuckDB) search bushy — graph pattern queries especially want bushy plans.
4. Cardinality estimation — where it all goes wrong
Cost models rank plans by estimated CARDINALITIES. The estimates rest on three lies:
| Assumption | Reality | Blowup |
|---|---|---|
| uniformity (1/NDV per value) | skew: one hot value = 50% of rows | 100× |
| independence: sel(a)×sel(b) | correlated columns (city↔country) | 1000× |
| containment for joins | fk distributions vary | 10× per join |
Errors are MULTIPLICATIVE up the plan tree — “How Good Are Query
Optimizers, Really?” (VLDB ’15) measured real systems mis-estimating by
10⁴–10⁶ on the (real-data) JOB benchmark, and showed cardinality error
dwarfs cost-model error. postgres’s shrug when it knows nothing:
DEFAULT_EQ_SEL = 0.005 (selfuncs.h:34) — a constant guess powering
million-dollar plan choices.
Graph angle: a 3-hop Cypher pattern is a 3-way self-join on the edge relation — cardinality = sparse matrix-product size estimation. Same problem, different clothes (M10/M20 will meet it as nnz estimation).
5. Two optimizer architectures
- Selinger / bottom-up: rewrite first, then DP join search with one cost model. postgres, DuckDB, your experiment. Simple, predictable.
- Cascades / top-down (Graefe ’95): everything — rewrites AND physical choices — is a RULE firing in a memo of equivalence groups; search is goal-driven with pruning. SQL Server, CockroachDB, DataFusion aspires. Pay complexity, get extensibility + on-demand exploration.
memo: G1 = {Join(G2,G3), Join(G3,G2), HashJoin(G2,G3), ...}
G2 = {Scan(A), IndexScan(A)} groups = equivalence classes,
G3 = {Scan(B)} members share cardinality
6. Code to read (guides in this dir)
| Guide | What you’ll trace |
|---|---|
| reading-duckdb-optimizer.md | The readable optimizer: DuckDB’s pass pipeline and join-order DP |
| reading-postgres-optimizer.md | Postgres’s optimizer: Selinger ’79, still in production |
| reading-rust-planner-stack.md | The Rust planner stack: Pratt parsing, rule traits, lazy frames |
| reading-selinger-cascades.md | Selinger and Cascades: the two optimizer architectures |
| reading-how-good-optimizers.md | Cardinality is the whole ballgame: the JOB audit |
Further references:
- “Apache Calcite” (SIGMOD 2018) — the optimizer as a library (rules + cost model, no storage, no executor); what DataFusion is to Rust, Calcite is to the JVM world.
- “Spark SQL: Relational Data Processing in Spark” (SIGMOD 2015) — Catalyst: plans as trees, rules as Scala pattern matches; the most widely deployed rewrite-rule engine in existence.
- Learned query optimization: “Learned Cardinalities” (Kipf et al., CIDR 2019) attacks §4’s problem with a model; “Neo” (VLDB 2019) and “Bao” (SIGMOD 2021, Marcus et al.) steer the whole planner — Bao picks among hint sets so the classical optimizer stays as the safety net. Read after the VLDB’15 paper: they are its direct descendants.
7. Experiments (experiments/)
Mini planner: sqlparser-rs parses; YOU build the logical plan, pushdown,
join reordering, and cardinality estimation. Tests fix the contract
(filters sink into scans, join order flips when stats flip, estimates
multiply). explain binary prints before/after plans — compare with
DuckDB’s EXPLAIN on the same queries (bench protocol in notes.md).
8. M10 checklist (capstone)
- Cypher-subset grammar: MATCH pattern, WHERE, RETURN — parser + binder (var→id, label→matrix)
- logical plan tree: NodeScan / Expand / Filter / Project — note that Expand(direction, label) is your Join
- rewrite rules: push WHERE into NodeScan; anchor selection (start from the most selective label — that’s join ordering!)
- after the fact: diff against the reference’s parser/ + planner/ + optimizer dirs
The readable optimizer: DuckDB’s pass pipeline and join-order DP
DuckDB’s src/optimizer/ is the clearest production optimizer you can
read: ~25 ordered rewrite passes, each verified after it runs, feeding a
DPccp join enumerator with a greedy escape hatch and a cost model that is
just cardinality. Start at optimizer.cpp, then filter_pushdown.cpp,
then the join_order/ subdirectory — the payoff.
1. The pass pipeline (optimizer.cpp)
Optimizer::Optimize runs ~25 sequential passes; every one is wrapped in
RunOptimizer (:119) which profiles it and Verifys (:134–139) column
bindings afterward — rewrites are checked for well-formedness after every
pass, in production. The order tells a story (read :197–367 top to
bottom):
expression rewriter → cte inlining → FILTER PULLUP → FILTER PUSHDOWN →
in-clause → deliminator (decorrelation cleanup) → …
→ JOIN_ORDER (:285) → … → unused columns → common subexpressions →
build/probe side (:334) → limit pushdown → TOP_N (:367)
- Pullup BEFORE pushdown (:212 then :218) looks backwards — it hoists filters through outer-join simplifications so pushdown can then sink them FURTHER. Order-dependent heuristics, not a fixpoint engine (contrast DataFusion’s max_passes loop, Cascades’ memo).
- Join order runs mid-pipeline, on a plan already scrubbed of noise.
2. Filter pushdown (filter_pushdown.cpp)
Rewrite (:106) dispatches on operator type → per-operator pushdown
(PushdownFilter :112); non-pushable operators get a fresh child
FilterPushdown (:130–137) — filters accumulate in a bag and sink until
something blocks them. Look at pushdown/ for the per-operator rules
(pushdown_left_join etc. — outer joins are where correctness bites:
a filter on the NULL-padded side cannot sink).
3. Join ordering (join_order/ — the core read)
query_graph_manager.cpp/relation_manager.cpp— extract relations- edges (predicates) from the plan: the QUERY GRAPH.
plan_enumerator.cpp:SolveJoinOrderExactly:375 — DPccp-style dynamic programming: enumerate connected subgraphs,EnumerateCmpRecursive:295,TryEmitPair:227 /EmitPair:185 keep the best plan per relation-SET (the memo).- The escape hatch at :234: “when the amount of pairs gets too large we
exit the dynamic programming and resort to a greedy algorithm” —
SolveJoinOrderApproximately:398 (greedily join the cheapest pair; smallest-intermediate-result-first).SolveJoinOrder:532 picks.
cardinality_estimator.cpp:EstimateCardinalityWithSet:897 — cardinality = product of base cardinalities × per-predicate selectivities, with total denominators from matching equivalence sets; unknown predicates getDEFAULT_SELECTIVITY(:917 — DuckDB’s 0.005 moment). No histograms here: distinct-count-based, plus base stats fromrelation_statistics_helper.cpp.cost_model.cpp:ComputeCost:40 — cost = estimated cardinality of the join output + children costs. That’s it. Cardinality IS the cost model (which is why VLDB’15’s result stings).
Both files in one function — Cout, the sum of intermediate sizes:
#![allow(unused)]
fn main() {
fn cost(plan: &Node) -> f64 {
match plan {
Scan(t) => t.estimated_rows,
Join(l, r, preds) => {
let mut card = rows(l) * rows(r);
for p in preds {
card /= distinct_count(p) as f64; // total denominators from
} // matching equivalence sets
card + cost(l) + cost(r) // output size + children:
} // cardinality IS the cost model
}
}
}
Questions for notes.md
- Why does pullup-then-pushdown beat pushdown alone? Find one operator
in
pullup/where hoisting first enables a deeper sink. - The DP keeps one best plan per relation set. What plan property does that discard that Selinger kept (hint: interesting orders) — and why does DuckDB get away with it (what physical op dominates)?
- Exact→greedy threshold: what workload shape triggers it — star schema (one fact, k dims) or chain? Count connected subgraphs for both at n=10.
- Cost = output cardinality only: no distinction between hash-join build sides at this stage (that’s the later BUILD_SIDE_PROBE_SIDE pass :334). What does splitting order-choice from side-choice lose?
- M10: a Cypher chain
(a)-[:R]->(b)-[:S]->(c)is a chain query graph over edge relations. Which DuckDB piece maps to anchor-node selection — the enumerator or the cardinality estimator?
Done when
You can list the pass order from memory (coarse buckets), and explain DPccp + the greedy fallback + the cardinality formula in three sentences.
References
Code
- duckdb —
src/optimizer/:optimizer.cpp(the pass pipeline, read :197–367 top to bottom),filter_pushdown.cpp+pushdown/, andjoin_order/(plan_enumerator.cpp,cardinality_estimator.cpp,cost_model.cpp); ~2 h
Cardinality is the whole ballgame: the JOB audit
The humbling paper. Leis et al. (VLDB ’15) built the Join Order Benchmark (JOB) — 113 queries over IMDB, REAL correlated data instead of TPC-H’s synthetic uniformity — and audited every layer of the classical optimizer stack. The verdict reorders this whole topic: cardinality error dwarfs cost-model error dwarfs search-space limits.
The experimental design (worth copying forever)
Factor the optimizer into its three claims and test each in isolation:
- cardinality estimates — compare against TRUE cardinalities (computed offline) for every subplan;
- cost model — feed it TRUE cardinalities, see if better cost = faster;
- plan space — with perfect estimates, how much do bushy trees / exhaustive search matter?
Injecting ground truth at each layer isolates the blame. (This is the fair-benchmarking discipline of topic 0, applied to a brain.)
The findings to internalize
- Cardinality is the whole ballgame. Estimates degrade EXPONENTIALLY with join count: median q-error at 6 joins reaches 10²–10⁴ across all tested systems (postgres, and commercial A/B/C); underestimation dominates (independence assumption multiplies toward zero).
#![allow(unused)]
fn main() {
// the estimator every audited system runs, and why it under-shoots
fn estimate_join_card(tables: &[Table], preds: &[EquiPred]) -> f64 {
let mut card: f64 = tables.iter().map(|t| t.rows as f64).product();
for p in preds {
card /= p.ndv_left.max(p.ndv_right) as f64; // uniformity: 1/NDV
} // each predicate applied INDEPENDENTLY — on correlated data the
card // true overlap is larger, so factors compound toward zero
}
}
- TPC-H hides this: uniform, independent, synthetic → estimates look fine. JOB’s correlated real data (actors↔genres↔years) breaks them. Benchmark data distribution is part of the benchmark.
- The cost model barely matters: with true cardinalities, even a trivial cost model (they use Cout = sum of intermediate cardinalities) picks plans within ~2× of optimal. Cost-model tuning is polishing the wrong layer.
- Plan space matters at the margins: exhaustive beats greedy/quickpick meaningfully; bushy beats left-deep-only by ~10–40% on some queries. But all of it is noise next to cardinality error.
- Their pragmatic mitigations: prefer plans robust to misestimation (hash over nested-loop when unsure) — postgres’s nested-loop catastrophes come from underestimates of 10⁴ feeding “it’s only 3 rows” decisions.
error source typical impact on runtime
cardinality (6-way) 10×–1000× (catastrophic plans)
cost model ~2×
search space ~1.1×–1.4×
Questions for notes.md
- Why does independence UNDERestimate join sizes on correlated data? Construct a 2-table example where sel(a)×sel(b) is 100× low.
- Cout (sum of intermediate sizes) as the whole cost model: which of your engines’ knobs does that validate (DuckDB cost_model.cpp:40 is literally this)?
- “Robust plans”: hash join degrades linearly with a bad estimate, nested-loop quadratically. Frame it as a minimax decision — what’s the regret matrix?
- Design JOB-for-graphs: what’s the correlated-data equivalent for Cypher patterns (degree skew × label correlation × triangle density)? Sketch 3 queries where independence-based nnz estimation (matrix-product size) blows up the same way. This is the M10/M22 benchmark seed — write it down properly.
Done when
You can rank cardinality/cost/search by measured impact, explain WHY independence fails low, and have the graph-JOB sketch in notes.md.
References
Papers
- Leis, Gubichev, Mirchev, Boncz, Kemper, Neumann — “How Good Are Query Optimizers, Really?” (VLDB 2015) — ~1.5 h; the methodology (§2–3) is worth as much as the findings — injecting ground truth per layer isolates the blame
Postgres’s optimizer: Selinger ’79, still in production
Forty-five years on, postgres’s join search is still Selinger’s DP — level-by-level over relation sets, interesting orders kept as extra DP state, a genetic-algorithm escape hatch for big joins. Read it for the search skeleton and for the honesty of the default constants that run the world when stats are missing.
1. The skeleton (path/allpaths.c)
make_one_rel:183 — the whole story in one function name: from base relations to ONE final rel. Firstset_base_rel_pathlists:384 (every table gets its access paths: seqscan, index paths — Selinger’s “access path selection”), then the join search.- The dispatcher (:3915): if
enable_geqo && levels_needed >= geqo_threshold(default 12) → GENETIC algorithm (geqo/ — join order as TSP-style chromosome evolution; nobody’s proud of it, everybody ships a fallback); elsestandard_join_search:3952.
2. standard_join_search (:3952) + joinrels.c
Textbook Selinger DP, level by level:
level 1: {A} {B} {C} best path(s) per single rel
level 2: {AB} {AC} {BC} join_search_one_level (joinrels.c:78):
level 3: {ABC} combine level k-1 rels with level 1
(left-deep bias) AND k-2 with 2 (bushy)
each set keeps: cheapest total path, cheapest startup path, plus one
path per INTERESTING ORDER (sorted output that a later merge join /
ORDER BY could exploit — the DP state postgres kept and DuckDB dropped)
- Connectedness:
join_search_one_levelonly pairs rels linked by a predicate, unless forced into a cartesian product at the end. - Paths carry (startup_cost, total_cost) — LIMIT queries pick differently than full scans. Two costs per path is the underrated design decision.
The DP cell keeps MULTIPLE surviving paths, not one — this is add_path,
conceptually:
#![allow(unused)]
fn main() {
fn add_path(rel: &mut RelOptInfo, new: Path) {
let dominated = rel.paths.iter().any(|p|
p.total_cost <= new.total_cost
&& p.startup_cost <= new.startup_cost // LIMIT-friendly axis
&& p.ordering.subsumes(&new.ordering)); // sorted output IS DP state
if !dominated {
rel.paths.retain(|p| !new.dominates(p));
rel.paths.push(new); // a pricier-but-sorted path survives here,
} // to win later at a merge join or ORDER BY
}
}
3. The constants that run the world (include/utils/selfuncs.h)
DEFAULT_EQ_SEL 0.005:34 — “col = ?” with no stats: 0.5%.DEFAULT_INEQ_SEL 0.3333…:37 — “col < ?”: one third. A COIN FLIP wearing three decimal places.DEFAULT_RANGE_INEQ_SEL 0.005:40.
With stats, selfuncs.c uses histograms + MCV (most-common-value) lists
— skew handled for single columns; CROSS-column correlation still assumed
independent unless you CREATE STATISTICS. VLDB’15’s 10⁴× errors live
exactly in that gap.
Questions for notes.md
- Interesting orders: construct the query where the globally-cheapest {AB} subplan loses — a sorted-but-pricier {AB} wins at level 3.
- Why does geqo exist instead of DuckDB-style greedy? What does genetic search preserve that greedy can’t (hint: it searches TREES, not sequences)?
- Two costs (startup, total): which plan flips between
LIMIT 10and full result — index scan vs sort — and why does one number fail? - MCV lists fix single-column skew. Give the graph-shaped failure that remains: super-node degree skew is a JOIN skew, invisible to per-column stats. What stat would M10 need instead (degree histogram per label?).
Done when
You can walk standard_join_search for A⋈B⋈C on paper, keeping two paths per set (cheapest, interesting-order), and name the three default selectivities from memory.
References
Code
- postgres —
src/backend/optimizer/:path/allpaths.c(make_one_rel, standard_join_search),path/joinrels.c(join_search_one_level), plussrc/include/utils/selfuncs.hfor the default selectivities; ~1.5 h
The Rust planner stack: Pratt parsing, rule traits, lazy frames
Three codebases, three Rust-shaped answers: sqlparser-rs (the parser you’ll use directly in the experiments), DataFusion’s rules-as-a-trait optimizer, and polars’ rewrites-only lazy frames. M10’s Cypher planner will face every design choice DataFusion made — read for the shapes, not the SQL details.
1. sqlparser-rs — Pratt parsing (src/parser/mod.rs)
-
- Entry:
parse_sql:582 →parse_statements:531 →parse_statement - 626 — a hand-written recursive-descent parser (no parser generator; same choice as postgres’ gram.y ≠, DuckDB’s libpg_query, and most production systems that started generated and went manual for error messages).
- Entry:
- The heart:
parse_subexpr:1428–1450 — Pratt / precedence-climbing expression parsing: parse a prefix, then loop “whileget_next_precedence(:1449) > my precedence, consume infix”. This is the 30-line answer to expression grammars that would take 40 grammar rules; steal it for Cypher expressions in M10.
#![allow(unused)]
fn main() {
fn parse_subexpr(&mut self, min_prec: u8) -> Expr {
let mut lhs = self.parse_prefix(); // literal, ident, unary, (…)
loop {
let prec = self.get_next_precedence(); // 0 if next isn't infix
if prec <= min_prec { return lhs; } // caller binds tighter: stop
let op = self.next_token();
let rhs = self.parse_subexpr(prec); // recurse with MY precedence:
lhs = Expr::binary(lhs, op, rhs); // higher-prec ops bind first,
} // left-assoc falls out of <=
}
}
- Note the
Dialecttrait plumbing — one AST, many SQLs; the AST types insrc/ast/are the de-facto Rust standard (DataFusion consumes them directly).
2. DataFusion optimizer — rules as a trait (optimizer/src/optimizer.rs)
OptimizerRule:83 —rewrite(&self, plan, config) -> Transformed<LogicalPlan>(:135): every pass is this trait; theTransformedwrapper tracks “did anything change”.- The driver (
optimize:581): run ALL rules in order, REPEAT up tomax_passes(:604, default 3) or until a full pass changes nothing — a fixpoint loop, where DuckDB runs each pass once in a hand-tuned order. Trade: no pass-ordering cleverness needed / passes must be idempotent-ish and you pay repeated traversals. - Skim the rule files:
push_down_filter.rs,eliminate_cross_join.rs,extract_equijoin_predicate.rs,decorrelate_predicate_subquery.rs— the same rewrite menu as DuckDB §2, one file per rule, unit-testable in isolation (each file’s bottom half is tests — the payoff of rule-as-trait).
3. polars lazy frames (crates/polars-plan/src/plans/optimizer/)
- A DATAFRAME library with a query optimizer:
.lazy()builds an IR plan;.collect()optimizes + executes. The dir reads like a mini DuckDB:predicate_pushdown/,projection_pushdown/,simplify_expr/,cse/,collapse_and_project.rs,delay_rechunk.rs. - What’s MISSING is the lesson: no cost-based join reordering to speak of — dataframe programs mostly encode the join order the user wrote. Rewrites-only optimization is viable when the API hands you an explicit plan. (M10 corollary: Cypher gives no such luck — MATCH patterns NEED cost-based anchor/expansion choice.)
Questions for notes.md
- Trace
a + b * c > d AND ethrough parse_subexpr by hand (precedence table lookups included). Now write the Cypher expression subset you need for M10 and its precedence table. - DataFusion’s fixpoint-of-all-rules vs DuckDB’s once-in-order: which
catches
filter → (rewrite exposes new filter) → filterchains, and what’s the worst-case cost? - Why can polars skip join reordering but FalkorDB can’t? Where exactly does Cypher hide the join order decision (pattern → expansion order)?
- The
Transformedflag: why does a fixpoint driver need rules to report changes honestly — what breaks with a rule that always says “changed”?
Done when
You can parse an expression with Pratt precedence on paper, and argue rules-as-trait-with-fixpoint vs ordered-pass-pipeline for M10 (pick one, justify in notes.md).
References
Code
- sqlparser-rs —
src/parser/mod.rs(parse_subexpr is the heart),src/ast/ - datafusion —
optimizer/src/optimizer.rs(OptimizerRule trait + fixpoint driver), then skim the one-file-per-rule menu - polars —
crates/polars-plan/src/plans/optimizer/— read the directory listing as much as the code; what’s MISSING is the lesson
Selinger and Cascades: the two optimizer architectures
Two papers, 16 years apart, that define the design space every optimizer lives in: Selinger ’79 invented cost-based join search as bottom-up DP; Graefe’s Cascades ’95 turned the whole optimization process into rules firing in a memo. Read Selinger closely (it’s short and shockingly modern), then Cascades for the generalization.
1. “Access Path Selection in a Relational DBMS” (Selinger et al., SIGMOD ’79)
System R’s optimizer. Nearly everything survives:
- Cost = weighted I/O + CPU:
PAGE FETCHES + W × RSI CALLS. One formula, two resources. (Modern engines still argue about W.) - Selectivity factors (§4): 1/ICARD(index) for equality — the 1/NDV uniformity assumption, born here. The defaults table (1/10 for “no info” equality…) is postgres’s DEFAULT_EQ_SEL’s grandparent.
- Access path selection: per relation, cost every index vs segment scan, keep the cheapest — plus the cheapest per INTERESTING ORDER (order useful to a later join or ORDER BY/GROUP BY). The DP state refinement that makes merge-join plans findable.
- The DP (§5): best plan for a set of n relations = best(best plan for n-1) ⋈ nth. Left-deep trees only, cartesian products deferred to last. Complexity: the famous “n joins considered in O(2ⁿ)-ish sets”.
- Nested queries (§6): correlated subqueries re-evaluated per row — the pre-decorrelation world DuckDB’s deliminator escapes.
The DP, as code — best plan for a set composed from best plans of subsets:
#![allow(unused)]
fn main() {
fn best_plan(rels: RelSet, memo: &mut HashMap<RelSet, Plan>) -> Plan {
if let Some(p) = memo.get(&rels) { return p.clone(); }
let mut best = Plan::infinite_cost();
for r in rels.iter() {
let rest = rels.without(r);
if !has_join_predicate(rest, r) { continue; } // defer cartesians
let p = cheapest_join(best_plan(rest, memo), access_paths(r));
if p.cost < best.cost { best = p; } // left-deep: (n−1) ⋈ 1
// Selinger also keeps the cheapest plan per INTERESTING ORDER here —
// a pricier-but-sorted subplan can win at a later merge join
}
memo.insert(rels, best.clone());
best
}
}
Reading exercise: their example query (§5’s OPTIMAL plans tables) —
follow the DP tables by hand once; it’s the same table your
experiments’ reorder_joins builds.
2. “The Cascades Framework for Query Optimization” (Graefe ’95)
The generalization: optimization itself becomes data.
- Memo: groups of logically-equivalent expressions; members share cardinality estimates. Duplication-free search space.
- Rules: transformation rules (logical→logical: commute, associate) and implementation rules (logical→physical: Join→HashJoin). Adding an operator or algorithm = adding rules, not editing a search loop.
- Top-down, goal-driven: “optimize group G under requirement R (e.g. sorted by x)” spawns tasks; guidance/promise heuristics order rule firing; branch-and-bound pruning kills subtrees that already cost more than the best known plan.
- Enforcers: sort/exchange as operators the search inserts to meet required properties — how distributed engines later got shuffle planning for free.
vs Selinger:
| Selinger (bottom-up) | Cascades (top-down) | |
|---|---|---|
| search | DP over relation sets | memoized task recursion |
| space | joins only; rewrites separate | rewrites + physical, one space |
| pruning | none needed (small space) | branch-and-bound essential |
| extensibility | edit the enumerator | add a rule |
| shipped in | postgres, DuckDB, SQLite | SQL Server, CockroachDB, Orca |
Questions for notes.md
- Selinger’s W (CPU weight): what happens to plan choice as storage moves NVMe→RAM (topic 6’s numbers)? Which plans flip?
- Interesting orders are DP state. What’s the Cascades equivalent (required physical properties), and why is top-down more natural for propagating them?
- Cascades promises “adding an operator = adding rules”. Check it:
list the rules M10 needs to add for
Expand(graph traversal as an operator) — transformation (Expand commutes with Filter?) and implementation (Expand → mxv? → per-node lookup?). - Why did the simple architecture (bottom-up DP) win in open source and the complex one in commercial engines? (Consider: who writes the rules, who debugs the search.)
- M10 decision to record: Selinger-style enumerator or mini-Cascades for the Cypher planner? (FalkorDB today: heuristic + label-cardinality anchor selection — which architecture is that closer to?)
Done when
You can run Selinger’s DP on a 3-table join by hand, and describe a memo group’s contents for the same query in Cascades terms.
References
Papers
- Selinger, Astrahan, Chamberlin, Lorie, Price — “Access Path Selection in a Relational Database Management System” (SIGMOD 1979) — read it all; it’s short, and §4’s selectivity factors + §5’s DP are the core
- Graefe — “The Cascades Framework for Query Optimization” (IEEE Data Engineering Bulletin 1995) — the memo, rules, and top-down task model
Topic 10 notes — parsing, planning, optimization
Predictions (fill BEFORE running / reading)
explain.rs query 3 (items ⋈ orders ⋈ users, users filtered to city=7 AND age=30)
| question | prediction | actual |
|---|---|---|
| greedy first pair (my planner) | ||
| DuckDB’s first pair for same query/stats | ||
| do they agree? if not, whose estimate diverged | ||
| est vs actual card of users after both filters (independence: 10000/100/50 = 2) |
DuckDB EXPLAIN comparison
Load the same schema + row counts into DuckDB, run the three explain.rs
queries with EXPLAIN. Note every join-order disagreement:
| query | my order | duckdb order | why |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 3 |
Implementation log
-
parse_and_plan— sqlparser 0.52, GenericDialect; naive left-deep plan -
push_down— literal filters into scans, ColEqCol into lowest covering join -
estimate— 1/NDV, independence multiply, |L|·|R|/max(NDV) -
reorder_joins— greedy smallest-pair-first - all tests green;
join_order_flips_with_statswas the fiddly one? notes: - explain.rs run, DuckDB comparison table filled
Surprises / dead ends:
Questions from the reading guides
DuckDB optimizer (reading-duckdb-optimizer.md)
- Pullup-then-pushdown — which pullup rule enables a deeper sink:
- DP keeps one plan per set; what Selinger kept that DuckDB drops, and why it’s ok:
- Exact→greedy threshold — star vs chain connected-subgraph counts at n=10:
- What splitting join-order from build/probe-side choice loses:
- Cypher chain → which DuckDB piece is anchor selection:
Postgres optimizer (reading-postgres-optimizer.md)
- Query where sorted-but-pricier {AB} wins at level 3:
- Why geqo instead of greedy (trees vs sequences):
- Which plan flips between LIMIT 10 and full scan, why one cost number fails:
- Super-node degree skew as join skew — what stat M10 needs:
Rust planner stack (reading-rust-planner-stack.md)
- Pratt parsing: precedence climb for
a = 1 AND b < 2 OR c— draw the tree: - DataFusion fixpoint vs DuckDB ordered passes — which bug class each risks:
- Why polars can skip join reordering and a Cypher engine can’t:
Selinger vs Cascades (reading-selinger-cascades.md)
- Interesting orders = extra DP state; Cascades equivalent (enforcers + physical properties):
- What the memo shares that Selinger’s table doesn’t:
- M10 architecture decision — bottom-up Selinger-style or memo-based, and why:
How Good Are Query Optimizers (reading-how-good-optimizers.md)
- 2-table example where independence underestimates 100×:
- Cout validates which of my engines’ knobs:
- Hash vs nested-loop regret matrix (minimax framing):
- graph-JOB sketch (M10/M22 benchmark seed) — 3 Cypher queries where nnz-based estimation blows up (degree skew × label correlation × triangles):
Cross-topic threads
- Cardinality ≫ cost ≫ search (VLDB’15) is fair-benchmarking (topic 0) applied to the optimizer itself: inject ground truth per layer to isolate blame.
- DuckDB cost_model.cpp:40 = Cout = “sum of intermediate cardinalities” —
the cost model IS the cardinality estimate. My
estimateis the whole game. - Greedy fallback (:234) = the amortize-and-escape-hatch pattern again: exact until the state space explodes, then heuristic.
M10 log (Cypher parser + binder + planner)
- Cypher pattern
(a:L1)-[:R]->(b:L2)= join over edge relation; MATCH with k relationships = k-way join ordering - anchor-node selection = which base relation to scan first = join order leaf choice; label cardinality = table stats
- decision: Selinger-style bottom-up vs memo (answer question above first)
- cardinality for Expand = nnz of sparse matrix product — record the formula and its independence assumption; graph-JOB queries stress it
- rewrite pass order for Cypher: label pushdown before anchor selection (mirrors DuckDB filter-pushdown-before-join-order)
Done when
- All planner tests green; explain.rs vs DuckDB comparison table filled with at least one disagreement explained.
- Architecture decision for M10 written with a reason.
- graph-JOB sketch exists (3 queries + why each breaks independence).
Topic 11 — Query Engines II: Execution Models
Volcano vs vectorized vs compiled — the defining performance battle of modern analytics. Topic 10 chose the plan; this topic is about how fast you can RUN it. The gap between tuple-at-a-time and vectorized execution is one to two orders of magnitude, and you will measure it yourself.
flowchart LR
P[physical plan] --> M{execution model}
M -->|"Volcano '90"| V["next() per TUPLE<br/>interpret per tuple"]
M -->|"X100 '05"| X["next() per VECTOR<br/>interpret per 1-2K batch"]
M -->|"HyPer '11"| H["compile plan to<br/>machine code, no next()"]
V --> C[same results,<br/>10-100x apart]
X --> C
H --> C
1. The Volcano (iterator) model
Every operator implements open() / next() / close(); next() returns
ONE tuple. Elegant: operators compose arbitrarily, demand-driven, bounded
memory.
Project.next()
└─ calls Agg.next()
└─ calls Filter.next() per-tuple costs, PER TUPLE:
└─ calls Scan.next() - virtual call (indirect branch) x depth
- interpretation of the expression tree
- tuple is gone from registers between calls
Postgres still runs this (ExecProcNode — a function pointer per node),
and for OLTP it’s fine: a point query touches 3 rows; who cares about
per-tuple overhead. The disaster is analytics: 100M rows × 5 operators ×
~20 ns of interpretation overhead = minutes spent NOT computing.
2. Vectorized execution (MonetDB/X100 → DuckDB)
Same iterator shape, but next() returns a BATCH (DuckDB: DataChunk,
2048 rows). All the per-call overhead amortizes over the batch, and the
inner loops become tight for over columnar arrays — the compiler
auto-vectorizes, the prefetcher streams, branches disappear.
per-TUPLE model: overhead × N_rows
per-VECTOR model: overhead × (N_rows / 2048) + tight loops over arrays
Two supporting tricks (both in DuckDB, both worth stealing for M11):
- Selection vectors: a filter doesn’t copy survivors — it produces an
index array
sel[]over the same vectors. Downstream kernels take(data, sel, count). Zero copies until materialization is forced. - Vector type flags: a vector can be FLAT, CONSTANT (one value — arithmetic with a constant never expands it), DICTIONARY (selection over a dictionary — compressed data flows through the engine). Kernels dispatch on the combination.
Why 1–2K rows? Big enough to amortize call overhead, small enough that a chunk’s working set stays in L1/L2 between operators. It’s the cache ladder of topic 0 turned into an engine design parameter.
3. Compiled execution (HyPer)
Skip interpretation entirely: fuse each PIPELINE (chain of operators between materialization points) into one tight loop and JIT it — the tuple stays in CPU registers from scan to sink.
for (row in fact_table) // one compiled loop = whole pipeline
if (row.f < 50) // filter: a branch, not an operator
ht[row.k] += row.v; // agg: an add, not a next() chain
VLDB’18 (“Everything You Always Wanted to Know…”) raced the two champions: roughly equal on aggregation-heavy work; compilation wins complex expressions and tight OLTP; vectorization wins compile-time (ms vs 100s of ms), profiling, and adaptivity. DuckDB chose vectors partly for engineering reasons — no LLVM dependency, debuggable C++ kernels. Topic 19 revisits compilation; M11 goes vectorized.
4. Morsel-driven parallelism (SIGMOD’14)
How to parallelize pipelines: break the input into MORSELS (~100K rows), workers PULL morsels dynamically instead of getting static partitions.
- NUMA-aware: a worker prefers morsels on its socket.
- Elastic: skew doesn’t strand workers (no “thread 3 got the hot partition”); a slow morsel just means that worker pulls fewer.
- DuckDB: source operators hand out row-group-sized work units
(122880 rows = 60 vectors);
MaxThreadson the source caps fan-out. polars-stream literally names its unitMorseland pairs it with a sequence token so order-sensitive sinks can reassemble.
┌ morsel queue ┐
scan: [m0][m1][m2][m3][m4]...
▲ ▲ ▲
w0 pulls, w1 pulls, w2 pulls (dynamic — no static split)
each worker runs the WHOLE pipeline on its morsel:
scan → filter → probe → partial agg (thread-local HT)
then: combine partial HTs (the sink's Combine/Finalize phase)
5. Hash joins and aggregation, vectorized
The two operators where analytics time actually goes.
- Hash join (DuckDB
join_hashtable.cpp): build side materializes into row-format tuple data, partitioned by hash radix; the hash table stores 8-byte entries = pointer + SALT bits (topic 2’s bit-smuggling: compare salt before chasing the pointer — most misses never touch the tuple). Probe is vectorized: hash 2048 keys, gather 2048 buckets, compare salts, chase survivors via selection vector. - Hash aggregation: same skeleton, plus two-phase: threads build
thread-local partial HTs (no contention), then radix-partitioned
merge (
RadixPartitionedHashTable). DataFusion’sGroupedHashAggregateStreaminterns group keys → dense group index → aggregate states live in flat columnar arrays indexed by group id (not per-group heap objects).
Experiments (experiments/)
One query, three engines — SELECT k, SUM(v) WHERE f < t GROUP BY k:
volcano.rs— PROVIDED: tuple-at-a-time iterators, dyn dispatch. The honest 1990 baseline.vectorized.rs— YOU implement: 1024-row batches, columnar arrays, selection vectors, group-by into a flat array (k is dense).kernels.rs— YOU implement: branchless/SIMD-friendly single-pass fused kernel (what a compiled engine would emit for this pipeline).
cargo test checks all three agree with a scalar oracle;
cargo run --release --bin exec_bench prints rows/s for each — the gap
IS the lesson. Predict the two ratios in notes.md first.
Reading guides
| guide | what it walks |
|---|---|
| reading-duckdb-execution.md | DuckDB’s execution engine: 2048 rows at a time |
| reading-postgres-executor.md | Volcano in production: postgres’s executor, warts and wisdom |
| reading-rust-execution-stack.md | Vectorized in Rust: polars-stream morsels and DataFusion streams |
| reading-x100.md | X100: the vectorization manifesto |
| reading-compiled-vs-vectorized.md | Compiled vs vectorized: the fair fight ends in a near-tie |
| reading-morsel-parallelism.md | Morsel-driven parallelism: workers pull, skew dissolves |
Further references: “Photon” (SIGMOD 2022) — Databricks’ vectorized C++ engine, notable for why not compilation: easier to build, debug, and roll out than codegen at their scale; “Velox” (VLDB 2022) — Meta’s reusable execution library (the executor as a component, like Calcite for topic 10); “GAMMA” (1986) — where exchange-operator parallelism came from, if you want the pre-history of §4.
Capstone M11
Vectorized runtime for the graph engine (reference mirror:
runtime/batch.rs, vectorized.rs, eval.rs):
- batch type: fixed-capacity columnar chunk (node ids, properties) + selection vector — pick the batch size by measuring, not by copying DuckDB’s 2048
- operator pipeline: source (label scan) → expand → filter → project,
each
fn next(&mut self, out: &mut Batch) - expression eval over batches (property predicates) — no per-row dyn dispatch
- the FalkorDB angle: Expand over a sparse matrix IS a vectorized kernel already (one GraphBLAS call per batch of source nodes); decide where matrix ops end and row-ish batches begin
- bench: M11 runtime vs M10’s naive interpreter on the same plan
Compiled vs vectorized: the fair fight ends in a near-tie
Kersten et al. (VLDB ’18) built BOTH engines — Typer (HyPer-style data-centric compilation) and Tectorwise (X100-style vectorization) — sharing everything else, then raced them. Fair benchmarking (topic 0 discipline) applied to the execution-model war; the residual differences, not the headline winner, are what decide M11 and M19.
The two models, one query
SELECT k, SUM(v) FROM t WHERE f < 50 GROUP BY k:
Typer (compiled) Tectorwise (vectorized)
─ one fused loop, JIT-compiled ─ ─ interpreted per vector ─
for each row: sel = filter_lt(f_vec, 50) // loop 1
if (f < 50) h = hash(k_vec, sel) // loop 2
ht[k] += v g = ht_lookup(h, sel) // loop 3
agg_add(states, g, v_vec, sel) // loop 4
tuple stays in REGISTERS vector stays in L1; each loop is
across all operators simple, branch-free, SIMD-able
Same algorithms, same data structures — ONLY the loop structure differs. That’s what makes the comparison fair.
Findings to internalize
- Overall: nearly tied. TPC-H geometric mean within ~10–20% of each other. The 100× war of X100-vs-MySQL is over; both models kill interpretation overhead. The remaining differences are second-order.
- Compilation wins: expression-heavy work (fused loop keeps everything in registers, no intermediate vectors), joins with many columns carried through (“wide” pipelines), OLTP-style point work (no per-vector setup cost).
- Vectorization wins: memory-bound operators (hash probes: vectorized code overlaps MANY cache misses at once — the MLP lesson from topic 0; compiled code’s fused loop has ONE miss in flight unless you add software prefetching), SIMD applicability (isolated simple loops), and everything operational: compile time (ms vs 100s of ms per query), profiling (perf shows WHICH primitive; compiled code is one opaque blob), adaptivity (can swap primitive mid-query).
- Hash join probe is the great equalizer: both models end up memory-bound on the HT random accesses; Tectorwise slightly ahead because vectorized probing naturally batches misses.
- SIMD gains on modern cores were smaller than hoped: most operators are memory-bound; SIMD helps compute-bound primitives only.
The scorecard
| dimension | compiled (Typer) | vectorized (Tectorwise) |
|---|---|---|
| computation-heavy | wins (registers) | loses (intermediates) |
| memory-bound (probes) | loses (1 miss in flight) | wins (miss overlap) |
| compile latency | 100s of ms (LLVM) | zero |
| profiling/debugging | opaque blob | per-primitive |
| adaptivity | recompile | swap primitives |
| implementation effort | LLVM dependency, codegen bugs | 100s of kernels |
Questions for notes.md
- Why does vectorized probing overlap misses but the compiled loop doesn’t? Connect to lookup_shootout (topic 0): what did MLP do for HashMap throughput there?
- Software prefetching rescues compiled probes (they cite group prefetching / AMAC). Why is prefetching EASY in a vectorized kernel (you have the whole vector of hashes) and CONTORTED in a fused loop?
- The “wide pipeline” case: 10 carried columns through 3 operators — count the loads/stores per row for each model. Where did Tectorwise’s registers go?
- Your kernels.rs is a HAND-compiled Typer pipeline for one fixed query. Predict from the paper: will it beat your vectorized.rs on the filter+sum workload (compute-bound, k dense)? By how much?
- M11 (and topic 19’s JIT milestone): FalkorDB queries are pattern-matching heavy — probes and expands, memory-bound. Which column of the scorecard do graph workloads live in, and what does that say about JIT priority for M19?
Done when
You can argue BOTH sides for a graph engine in 3 sentences each, then commit to one (spoiler: the scorecard’s memory-bound row + operational column point vectorized for M11; revisit at topic 19).
References
Papers
- Kersten, Leis, Kemper, Neumann, Pavlo, Boncz — “Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask” (VLDB 2018) — ~1.5 h; the scorecard sections matter more than the geometric means
DuckDB’s execution engine: 2048 rows at a time
The vectorized reference, in production C++. Read in this order: the vector types (the data plane), then the pipeline executor (the control plane), then the join hash table (where the tricks pay off) — every X100 idea from this topic’s papers appears here with a file:line.
1. Vectors and chunks (the data plane)
src/include/duckdb/common/vector_size.hpp:16–20—STANDARD_VECTOR_SIZE = 2048. The single most consequential constant in the engine; everything below flows through 2048-row units.src/include/duckdb/common/enums/vector_type.hpp:15— the vector kinds:
FLAT plain columnar array
CONSTANT one value stands for the whole vector (literals, and any
op whose inputs were constant — never expanded)
DICTIONARY selection vector over another vector (filter output,
decompressed dictionary data — flows through unexpanded)
SEQUENCE start + increment (row ids)
FSST still-compressed strings (topic 12)
src/include/duckdb/common/types/data_chunk.hpp:44—DataChunk= a set of vectors + count. This is whatnext()returns.src/include/duckdb/common/types/selection_vector.hpp:31—SelectionVector: the filter-without-copying mechanism. A kernel takes(Vector, sel, count); a filter’s whole output is a new sel over the same buffers.
#![allow(unused)]
fn main() {
// every kernel takes (data, sel, count); a filter's OUTPUT is a new sel
fn filter_lt(v: &[i64], t: i64, sel: &[u32], out_sel: &mut [u32]) -> usize {
let mut n = 0;
for &i in sel {
out_sel[n] = i; // branch-free: write always,
n += (v[i as usize] < t) as usize; // advance only on match
}
n // survivor count — the data vectors are untouched, zero copies
}
}
Question to hold: every kernel must handle
{flat, constant, dictionary}². How does DuckDB avoid writing 9 loops per
operation? (Look for UnifiedVectorFormat / ToUnifiedFormat — the
normalize-then-one-loop dodge, at the price of an indirection.)
2. Pipelines (the control plane)
Plans are split into PIPELINES at materialization points (hash table builds, sorts). Each pipeline = source → streaming operators → sink.
src/parallel/pipeline.cpp:136—Pipeline::ScheduleParallel: asks source AND sink whether they support parallelism, creates onePipelineTaskper allowed thread;:95sequential fallback.src/execution/operator/scan/physical_table_scan.cpp:77—MaxThreadscomes from the source’s global state: for a table scan, ~one unit per row group (122880 rows,storage_info.hpp:26) — DuckDB’s morsel size.src/parallel/pipeline_executor.cpp:260—Execute(max_chunks): the main loop. Fetch a chunk from source (:281), push it through the operator chain —ExecutePushInternal :375, which walks operators viaExecute(input, result, idx) :483— into the sink. Note theOperatorResultTypeprotocol:HAVE_MORE_OUTPUT(operator wasn’t done with this input — e.g. a join that exploded one chunk into many),NEED_MORE_INPUT,FINISHED.- Push-based inside a task, pull-based between tasks: within a pipeline DuckDB pushes chunks sink-ward, but workers PULL work units from the source. Compare with textbook Volcano (pull all the way down).
3. The join hash table (src/execution/join_hashtable.cpp)
- Build side:
Sinkcollects chunks into partitioned row-format storage (sink_collection,:169Combine merges thread-local partitions — morsel-driven two-phase in action). - The HT proper: 8-byte entries = pointer + salt
(
ht_entry_t::ExtractSalt:195) — compare hash-salt bits BEFORE chasing the tuple pointer; most non-matches are rejected without a cache miss. (Topic 2’s SwissTable H2 / topic 8’s tagged pointers, again.) - Probe (
ProbeState, header:206): vectorized — hash a whole chunk (VectorOperations::CombineHash:393), gather entries, salt-compare en masse, build a selection vector of candidates, then compare actual keys only for those. Chains handled withResidualPredicateProbeStateselection juggling (header:74–:80).
Questions for notes.md
- Why 2048 and not 64K (X100 used ~1K)? Compute: chunk bytes for 8 columns × 8 B at each size vs your measured L2 (topic 0 ladder).
- CONSTANT vectors: trace
2 * pricewhere price is FLAT and 2 is CONSTANT — which loop runs? What would a Volcano engine do per row? HAVE_MORE_OUTPUT: which operators need it and why can’t they just buffer internally? (Memory bound + who owns the chunk.)- The salt trick: with 64-bit hashes and k salt bits, what fraction of non-matching probes still chase a pointer? Pick k.
- M11: your Expand operator explodes one source node into deg(n)
results — that’s
HAVE_MORE_OUTPUTshaped. Sketch the state it must keep between calls.
Done when
You can draw a pipeline for SELECT k, SUM(v) FROM t JOIN s ... GROUP BY k
(two pipelines, which is the sink of which), and explain selection
vectors + the salt trick in two sentences each.
References
Code
- duckdb — the data plane:
src/include/duckdb/common/vector_size.hpp,enums/vector_type.hpp,types/data_chunk.hpp,types/selection_vector.hpp; the control plane:src/parallel/pipeline.cpp,src/parallel/pipeline_executor.cpp; the payoff:src/execution/join_hashtable.cpp; ~2 h
Morsel-driven parallelism: workers pull, skew dissolves
Leis et al. (SIGMOD ’14, HyPer group) — the scheduling half of the modern engine: this topic’s other papers decide the INNER loop; this one decides how 8+ cores share it. The idea fits in a sentence — workers pull small work units instead of receiving static partitions — and everything else falls out of it.
The problem with plan-driven parallelism
The classical approach (Volcano “exchange” operators): the OPTIMIZER picks a degree of parallelism, inserts exchange operators that partition data between static worker sets.
exchange model morsel model
───────────── ────────────
plan fixes DOP at optimize time DOP changes per SECOND
static partitions → skew strands workers PULL 100K-row morsels;
workers (one hot partition = one fast workers just pull more
busy thread, N-1 idle)
exchange = extra materialization + same pipeline object shared by
copying between workers all workers, zero exchange ops
plan explosion (parallel variants) one plan, parallelism is runtime
The design
- Morsel = ~100K tuples. Workers grab one, run the WHOLE pipeline on it (scan → filter → probe → partial-agg), grab the next.
- Dispatcher: a queue of morsels per pipeline; pipelines with dependencies (build before probe) gate on completion events.
- NUMA awareness: morsels are placed on sockets; a worker prefers local morsels, steals remote ones only when starved. Intermediate results stay socket-local because the same thread runs all operators.
- Elasticity: since workers commit only to one morsel at a time, the engine can change effective DOP mid-query (new query arrives → workers finish their morsel and switch). Compare: canceling a static-partition plan mid-flight.
- Shared state is confined to pipeline BREAKERS: thread-local partial hash tables merged at pipeline end (or a shared global HT with atomic inserts for the build — they use the latter, lock-free, topic 9’s toolbox).
The worker loop IS the design:
#![allow(unused)]
fn main() {
fn worker(dispatcher: &Dispatcher, ht: &BuildHt) {
let mut local_agg = PartialAgg::new(); // thread-local: no contention
while let Some(m) = dispatcher.pull(my_socket()) { // prefer LOCAL morsels,
let chunk = scan(m); // steal remote when starved
let sel = filter(&chunk); // the WHOLE pipeline runs
let matches = probe(ht, &chunk, &sel); // here, one thread, so
local_agg.update(&matches); // intermediates stay hot
} // commit unit = one morsel:
dispatcher.combine(local_agg); // that's the elasticity
}
}
Where you’ve already seen it
- DuckDB: row-group (122880) work units +
MaxThreadson sources — morsels without the NUMA half (laptops don’t have sockets). - polars-stream:
Morsel+MorselSeq+ source tokens — morsels with explicit ordering and backpressure. - Your topic 9 scaling.rs: static key-range split vs the shootout’s shared-queue pulling — you measured the skew-stranding effect without naming it.
Questions for notes.md
- Morsel size tradeoff: 100K rows vs DuckDB’s 122880 vs your topic 7 batch findings — what bounds it below (scheduling overhead per morsel) and above (load-balance granularity + cache)? Same amortize-and-batch curve as everywhere else.
- Two-phase aggregation (thread-local HTs + merge) vs the paper’s shared lock-free build HT: which wins for 64 groups? For 64M groups? (Contention vs merge cost — your exec_bench has 64 dense groups; predict.)
- Ordering: morsel pulling destroys tuple order. What does the paper (and polars’ MorselSeq) do when ORDER BY needs it back, and what does that cost?
- On a MacBook (no NUMA, but P-cores vs E-cores): does the heterogeneous-core problem look MORE like NUMA or more like skew? Which mechanism (locality preference vs dynamic pulling) addresses it?
- M11: FalkorDB is single-writer, many-reader (M8/M9 decisions). A read query’s Expand over a big frontier — morselize the FRONTIER? What’s the natural morsel for SpMV (row-block of the matrix?). This is the M11 parallelism design question — write a paragraph.
Done when
You can explain skew-stranding with the one-hot-partition picture and say precisely what “elasticity” means (commit granularity = one morsel).
References
Papers
- Leis, Boncz, Kemper, Neumann — “Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age” (SIGMOD 2014) — ~1 h; §2–3 for the design, skim the NUMA eval if you live on a laptop
Volcano in production: postgres’s executor, warts and wisdom
Tuple-at-a-time execution, still shipping: postgres’s executor is the
honest per-tuple baseline your benchmark’s volcano.rs models. Read it
for the two dispatch costs — a function pointer per plan node per tuple,
an opcode per expression step — and for the one place postgres already
fought back (the computed-goto expression interpreter).
1. ExecProcNode: the iterator model in one function pointer
src/include/executor/executor.h:322—ExecProcNode(node)is justreturn node->ExecProcNode(node);— an indirect call PER TUPLE per plan node. A 5-node plan over 100M rows = 500M indirect branches before any work happens.src/backend/executor/execProcnode.c:439— the cute part: nodes are initialized withExecProcNode = ExecProcNodeFirst(:448), a wrapper that does one-time checks (stack depth:457, instrumentation) then REPLACES the pointer withExecProcNodeReal— self-modifying dispatch, so the steady-state path skips the checks.- Tuples travel as
TupleTableSlot— an abstraction over heap/minimal/virtual tuples; every attribute access may deform (unpack) the on-disk tuple. Vectorized engines pay deforming once per column per chunk; postgres pays per access.
2. execExprInterp.c: the fight against interpretation overhead
Expressions (a.x + 1 > b.y) are compiled to a linear array of STEPS,
then interpreted:
:14and:86–:126— dispatch is a computed goto where the compiler supports it (EEO_SWITCH/EEO_CASE,:119–:126): each opcode’s implementation ends withgoto *dispatch_table[op->opcode]. One indirect branch per step, but each opcode site gets its OWN branch predictor entry (vs a single switch’s shared one) — the classic interpreter trick (same reason redis’ RESP parsing stays cheap, and the thing JIT removes entirely — topic 19).:146—ExecInterpExpr: the giant opcode loop itself.:300— peephole: if the step pattern matches common shapes (e.g. fetch-inner + fetch-outer + compare), dedicated fast-path routines skip the interpreter entirely.- Flat steps instead of tree-walking: postgres ALREADY did the “linearize the expression” half of vectorization — it just still applies it one tuple at a time.
#![allow(unused)]
fn main() {
// expressions compile to FLAT STEPS, then interpret — once per tuple
fn interp(steps: &[Step], row: &Row, regs: &mut [Datum]) -> Datum {
let mut ip = 0;
loop {
match steps[ip].op { // in C: goto *dispatch[op] — each
FetchAttr(a, r) => regs[r] = row.attr(a), // opcode SITE gets
AddI64(x, y, r) => regs[r] = regs[x] + regs[y], // its own branch-
GtI64(x, y, r) => regs[r] = (regs[x] > regs[y]).into(), // predictor
Done(r) => return regs[r], // entry
}
ip += 1;
}
}
// vectorization = the SAME flat steps, applied per 2048 rows instead
}
tree-walk interpreter linear-step interpreter vectorized kernel
(recursive, per tuple) (flat, per tuple) (flat, per 2048)
slowest → postgres → DuckDB
↘ JIT (topic 19) compiles the steps
3. Why postgres gets away with it
- OLTP: per-tuple overhead × 3 tuples is nothing.
- The buffer manager / WAL / locking dominate anyway for writes.
- For analytics it does NOT get away with it — that’s the market gap
DuckDB drove a truck through. (JIT via LLVM exists for expressions
—
jit_above_cost— but not for the operator loop.)
Questions for notes.md
- Count the indirect branches per tuple for
SELECT sum(x) FROM t WHERE y > 10: plan nodes × 1 + expression steps. Then per 2048 tuples for the DuckDB equivalent. - Computed goto vs switch: WHY does one predictor entry per opcode site help? (Think topic 0’s branch_misprediction bench.)
ExecProcNodeFirst’s pointer swap is bit-smuggling’s cousin — self-modifying dispatch. Where else have you seen “first call does setup, then replaces itself”? (Hint: lazy statics, memoized FFI resolution.)- M11: your eval.rs will interpret property predicates over batches.
Linear steps or closure tree? What does postgres’
:300peephole suggest about the 3 shapes worth special-casing for Cypher (n.prop = lit,n.prop > lit, label check)?
Done when
You can explain the two dispatch costs (node-level ExecProcNode, step-level opcode) and name the mitigation for each (vectorization / computed goto + JIT).
References
Code
- postgres —
src/backend/executor/:execProcnode.c(the dispatch),execExprInterp.c(the computed-goto interpreter — read the :14 header comment first), plussrc/include/executor/executor.h; ~1 h
Vectorized in Rust: polars-stream morsels and DataFusion streams
Two Rust answers to the same design questions DuckDB answered in C++ — and the closest templates for M11’s runtime. polars-stream makes morsels a first-class type driven by an async graph; DataFusion keeps Volcano’s shape with a vector payload and async clothes.
1. polars-stream: morsels as a first-class type
crates/polars-stream/src/:
morsel.rs:82—Morsel=DataFrame+MorselSeq(:21) +SourceToken. The sequence number is what DuckDB keeps implicit: order-sensitive sinks reassemble by seq, order-insensitive ones ignore it.get_ideal_morsel_size(:11) is a config knob, not a compile constant — contrast STANDARD_VECTOR_SIZE.SourceToken= backpressure: a sink can ask sources to stop. (Topic 7’s output-buffer problem, solved politely instead of by killing clients.)graph.rs:21,:165— the physical plan is an explicitGraphofGraphNodes connected by pipes (pipe.rs);execute.rs:301execute_graphdrives it. Nodes are async tasks; pipes are channels — the pipeline parallelism falls out of the async runtime rather than a hand-rolled scheduler. Skimnodes/for the operator implementations.
2. polars-compute: what a SIMD kernel actually looks like
crates/polars-compute/src/float_sum.rs:
:44vector_horizontal_sum— reduce a SIMD register to a scalar, shaped “to map to good shuffle instructions”.:67SumBlocktrait:sum_block_vectorized+sum_block_vectorized_with_mask— every kernel comes in a masked variant (nulls!). The mask is aBitMask, and the masked sum SELECTS into the lanes rather than branching per element. This masked/unmasked pairing is the columnar equivalent of your selection vectors.- Note the block structure: fixed-size blocks accumulated in multiple independent SIMD accumulators (ILP — the MLP lesson from topic 0 applied to arithmetic ports), reduced once at the end. Also: float summation order changes the answer — vectorized sum ≠ sequential sum bit-for-bit. Engines document this away.
3. DataFusion: Volcano shape, vector payload, async clothes
datafusion/physical-plan/src/execution_plan.rs:97—trait ExecutionPlan;execute(partition, ctx) -> SendableRecordBatchStream(:478). It’sopen()returning a stream;poll_nextisnext(). Volcano’s SHAPE survived — what changed is the unit (ArrowRecordBatch, ~8K rows) and the dispatch (async poll, amortized over the batch, so the per-call cost stops mattering).- Partition-per-stream parallelism:
execute(i)for i in 0..N partitions, one task each — STATIC partitioning, not morsel-pulling. Skew hurts more than DuckDB/polars-stream; repartition operators (RepartitionExec) patch it up mid-plan. aggregates/grouped_hash_stream.rs:275—GroupedHashAggregateStream, the engine’s heart:poll_next(:641) pulls input batches, and for each batch INTERNS the group keys (group_values/mod.rs:90,trait GroupValues) → dense group indices; aggregate states are flat columnar arrays indexed by group id, updated with a vectorizedupdate_batch(values, group_indices). No per-group objects — the group-by IS array arithmetic. This is exactly the shape yourvectorized.rsgroup-by should have.
#![allow(unused)]
fn main() {
// group-by IS array arithmetic: intern keys → dense ids → flat states
fn update_batch(&mut self, keys: &Column, vals: &[i64]) {
let gids = self.group_values.intern(keys); // ONE HT probe per row,
for (i, &g) in gids.iter().enumerate() { // shared by all aggregates
self.sums[g] += vals[i]; // states are flat arrays
self.counts[g] += 1; // indexed by group id —
} // no per-group heap objects
}
}
The comparison that matters
| DuckDB | polars-stream | DataFusion | |
|---|---|---|---|
| unit | DataChunk 2048 | Morsel (config) | RecordBatch ~8K |
| parallelism | morsel pull | async graph + tokens | static partitions |
| scheduling | own scheduler | async runtime | tokio |
| ordering | implicit | MorselSeq | stream contract |
Questions for notes.md
- Async operators (polars/DF) vs hand-rolled state machines (DuckDB’s OperatorResultType): what does async buy (blocking sources) and cost (poll overhead, buffer ownership)? Which fits M11 — remember topic 7’s one-threadpool decision.
- MorselSeq: which graph query results are order-sensitive? (ORDER BY obviously — anything else in Cypher? LIMIT without ORDER BY?)
- The masked-kernel pattern: your batches will have selection vectors instead of null masks. Same trick? When does select-in-lanes beat compact-then-compute? (Selectivity threshold — guess, then bench.)
- DataFusion interning group keys per batch: why is hash-once-then-index cheaper than hashing per aggregate? Count the HT probes for 4 aggregates either way.
- M11: FalkorDB’s Expand does one GraphBLAS SpMV per batch — which of the three systems’ operator contracts fits “one call produces a whole matrix of results” best?
Done when
You can name the batch unit + parallelism strategy of all three systems from the table WITHOUT the table, and describe the intern-then-flat-arrays group-by shape in two sentences.
References
Code
- polars —
crates/polars-stream/src/(morsel.rs,graph.rs,execute.rs,nodes/) andcrates/polars-compute/src/float_sum.rsfor what a SIMD kernel actually looks like - datafusion —
datafusion/physical-plan/src/execution_plan.rs(the trait) andaggregates/(GroupedHashAggregateStream,group_values/) — the engine’s heart; ~1.5 h for both
X100: the vectorization manifesto
MonetDB/X100 (CIDR ’05) is where vectorized execution was born — from a profiler, not a whiteboard. Twenty years old and it reads like the DuckDB design doc — because it is (Boncz co-authored both; DuckDB came out of the same CWI group).
The setup: why were databases 100× slower than hand-written C?
They profile TPC-H Q1 (scan + filter + arithmetic + group-by — no join!) on MySQL and find ~90% of time in interpretation overhead: per-tuple function calls, attribute extraction, expression-tree walking. IPC (instructions per cycle) near 0.7 on hardware capable of 3+. The famous framing: databases were running BELOW 10% of what a hand-coded loop achieves ON THE SAME DATA.
hand-written C for Q1: ~0.6 s <- the roofline (topic 0!)
MySQL (Volcano, rows): ~27 s <- 45x of pure interpretation tax
MonetDB (full-column): ~3.7 s <- better, but materializes
X100 (vectors): ~0.6 s <- reaches the roofline
The two failure modes X100 threads between
- Volcano (tuple-at-a-time): overhead per tuple — dies of interpretation.
- MonetDB’s original model (full-column-at-a-time): each operator processes ENTIRE columns, materializing full intermediate results — no per-tuple overhead, but intermediates spill out of cache to RAM; dies of memory bandwidth. (BAT algebra: every op reads and writes DRAM-sized arrays.)
- X100: vectors of ~1000 values — small enough that operator intermediates stay in L1/L2, big enough to amortize interpretation. Pipelining THROUGH the cache: “hyper-pipelining”.
Findings to internalize
- The vector-size sweep (their Figure): performance vs vector length is U-shaped. 1 = MySQL, ∞ = MonetDB; the sweet spot is where (vectors × columns in flight) ≈ cache size. Your exec_bench should reproduce this curve’s shape — sweep 1 / 64 / 1024 / 64K.
- Primitives: each operation is a compiled loop
(
map_add_int_vec_int_vec) selected at plan time — interpretation happens per VECTOR, primitives are branch-free and auto-vectorizable. Templated combinatorics (types × ops) generate hundreds of them — DuckDB inherits this wholesale.
#![allow(unused)]
fn main() {
// a primitive: picked once at plan time, then runs branch-free per vector
fn map_add_i64_vec(a: &[i64], b: &[i64], out: &mut [i64],
sel: Option<&[u32]>) -> usize {
match sel {
None => { for i in 0..a.len() { out[i] = a[i] + b[i]; } a.len() }
Some(s) => {
for (o, &i) in s.iter().enumerate() {
out[o] = a[i as usize] + b[i as usize];
}
s.len()
}
}
} // interpretation: ONE dispatch per ~1000 values, not per value;
// ~1000 × 8 B per operand keeps the intermediates in L1
}
- Selection vectors appear here too: filters produce index lists; primitives take an optional sel.
- IPC as the health metric, not just runtime: X100 runs at ~2 IPC where
MySQL managed 0.7. (Your flamegraph +
instruments/counters angle.)
Questions for notes.md
- Reproduce the arithmetic: 8-col chunk of 8-byte values — what vector length keeps 3 operators’ intermediates inside your M-series L1 (128 KB data)? Does DuckDB’s 2048 fit?
- Full-column MonetDB dies of bandwidth. Compute: Q1 over 6M rows, ~10 intermediate columns materialized — GB moved vs your Mac’s ~100 GB/s. Seconds of pure memory traffic?
- Primitives are monomorphized per type combination — the C++ template trick. What’s the Rust equivalent, and what does it do to compile time / binary size? (You’ll hit this writing kernels.rs.)
- X100 pre-dates SIMD-everywhere: which of its wins does the compiler
now deliver FREE via autovectorization of the primitive loops, and
what still needs explicit
std::simd? (Answer after writing kernels.rs — compare autovec asm vs your manual version.)
Done when
You can draw the U-curve from memory with the two failure modes labeled, and explain why vector size is a CACHE parameter, not a tuning constant.
References
Papers
- Boncz, Zukowski, Nes — “MonetDB/X100: Hyper-Pipelining Query Execution” (CIDR 2005) — ~1 h; the TPC-H Q1 profile and the vector-size sweep figure are the two things to internalize
Topic 11 notes — execution models
Predictions (fill BEFORE implementing vectorized.rs / kernels.rs)
Measured baseline (provided volcano, release, 50M rows, sel 50%): 0.277 s = 180.7 M rows/s — already fast! ~5.5 ns/row including two virtual calls per tuple. Modern branch predictors eat stable indirect calls; the Volcano tax on an M-series core is NOT mostly call overhead. Where will the vectorized win actually come from? (SIMD, ILP, no per-row branch.) Predict accordingly:
| engine | predicted M rows/s | predicted ratio vs volcano | actual | actual ratio |
|---|---|---|---|---|
| volcano (sel 50) | — | 1× | 180.7 | 1× |
| vectorized (sel 50) | ||||
| kernel (sel 50) |
| question | prediction | actual |
|---|---|---|
| does selectivity 5 vs 95 change volcano most or vectorized most? | ||
| X100 U-curve: vectorized at BATCH_SIZE 64 / 1024 / 65536 | ||
does the kernel’s branchless mask beat a branchy if at sel 50? (topic 0 says: yes, hugely) | ||
| kernels.rs: does autovec emit NEON for the fused loop? | ||
| multi-accumulator ILP trick: helps or not with random 64-slot destination? |
Implementation log
- vectorized.rs: batches + selection vector + flat group array; tests green
- kernels.rs: fused branchless pass; negative-values test green (mask sign extension!)
- exec_bench full run recorded above
- BATCH_SIZE sweep recorded (64 / 1024 / 65536)
- flamegraph of volcano run — where does time actually go? (dispatch vs branch miss vs agg)
- look at kernels.rs asm — NEON? record instruction mix
Surprises / dead ends:
- (provided-baseline surprise, already found) LLVM DEVIRTUALIZED the
Box<dyn>operator chain when the tree was statically known — 202 M rows/s before black_box, 180 after. A compiler will happily turn your Volcano engine into a compiled engine if you let it. Real engines can’t (trees built from plans at runtime).
Questions from the reading guides
DuckDB execution (reading-duckdb-execution.md)
- Why 2048 not 64K (chunk bytes vs L2):
- CONSTANT vector trace for
2 * price: - Which operators need HAVE_MORE_OUTPUT and why:
- Salt trick: fraction of non-matches still chasing pointers, for k salt bits:
- M11 Expand as HAVE_MORE_OUTPUT — state between calls:
Postgres executor (reading-postgres-executor.md)
- Indirect branches per tuple for
SELECT sum(x) WHERE y > 10vs per 2048 in DuckDB: - Why computed goto helps (predictor entry per opcode site):
- Other “first call replaces itself” patterns:
- eval.rs: linear steps or closure tree; 3 Cypher shapes worth peepholing:
Rust execution stack (reading-rust-execution-stack.md)
- Async operators vs hand-rolled state machines for M11 (one-threadpool decision from topic 7):
- Which Cypher results are order-sensitive (MorselSeq equivalent needed?):
- Select-in-lanes vs compact-then-compute — selectivity threshold guess:
- HT probes saved by intern-then-index with 4 aggregates:
- Which operator contract fits SpMV-produces-a-matrix best:
X100 (reading-x100.md)
- Vector length that keeps 3 ops × 8 cols in M-series L1:
- Full-column materialization: GB moved for Q1, seconds at ~100 GB/s:
- Rust monomorphization of primitives — compile time/binary size cost:
- What autovec gives free vs what needs std::simd (answer from kernels.rs asm):
Compiled vs vectorized (reading-compiled-vs-vectorized.md)
- Why vectorized probing overlaps misses and fused loops don’t (MLP):
- Why prefetching is easy vectorized, contorted compiled:
- Wide-pipeline load/store count per row, both models:
- Prediction for kernels.rs vs vectorized.rs on THIS workload:
- Graph workloads’ scorecard column → JIT priority for M19:
Morsel-driven parallelism (reading-morsel-parallelism.md)
- What bounds morsel size below and above:
- Thread-local HTs + merge vs shared lock-free HT: 64 groups / 64M groups:
- Restoring order after morsel pulling — cost:
- P/E cores: NUMA-shaped or skew-shaped problem:
- M11 parallelism paragraph: morselize the frontier? natural morsel for SpMV:
Cross-topic threads
- Vector size is a CACHE parameter — topic 0’s ladder decides it.
- Selection vectors = filter-without-copying = the same don’t-materialize discipline as late materialization (topic 12 next).
- Salt-in-pointer in the join HT = bit-smuggling ledger entry #6.
- Vectorized probes win by MLP = lookup_shootout’s HashMap flatline.
- Morsel pulling vs static partitions = topic 9’s scaling.rs skew story.
M11 log (vectorized runtime: batch.rs / vectorized.rs / eval.rs)
- batch size chosen by measurement (sweep on M-series, not DuckDB’s 2048 by faith)
- Batch type: node ids + property columns + selection vector
- operator contract:
next(&mut self, out: &mut Batch)+ a HAVE_MORE_OUTPUT-style result enum (Expand explodes) - eval.rs: linear-step interpreter over batches, peephole the 3 hot shapes
- boundary decision: GraphBLAS matrix ops ↔ row-ish batches — where does Expand hand off?
- bench vs M10’s naive interpreter; flamegraph both
Done when
- All three engines agree + full bench table filled + U-curve recorded.
- The compiled-vs-vectorized scorecard argued for M11 with a decision.
- M11 parallelism paragraph written (morsel design for graph queries).
Topic 12 — Columnar Storage & Analytics
DuckDB/ClickHouse-style OLAP. The thesis of this topic: compression IS performance. Encoded data is smaller, so scans move fewer bytes — and with lightweight encodings, scanning compressed data is often FASTER than scanning raw, because analytics is memory-bound (topic 11’s lesson) and decode cost < bandwidth saved.
row store (OLTP) column store (OLAP)
┌──────────────────┐ ┌────┐┌────┐┌────┐
│ id │ name │ city │ 1 row = │ id ││name││city│ 1 column =
│ id │ name │ city │ 1 place │ id ││name││city│ 1 place
│ id │ name │ city │ │ id ││name││city│
└──────────────────┘ └────┘└────┘└────┘
point lookup: 1 cache line SELECT sum(x): reads ONLY x,
analytics: reads everything 10-100x less I/O + it compresses
Columns compress because a column is SELF-SIMILAR: same type, similar values, sorted or clustered. Rows interleave types and kill every trick below.
1. The lightweight encoding zoo
Not gzip. These are encodings the SCAN can execute over directly:
| encoding | idea | wins on | decode cost |
|---|---|---|---|
| RLE | (value, run_length) pairs | sorted / low-cardinality | ~free, can even predicate-push on runs |
| Dictionary | ids into a distinct-value dict | strings, low NDV | array index; comparisons become int == |
| Bit-packing | ints in ceil(log2(max-min+1)) bits | small int ranges | shift+mask, SIMD-able |
| FOR (frame of reference) | store min + deltas from it | clustered values | add a constant |
| Delta | diffs from previous value | timestamps, sequences | prefix sum (SIMD-able, but sequential-ish) |
| FSST | string symbol table: 8-byte substrings → 1-byte codes | med-cardinality strings dictionary can’t dedup | table lookup per code; random access preserved |
DuckDB stacks them: bitpacking.cpp implements CONSTANT / FOR / DELTA_FOR
as MODES of one function; dictionary ids get bit-packed; FSST codes get
dictionary’d (dict_fsst/). BtrBlocks (SIGMOD ’23) makes the stacking
recursive and picks per-block by SAMPLING each encoder.
2. How a system picks: analyze → score → compress
DuckDB’s compression framework (compression_function.hpp:130–141):
every candidate encoder gets an analyze pass over the column data,
returns a SCORE (estimated compressed size); the cheapest wins, per
row-group per column. Forced via PRAGMA force_compression for your
experiments. This is the benchmark-before-choosing discipline built into
the storage engine.
3. Zone maps (min/max pruning)
Per-segment min/max stats let the scan SKIP segments that can’t match:
WHERE ts BETWEEN '2026-01-01' AND '2026-01-02'
seg 0 [ts: 2025-11-01 .. 2025-12-04] -> skip (no read, no decode)
seg 1 [ts: 2025-12-04 .. 2026-01-05] -> scan
seg 2 [ts: 2026-01-05 .. 2026-02-11] -> skip
Effective ONLY if data is clustered on the filter column — zone maps on
random data prune nothing (every zone spans the whole domain). That’s
why ClickHouse makes you declare ORDER BY at table creation. DuckDB:
ColumnData::CheckZonemap (column_data.cpp:423), stats per segment.
Parquet: min/max per column chunk + page.
4. The formats: Arrow (memory) vs Parquet (disk)
- Arrow: columnar IN MEMORY, designed for zero-copy compute:
ArrayData(arrow-data/src/data.rs:208) = type + buffers (values, validity bitmap, offsets). No encoding beyond dictionary — layout IS the contract, kernels (topic 11’s polars-compute) run on it directly. - Parquet: columnar ON DISK: file → row groups → column chunks → pages, each page encoded (PLAIN / RLE_DICTIONARY / DELTA_BINARY_PACKED / BYTE_STREAM_SPLIT, parquet/src/basic.rs:397+) then optionally block-compressed (snappy/zstd). Min/max stats per chunk and page (metadata/mod.rs:630, :808).
- The boundary: Parquet optimizes bytes-at-rest + selective reads; Arrow optimizes compute. Decode once at the boundary — unless the engine can execute ON the encoding (DuckDB scans over compressed segments; late materialization below).
5. Late materialization
Keep data encoded/columnar as deep into the plan as possible:
- filter on dictionary column → compare dictionary CODES (int ==), only decode survivors;
- DuckDB’s DICTIONARY/FSST vector types (topic 11) carry compressed data THROUGH operators;
- join produces row ids; fetch payload columns only for matches (C-Store’s original pitch).
6. Architectures compared
| ClickHouse MergeTree | DuckDB | Pinot/Druid | |
|---|---|---|---|
| shape | LSM-flavored: sorted PARTS merged in background (topic 4!) | single file, row groups of 122880 | ingest-time indexing, segments |
| primary index | SPARSE: one key per 8192-row granule, binary-search marks | zone maps only | per-segment inverted/star-tree |
| ordering | ORDER BY key, physical sort | insertion order (+ optional sort) | time-partitioned |
| niche | fastest brute-force scans | embedded analytics | real-time slice-and-dice |
MergeTree = topic 4’s LSM ideas at analytics scale: immutable sorted parts, background merges, but the “memtable” is a whole part and the index is sparse because scans, not point reads, are the workload.
Experiments (experiments/)
encodings.rs— YOU implement RLE, dictionary, and bit-packing encode/decode forVec<u64>; tests fix round-trips and exact compressed sizes.scan_bench— PROVIDED:sum()over raw vs encoded columns for three data shapes (sorted-ish / low-NDV / random). The headline: does decode-while-scanning beat raw’s bandwidth? Includes a sum-WITHOUT-decoding path for RLE (value × run_length) — operate on the encoding.duckdb-clickbench.md— run 5 ClickBench queries on DuckDB, EXPLAIN ANALYZE, note which compression each hot column chose (PRAGMA storage_info).
Reading guides
| guide | chapter |
|---|---|
| reading-duckdb-compression.md | DuckDB’s encoding zoo: analyze, score, commit |
| reading-clickhouse-mergetree.md | MergeTree: brute force, organized |
| reading-arrow-parquet.md | Arrow & Parquet: the layout compute wants, the bytes disk wants |
| reading-cstore-compression.md | C-Store: operate on compressed data |
| reading-btrblocks-fsst.md | FSST & BtrBlocks: compress harder, stay random-access |
| reading-clickhouse-paper.md | ClickHouse: the case for brute force |
Further references: “Dremel” (VLDB 2010) — the repetition/definition- level encoding for NESTED data that Parquet adopted wholesale (§4’s format has a whole second half we skip because graphs are flat); “Lakehouse” (CIDR 2021) + “Delta Lake” (VLDB 2020) — what happens when the Parquet files themselves become the database (ACID via a transaction log of file lists — topic 28’s manifest idea).
Capstone M12
Columnar attribute storage + zone-map pruning for property filters:
- properties stored as columns per label (node id → value), not per-node maps — the schema question: sparse columns for optional properties (validity bitmap, Arrow-style)
- encodings: dictionary for strings, FOR/bitpack for ints — reuse encodings.rs
- zone maps per column segment;
WHERE n.age > 65prunes segments before decode - the FalkorDB angle: matrices index STRUCTURE (topology), columns store PAYLOAD (properties) — the split mirrors Parquet-stats/Arrow-compute; measure a property-filter query before/after zone maps
Arrow & Parquet: the layout compute wants, the bytes disk wants
Two open formats split the columnar world: Arrow is “the layout kernels compute on” (in memory, O(1) random access, almost no encoding), Parquet is “the layout bytes rest in” (on disk, encoded then block-compressed, stats for pruning). This chapter reads both from one Rust repo — arrow-rs ships both crates — and then the boundary between them, which is where engines actually differ.
1. Arrow: layout as contract
arrow-data/src/data.rs:208—ArrayData: data type + length + null count +buffers+ child data. Every array type is a recipe of buffers:
Int64Array [validity bitmap][values i64 * n]
StringArray [validity][offsets i32 * (n+1)][utf8 bytes]
DictionaryArray [keys array][values array] <- topic 11's
ListArray [validity][offsets][child array] DICTIONARY vector
- Validity is a BITMAP, not tombstones: null slots still occupy value space (fixed-width) — that’s what makes kernels branch-free (compute everything, mask nulls; polars float_sum’s masked variant, topic 11).
- Offsets-based strings: no per-string allocations, one contiguous bytes buffer. Compare redis SDS (topic 2) — same “length-prefixed, cache-friendly” instinct, different scale.
- Zero-copy slicing:
offset+lenover shared buffers (Arc’d) — the same buffer serves many arrays. IPC (arrow-ipc/) ships these buffers as-is: serialization = memcpy, the whole point of a standard.
2. Parquet: the on-disk hierarchy
file
└─ row group (~1M rows) RowGroupMetaData (metadata/mod.rs:630)
└─ column chunk (1 col × 1 rg) ColumnChunkMetaData (:808)
└─ pages (~1MB) encoding per page
footer: thrift metadata + min/max stats (:1458 min_values/max_values)
- Encodings (
parquet/src/basic.rs:397+): PLAIN, RLE (:408 — actually an RLE/bit-packing HYBRID: runs when repetitive, bit-packed groups when not), RLE_DICTIONARY, DELTA_BINARY_PACKED (:429), BYTE_STREAM_SPLIT (floats: transpose bytes so compressors see similar bytes together). parquet/src/encodings/rle.rs:55/:342— the hybrid encoder/decoder;util/bit_util.rs:696get_batch— unpack a batch of bit-packed values (the tight loop under everything).
The hybrid’s shape, decoded — each group’s header low bit picks one of two worlds:
#![allow(unused)]
fn main() {
// parquet "RLE" is really RLE + bit-packing, alternating per group:
// runs when the data repeats, packed literals when it doesn't
fn decode_hybrid(r: &mut BitReader, width: u32, out: &mut Vec<u32>) {
while let Some(header) = r.read_uleb128() {
if header & 1 == 0 {
let count = header >> 1; // RLE group:
let value = r.read_le_bytes(width); // one value,
out.extend(repeat(value).take(count)); // count copies
} else {
let literals = (header >> 1) * 8; // bit-packed group:
for _ in 0..literals { // 8-value multiples,
out.push(r.read_bits(width)); // width bits each
}
}
}
}
}
- Two compression layers: encoding (semantic, scannable) THEN optional block compression (zstd/snappy over the encoded page). DuckDB skips the second layer for its own storage — random access again.
- Stats at chunk and page level = Parquet’s zone maps; readers prune row groups by footer stats BEFORE reading data pages (predicate pushdown across a file boundary).
3. The boundary (where engines differ)
Reading Parquet into Arrow: dictionary pages can map DIRECTLY to Arrow DictionaryArrays (no decode!), RLE levels decode to validity bitmaps. The choice of when to decode is the late-materialization decision:
| system | strategy |
|---|---|
| DuckDB | own format; scans execute over encodings, decode per-vector |
| polars/DataFusion | Parquet → Arrow at scan, engine sees Arrow only |
| ClickHouse | own format; decompress granules, engine sees flat columns |
Questions for notes.md
- Why does Arrow have almost NO encodings (just dictionary + REE) while Parquet has many? What would delta-encoded values break for an O(1)-random-access compute kernel?
- Parquet’s RLE hybrid: why alternate runs with bit-packed groups instead of pure RLE? (What input kills pure RLE — and what’s the worst-case size vs PLAIN?)
- BYTE_STREAM_SPLIT: why does splitting f64s into 8 byte-planes help zstd? Connect to why columns compress better than rows — it’s the same argument one level down.
- min/max stats on a string column: why do engines store truncated prefixes, and what bug lurks if truncation isn’t handled on the max side? (Hint: “abc\xff…” — increment-the-prefix.)
- M12: property columns for FalkorDB — Arrow-style validity bitmaps for optional properties, or a separate presence structure (roaring bitmap keyed by node id)? What does each cost when 1% vs 99% of nodes have the property?
Done when
You can draw both hierarchies (buffers / file→rg→chunk→page), explain the two compression layers and why only one is scannable, and name where the Parquet→Arrow decode happens in polars vs DuckDB.
References
Papers
- Melnik et al. — “Dremel: Interactive Analysis of Web-Scale Datasets” (VLDB 2010) — optional; the repetition/definition-level encoding for nested data that Parquet adopted wholesale (skipped here — graphs are flat)
Code
- arrow-rs — one repo, both
crates:
arrow-data/src/data.rs(ArrayData, the layout contract),arrow-ipc/(zero-copy shipping),parquet/src/basic.rs(encodings),parquet/src/encodings/rle.rs+util/bit_util.rs(the hybrid),parquet/src/file/metadata/mod.rs(footer stats); a fresh shallow clone is enough
FSST & BtrBlocks: compress harder, stay random-access
Dictionary encoding dedups whole strings; LZ catches partial overlap but kills random access. FSST closes that gap — LZ4-class ratios on similar-but-distinct strings with every single string decodable alone — and BtrBlocks (same group, three years later) shows what happens when you cascade such encodings recursively and pick per block by sampling. Read FSST first (it’s a component), then BtrBlocks (the composition).
FSST: Fast Static Symbol Table (VLDB ’20)
The gap it fills: dictionary encoding dedups WHOLE strings — useless when strings are distinct but SIMILAR (URLs, emails, paths). LZ4/zstd catch that redundancy but kill random access (must decode a whole block to read one string).
- The scheme: a static table of ≤255 symbols, each a 1–8 byte substring; encoding replaces substrings with 1-byte codes; code 255 = escape byte for literals.
"http://www.example.com/index.html"
[http://www.] [example] [.com/] [index] [.html]
3 17 9 42 51 -> 5 bytes + table
- Random access preserved: any single string decodes alone — decode is a per-code table lookup (fits in L1: 255 × 8 B), no history window like LZ. This single property is why DuckDB ships it as a storage encoding and a VECTOR TYPE (FSST_VECTOR, topic 11) — compressed strings flow through the executor.
#![allow(unused)]
fn main() {
// FSST decode: a table lookup per code, NO history window —
// which is exactly why one string decodes without its neighbors
fn decode(codes: &[u8], sym: &[[u8; 8]; 255], len: &[u8; 255]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < codes.len() {
match codes[i] {
255 => { out.push(codes[i + 1]); i += 2; } // escape: literal byte
c => {
let n = len[c as usize] as usize; // symbol = 1..8 bytes
out.extend_from_slice(&sym[c as usize][..n]);
i += 1;
}
}
}
out
}
}
- Symbol table construction: iterative — start with single bytes, repeatedly extend/merge symbols scoring by (frequency × length) gain, on a SAMPLE. A greedy-with-restarts search, bounded iterations.
- Claims: ~LZ4-class ratios on string data, faster decompression, AND random access. Check their table for where it loses (long-range redundancy, already-compressed data).
BtrBlocks: cascaded encodings, chosen by sampling (SIGMOD ’23)
The setting: open formats (Parquet) pick conservative encodings; you can do much better if the format may choose AGGRESSIVELY per block.
- The scheme: for each 64K-value block, take small SAMPLES, try every applicable encoder ON the sample, pick the best ratio — then RECURSE: the outputs of one encoding (e.g. dictionary codes, FOR residuals) are themselves columns that get the same treatment, up to depth 3.
strings ─ dictionary ─┬─ codes (ints) ─ FOR ─ bit-pack
└─ dict entries ─ FSST
doubles ─ pseudodecimal ─ (mantissa ints) ─ ... <- their new float trick
- Sampling vs DuckDB’s full analyze pass: they show a handful of small random slices (not one contiguous slice!) estimates ratios well — ingest stays fast, choice stays near-optimal. (The topic 0 sampling lesson: representative beats exhaustive.)
- No general-purpose byte compressor on top — everything stays scannable + SIMD-decodable; they hit Parquet+zstd-class ratios with ~4× faster decompression, on the cheap-CPU side of the network-vs-CPU tradeoff (object storage era — topic 28).
Questions for notes.md
- FSST vs dictionary on: (a) 1M distinct URLs sharing 20 prefixes, (b) country codes with NDV 200, (c) UUIDs. Pick the winner per case and say why (BtrBlocks would cascade — which cascade for (a)?).
- Why must FSST’s table be STATIC (immutable after training) for random access + vectorized decode? What would adaptive (LZ78-style) codes break?
- BtrBlocks samples; DuckDB analyzes everything; ClickHouse makes you declare. Place the three on an ingest-cost / ratio-quality / operator-burden triangle.
- The escape byte: worst-case FSST inflation on incompressible input? Compare with Parquet RLE-hybrid’s worst case from the arrow-parquet guide.
- M12: property values in FalkorDB are often short similar strings
(emails, category names). Sketch the cascade for a string property
column and mark which stages allow predicate-on-encoded execution
(
= 'x'on dict codes: yes; on FSST codes: trickier — why? unequal code lengths, but equality CAN compare encoded bytes if the table is shared — when is it?).
Done when
You can explain FSST in three sentences (symbol table, 1-byte codes, random access), BtrBlocks in two (sample per block, cascade), and argue when each beats plain dictionary + zstd.
References
Papers
- Boncz, Neumann, Leis — “FSST: Fast Random Access String Compression” (VLDB 2020) — the scheme, the table-construction search, and the table of where it loses
- Kuschewski, Sauerwein, Alhomssi, Leis — “BtrBlocks: Efficient Columnar Compression for Data Lakes” (SIGMOD 2023) — the sampling argument and the cascade; same group as the VLDB ’15 / LeanStore papers
Code
- fsst — the authors’ reference
implementation; btrblocks —
the paper’s artifact (both optional — DuckDB’s
fsst.cppin reading-duckdb-compression.md is the production integration)
MergeTree: brute force, organized
ClickHouse’s storage engine is topic 4’s LSM shapes at analytics
scale: immutable sorted parts, background merges, and — because the
workload is scans, not point reads — an index that is deliberately
SPARSE. This chapter walks the slices of src/Storages/MergeTree/
that carry the design: parts / granules / sparse index / merges. The
codebase is huge; read ONLY what’s anchored below.
1. The mental model
table = set of immutable sorted PARTS (sorted by ORDER BY key)
part = one directory: one file per column + primary.idx + marks
granule = 8192 rows (index_granularity, MergeTreeSettings.cpp:70)
mark = (offset_in_compressed_file, offset_in_decompressed_block)
one mark per granule per column
INSERT -> writes a NEW part (no in-place anything; topic 4's
immutability), background MERGES combine parts
An LSM tree where: memtable ≈ the insert block, SSTable ≈ part, compaction ≈ merge — but no WAL-per-row, no point-read path, and the index is SPARSE because the workload is scans.
2. The sparse primary index
primary.idx= the ORDER BY key of the FIRST row of each granule — 8192× smaller than the data; always in memory (IMergeTreeDataPart.h:424getIndex /:425loadIndexToCache).- Query on the key → binary search granule RANGES:
MergeTreeDataSelectExecutor::markRangesFromPKRange(:1725, used at:189) — turns a predicate into a list ofMarkRangesto read. - Marks (
src/Formats/MarkInCompressedFile.h:17): two offsets, because granules live inside compressed blocks — seek to compressed offset, decompress, skip to row.
WHERE user_id = 42 (ORDER BY user_id):
primary.idx: [1, 800, 1600, ...] -> binary search -> granules 3..4
read marks[3..4] per needed column -> decompress ~16K rows, scan them
Sparse = you always over-read up to a granule; the bet is that decompress+scan of 8192 rows is cheap (vectorized) and the index stays resident. A B-tree answers “which row”; this answers “which 8192 rows”.
The pruning core is two binary searches:
#![allow(unused)]
fn main() {
// primary_idx[g] = ORDER BY key of granule g's FIRST row — 8192x
// smaller than the data, always in memory
fn mark_range(primary_idx: &[Key], lo: &Key, hi: &Key) -> Range<usize> {
let first = primary_idx.partition_point(|k| k < lo).saturating_sub(1);
let last = primary_idx.partition_point(|k| k <= hi);
first..last // for each granule: seek marks[g].compressed_offset,
} // decompress the block, skip to row — then just scan
}
3. Merges (topic 4 redux)
MergeTreeDataMergerMutator::selectPartsToMerge (:272) + MergeTask.h:84
— background merge selection with heuristics balancing write
amplification vs part count (too many parts = slow scans, the
read-amp/write-amp dial again). Specialized engines
(ReplacingMergeTree, AggregatingMergeTree, SummingMergeTree) do WORK
during merges — dedup, pre-aggregation — compaction-as-computation, the
trick FalkorDB could steal for graph statistics.
4. Codecs (src/Compression/)
Per-column codec CHAINS (CompressionCodecMultiple.cpp):
CODEC(Delta, LZ4) composes. The menu includes the time-series
specials: DoubleDelta, Gorilla (XOR floats), FPC, GCD, ALP — topic 30
material. Contrast DuckDB: ClickHouse makes YOU declare the chain (or
takes the default LZ4); no analyze-and-score pass.
5. What to take from the VLDB ’24 paper framing
Materialized views (AggregatingMergeTree targets) as the answer to “scans are still too slow”: precompute during ingest/merge. The architecture triangle: brute-force scan speed (ClickHouse) vs precomputation (Pinot/Druid star-tree) vs embedded convenience (DuckDB).
Questions for notes.md
- Sparse index over-read: worst case rows decompressed for a point query with granularity 8192 and a 3-column read? Why is that fine here and fatal for OLTP?
- Two offsets per mark: why can’t it be one? (Compression block boundaries ≠ granule boundaries.)
- ORDER BY choice:
(user_id, ts)vs(ts, user_id)— which queries does each serve, and what happens to zone maps on the second column? (Same clustering lesson as DuckDB zone maps, but declared upfront.) - Merge heuristics: what goes wrong with too-eager merging (write amp) vs too-lazy (read amp)? Topic 4’s leveled-vs-tiered, at part granularity.
- M12/M22: FalkorDB stores matrices per relationship type. What’s the “part” equivalent if property columns become mergeable segments — and could a merge pre-aggregate degree stats the way SummingMergeTree does?
Done when
You can draw part → granule → mark → compressed block, walk a point query through the sparse index, and name what ClickHouse traded away (point reads, in-place updates) for scan throughput.
References
Papers
- The VLDB ’24 system paper gets its own chapter: reading-clickhouse-paper.md — read it after this code walk
Code
- ClickHouse —
src/Storages/MergeTree/(the anchors above:MergeTreeSettings.cpp,IMergeTreeDataPart.h,MergeTreeDataSelectExecutor.cpp,MergeTreeDataMergerMutator.cpp,MergeTask.h),src/Formats/MarkInCompressedFile.h, andsrc/Compression/for the codec chains; a fresh shallow clone is enough
ClickHouse: the case for brute force
The system paper, 15 years in — the design rationale behind the
mechanisms you just read in
reading-clickhouse-mergetree.md,
plus the parts you didn’t read code for (mutations, replication,
scaling). Read it AFTER the code guide, paired with a local
clickhouse local session and ClickBench; its two-sentence thesis is
this topic’s strongest counterpoint to index-everything instincts.
Read for these arguments
- Why brute force wins: their bet is that with vectorization + compression + parallelism, scanning is fast enough that you rarely need per-row indexes. The sparse index prunes coarse ranges; from there it’s bandwidth. (Your scan_bench measures exactly this bet in miniature.)
- Everything happens at merge time: TTL enforcement, dedup (ReplacingMergeTree), pre-aggregation (Summing/AggregatingMergeTree), recompression to heavier codecs for cold parts. Merges are the system’s metabolic cycle — background bandwidth converted into query speed. (Topic 4’s compaction-as-computation, fully weaponized.)
- Specialized codecs as a product feature: per-column
CODEC(Delta, ZSTD)chains, Gorilla/DoubleDelta for time series — they let the USER declare what DuckDB’s analyze pass discovers. Position this against BtrBlocks’ sampling: three answers to “who chooses the encoding”. - The updates problem: mutations (
ALTER TABLE ... UPDATE) rewrite whole parts asynchronously — updates are batch jobs, not transactions. Honest scope: this is what giving up OLTP buys. - Scaling section: shared-nothing shards + ReplicatedMergeTree via Keeper (their RAFT-ish ZooKeeper replacement) — replication ships PARTS, not rows (state-machine replication at part granularity; topic 15 contrast: redis ships commands, postgres ships WAL, ClickHouse ships files).
The experiments to run alongside (this topic’s “run something real”)
# duckdb + clickbench slice (see ../duckdb-clickbench.md notes file):
# 1. grab hits.parquet sample; run Q0/Q3/Q8/Q13/Q20 in duckdb
# 2. EXPLAIN ANALYZE each: note rows pruned by zone maps
# 3. PRAGMA storage_info('hits'): which compression per hot column?
# record all of it in notes.md
Questions for notes.md
- The paper’s own numbers: where does ClickHouse lose (or barely win) on ClickBench-class queries, and is the cause ever the sparse index (vs e.g. string handling)?
- Merges do TTL/dedup/aggregation — what’s the failure mode when merge bandwidth can’t keep up with ingest (too many parts)? Which topic 4 stall mechanism is the analogue?
- Part-shipping replication: what does it give up vs WAL shipping (replication lag granularity, partial-part visibility) and why is that acceptable for analytics?
- User-declared codecs vs analyze-and-score vs sampling: which would you ship for a GRAPH database where property columns arrive via MERGE statements with unknown distributions? (M12 decision — commit to one and note why.)
- The “for everyone” claim: what did they add to serve small/embedded use (chdb, clickhouse-local), and does it threaten DuckDB’s niche or validate it?
Done when
You can give the two-sentence ClickHouse thesis (immutable sorted parts + merge-time work + brute-force vectorized scans; indexes only sparse), and you have ClickBench-on-DuckDB numbers recorded in notes.md.
References
Papers
- Schulze, Schreiber, Yatsishin, Dahimene, Milovidov — “ClickHouse: Lightning Fast Analytics for Everyone” (VLDB 2024) — read for the arguments above, not the mechanisms; skim the eval against your own ClickBench numbers
Code
- ClickHouse — the code side is covered by reading-clickhouse-mergetree.md; ClickBench for the queries to run alongside
C-Store: operate on compressed data
Every system in this topic descends from two papers out of the same lab, read here as a pair: C-Store proposes the column-store architecture, and the SIGMOD ’06 follow-up proves the thesis this topic is named for — the executor should OPERATE ON compressed data, not just store it. Twenty years on, the value is seeing which of the original bets survived, and in what disguise.
C-Store: the architecture bets (VLDB ’05)
Read for which bets survived twenty years:
| C-Store bet | survived as |
|---|---|
| columns, not rows, for reads | everything in this topic |
| projections: same table stored MULTIPLE times, each sorted differently | mostly died (storage cost); echoes in ClickHouse ORDER BY + materialized views, secondary “projections” feature literally named after it |
| WS/RS split: writeable store + read store, tuple mover between | LSM-shaped! delta + main (SAP HANA), parts + inserts (ClickHouse) |
| compression per column, chosen by data properties | DuckDB’s analyze/score |
| late materialization: join on position lists, fetch payload last | DuckDB selection vectors, Parquet late decode |
| k-safety via projection redundancy instead of RAID | died; replication won |
- The sorted-projection idea is worth dwelling on: sort order is THE enabler for RLE + zone maps (clustering decides compressibility — the ClickHouse ORDER BY lesson, stated in 2005).
- Positions (row ids within a projection) as the join currency between columns: operators exchange position BITMAPS/lists, not tuples — selection vectors avant la lettre.
SIGMOD ’06: compression-aware execution
The experiment: implement RLE, dictionary, bit-packing, LZ, null suppression in a column executor, then compare two modes — decompress-then-process vs process-compressed.
Findings to internalize:
- Operating on RLE is a different complexity class:
SUMover a run = value × length; a predicate evaluates ONCE per run, not per row. Sorted low-cardinality columns get speedups proportional to average run length (they show order-of-magnitude wins). - Dictionary codes compose with late materialization: compare encoded ints, decode only survivors. String predicates become int predicates (your scan_bench reproduces both of these).
- Heavyweight (LZ) compression saved I/O but cost CPU per block with no execution shortcuts — the case for LIGHTWEIGHT encodings in the scan path; gzip-class codecs belong at rest (Parquet’s two layers).
- The abstraction that makes it maintainable: operators consume “compressed blocks” through an API exposing properties (isRLE? isSorted? oneValue?) so each operator needs a few cases, not encodings × operators implementations. DuckDB’s vector-type flags (FLAT/CONSTANT/DICTIONARY/FSST, topic 11) are this API, shipped.
decompress-then-process: [decode all] -> [scan rows] bandwidth + work per ROW
process-compressed: [scan runs/codes directly] work per RUN / per code
The whole thesis fits in one loop — a filtered SUM over RLE that never materializes a row:
#![allow(unused)]
fn main() {
struct Run { value: u64, len: u32 }
// decompress-then-process is O(rows); this is O(runs).
// sorted low-cardinality columns: runs ≪ rows, often by 1000x
fn sum_where_gt(runs: &[Run], threshold: u64) -> u64 {
let mut sum = 0;
for r in runs {
if r.value > threshold { // predicate: ONCE per run
sum += r.value * r.len as u64; // aggregate: multiply, don't decode
}
}
sum
}
}
Questions for notes.md
- SUM over RLE runs is O(runs). Which OTHER aggregates stay run-shortcuttable (min/max? count? avg?) and which break (distinct? median?)?
- Projections died of write amplification. ClickHouse’s projections feature revives them WITH the merge machinery paying the cost — what changed to make it affordable? (Background merges as the universal work-absorber.)
- The WS/RS + tuple-mover design is an LSM with different names. Map the four components onto topic 4’s vocabulary.
- Position lists vs bitmaps for intermediate results: when does each win? (Selectivity — connect to your topic 11 select-vs-compact question.)
- M12:
WHERE n.country = 'IL'on a dictionary-encoded property column — write the process-compressed plan (code lookup, int compare, positions out) and count decodes for 1% selectivity.
Done when
You can state the SIGMOD ’06 thesis in one sentence (“expose encoding properties to operators; execute per-run/per-code, decode losers never”), and map C-Store’s four big bets to their modern descendants.
References
Papers
- Stonebraker et al. — “C-Store: A Column-oriented DBMS” (VLDB 2005) — read for the architecture bets and which survived twenty years
- Abadi, Madden, Ferreira — “Integrating Compression and Execution in Column-Oriented Database Systems” (SIGMOD 2006) — the compression-aware-execution experiment; internalize the findings list above
DuckDB’s encoding zoo: analyze, score, commit
Who picks the encoding? DuckDB’s answer: nobody — race every candidate encoder over the column and let the byte estimates decide, per column, per row group. This chapter walks the framework contract that makes that affordable, then two encoders end-to-end (RLE, bit-packing), then the string stack and the zone maps that filter pushdown lands on.
The selection loop, condensed:
#![allow(unused)]
fn main() {
// per column, per row group: race every encoder, cheapest estimate wins
fn choose(col: &RowGroupColumn, candidates: &[&dyn Encoder]) -> &dyn Encoder {
let mut best = (f64::INFINITY, candidates[0]);
for enc in candidates {
let mut st = enc.init_analyze();
if !col.vectors().all(|v| enc.analyze(&mut st, v)) {
continue; // encoder drops out early
}
let score = enc.final_analyze(st); // ESTIMATED bytes — no compressing yet
if score < best.0 { best = (score, *enc); }
}
best.1 // winner re-reads the whole column in compress_data
}
}
1. The framework: analyze → score → compress → scan
src/include/duckdb/function/compression_function.hpp:130–141 — the
lifecycle, documented in the header:
for each candidate encoder: (per column, per row group)
init_analyze (:139)
analyze(vector) per vector — may return false = drop out early
final_analyze (:141) -> SCORE (estimated bytes; lower wins)
winner runs compress_data (:148) over the same data again
scans use scan_vector (:159) / scan_partial (:162);
point lookups use fetch_row (:172) <- random access into encodings!
Two-pass design: DuckDB pays a full extra read of the data to CHOOSE the
encoding. Benchmark-before-committing, in production. fetch_row is
the constraint that shapes everything — every encoding must support
point access (or fake it); that’s why heavyweight block codecs (zstd)
are a LAST resort (whole-block decode to fetch one row).
2. RLE (rle.cpp) — the simplest complete example
RLEAnalyzeState :86/RLEAnalyze :99— counts runs;RLEFinalAnalyze :113returns bytes = runs × (value + count size).RLECompressState :126— writes two interleaved arrays (values, counts).- The
CompressionFunctionregistration:570bundles all the function pointers — grep this pattern in every other encoder.
3. Bit-packing (bitpacking.cpp) — four encodings in one
BitpackingMode (:103, decode :42): AUTO picks per GROUP of 2048
values (:209–:264):
all equal -> CONSTANT (store 1 value)
equal deltas -> CONSTANT_DELTA (store base + delta)
clustered -> FOR: store min, bit-pack (value - min)
sequential-ish -> DELTA_FOR: delta-encode, then FOR the deltas
Note :219–:237: the mode decision arithmetic — it computes the width
each variant needs and picks the smallest. Per-2048-group modes mean ONE
column segment mixes encodings. ForceBitpackingModeSetting :312 for
experiments.
4. The string stack (skim)
dictionary_compression.cpp:48— dictionary; ids then bit-packed.fsst.cpp:40–:47,:72— FSST analyze/compress: train a symbol table (8-byte substrings → 1-byte codes) on a sample, encode all strings; random access preserved (decode one string without its neighbors — unlike zstd).dict_fsst/— both at once: dictionary of FSST-compressed strings.zstd.cpp— the heavyweight fallback for what nothing else catches.
5. Zone maps
src/storage/table/column_data.cpp:423 — ColumnData::CheckZonemap:
consults per-segment stats (numeric_stats.cpp / string_stats.cpp — note
strings keep min/max PREFIXES) and returns a FilterPropagateResult:
always-true (drop the filter too!), always-false (skip the segment), or
no-pruning. Filter pushdown from topic 10 lands HERE — the plan-level
rewrite becomes a storage-level skip.
Questions for notes.md
- The analyze pass doubles ingest cost. What does BtrBlocks do instead (sampling) and what does it risk?
fetch_rowon DELTA_FOR: decoding row 1907 of a 2048 group requires what? Why is this fine for OLAP (how often does fetch_row run — think late materialization: fetch AFTER filter).- RLE score vs dictionary score on a column of 50% NULLs: which wins and why does validity (empty_validity.cpp) change the answer?
- Zone map always-true result removes the FILTER — when does that matter more than segment skipping? (Selectivity ~100% — filter cost itself.)
- M12: which of the four bitpacking modes fits node-id columns in a graph adjacency payload? (Ids are dense-ish and clustered by creation time.)
Done when
You can recite the analyze→score→compress lifecycle, the four bitpacking modes with their triggers, and explain why fetch_row shapes the whole encoder menu.
References
Code
- duckdb — read
src/include/duckdb/function/compression_function.hppfirst (the lifecycle contract is documented in the header), then the encoders insrc/storage/compression/(rle.cpp,bitpacking.cpp,dictionary_compression.cpp,fsst.cpp,dict_fsst/,zstd.cpp); zone maps insrc/storage/table/column_data.cpp
Topic 12 notes — columnar storage & analytics
Predictions (fill BEFORE running scan_bench)
Raw baseline context: 100M u64 = 800 MB; this Mac’s bandwidth ≈ ? GB/s (check topic 0 baselines) → raw sum floor ≈ ? s.
| shape | encoding | predicted vs raw (faster/slower, ×) | actual |
|---|---|---|---|
| sorted low-card | rle sum (no decode) | ||
| sorted low-card | rle decode+sum | ||
| shuffled low-card | dict sum (codes only) | ||
| small-range random | bitpack decode+sum |
| question | prediction | actual |
|---|---|---|
| best raw-equiv GB/s seen (does anything beat the memory bus?) | ||
| sizes: rle / dict / bitpack per shape | ||
| dict “codes only” sum: bound by 4-byte code reads or the counts array? |
Implementation log
- Rle encode/decode/sum-on-encoding; maximal-runs test green
- Dict encode/decode; sorted-dedup test green
- BitPacked encode/decode/get; width-0, FOR, random-access tests green
- scan_bench full table recorded above
- stretch: DictPacked cascade (bit-pack the codes) — sizes before/after
Surprises / dead ends:
ClickBench-on-DuckDB (see reading-clickhouse-paper.md §experiments)
| query | time | rows pruned by zone maps | hot column compression (storage_info) |
|---|---|---|---|
| Q0 | |||
| Q3 | |||
| Q8 | |||
| Q13 | |||
| Q20 |
Questions from the reading guides
DuckDB compression (reading-duckdb-compression.md)
- BtrBlocks sampling vs full analyze — what sampling risks:
- fetch_row on DELTA_FOR — cost, and why OLAP tolerates it:
- RLE vs dict on 50% NULLs (validity changes the answer how):
- Zone-map always-true (filter removal) — when it beats skipping:
- Bitpacking mode for graph node-id payload columns:
ClickHouse MergeTree (reading-clickhouse-mergetree.md)
- Worst-case over-read for a point query (granularity 8192, 3 cols):
- Why marks need two offsets:
- ORDER BY (user_id,ts) vs (ts,user_id) — zone maps on col 2:
- Too-eager vs too-lazy merging — topic 4 analogue:
- Part-equivalent for mergeable property segments + SummingMergeTree-style degree stats:
Arrow + Parquet (reading-arrow-parquet.md)
- Why Arrow has ~no encodings (what delta breaks for kernels):
- RLE-hybrid rationale + worst case vs PLAIN:
- BYTE_STREAM_SPLIT = columns-beat-rows one level down:
- Truncated string max stats — the increment-the-prefix bug:
- M12 optional properties: validity bitmap vs roaring presence, 1% vs 99%:
C-Store + SIGMOD ’06 (reading-cstore-compression.md)
- Run-shortcuttable aggregates (min/max/count/avg yes-ish; distinct/median?):
- What made ClickHouse projections affordable when C-Store’s weren’t:
- WS/RS/tuple-mover → topic 4 vocabulary map:
- Position lists vs bitmaps — selectivity crossover:
- Process-compressed plan for
n.country = 'IL'at 1% selectivity:
BtrBlocks + FSST (reading-btrblocks-fsst.md)
- URLs / country codes / UUIDs — winner per case + cascade for URLs:
- Why FSST’s table must be static:
- Ingest-cost/ratio/burden triangle: sample vs analyze vs declare:
- FSST worst-case inflation vs RLE-hybrid worst case:
- String property cascade + where predicate-on-encoded works:
ClickHouse paper (reading-clickhouse-paper.md)
- Where ClickHouse barely wins and why:
- Merge-starvation failure mode — topic 4 stall analogue:
- Part-shipping vs WAL-shipping tradeoffs:
- M12 decision: who chooses encodings for graph property columns (declare / analyze / sample) — commit + reason:
- clickhouse-local vs DuckDB niche:
Cross-topic threads
- Compression IS performance because analytics is memory-bound — topic 11’s bandwidth lesson cashed in.
- MergeTree = topic 4’s LSM with scan-shaped choices (sparse index, merge-time work); “too many parts” = write stalls.
- Selection vectors / late materialization = C-Store position lists — same idea, three names, twenty years.
- Vector-type flags (topic 11) = SIGMOD ’06’s compressed-block API.
- fetch_row constraint = why zstd loses to lightweight encodings — random access shapes the menu (LMDB/B-tree echo from topic 3).
M12 log (columnar properties + zone maps)
- per-label property columns, node id → value; optional props via validity (decide after arrow-parquet Q5)
- encodings from encodings.rs (dict strings, FOR/bitpack ints)
- zone maps per segment; measure
WHERE n.age > 65before/after - encoding chooser decision recorded (ClickHouse-paper Q4)
- structure/payload split doc: matrices = topology, columns = properties
Done when
- All encoding tests green; scan_bench table filled; at least one encoding beats the memory bus (raw-equiv GB/s > bandwidth).
- ClickBench-on-DuckDB table filled.
- M12 encoding-chooser decision written.
Topic 13 — Graph Engines (Home Turf, Deeper)
Compare FalkorDB’s sparse-matrix approach against the alternatives it competes with — with line numbers and benchmarks, not marketing. Four architectures, one question: what does an Expand (get neighbors) cost, and what does pattern matching (multi-way Expand) cost?
1. The adjacency representation menu
edge list [(0,5),(0,9),(1,5)...] cheap writes, useless reads
adjacency list node -> Vec<neighbor> per-node vectors, pointer-y
CSR offsets[n+1] + targets[m] read-optimal, immutable-ish
sparse matrix A[i][j] = edge (GraphBLAS) CSR + an ALGEBRA on top
record store fixed-size records, linked neo4j: chains of pointers
CSR for 0->{5,9}, 1->{5}, 2->{}:
offsets: [0, 2, 3, 3]
targets: [5, 9, 5]
neighbors(i) = targets[offsets[i] .. offsets[i+1]] one slice, zero chase
CSR IS a sparse matrix (it’s the standard storage for one). The
GraphBLAS move is to treat the whole graph as a boolean matrix and
traversals as linear algebra: BFS frontier expansion = SpMV
(y<¬visited> = A^T x — mask does the dedup/visited check), 2-hop =
A², triangles = A ⊙ A². One engine (the semiring kernels) serves
every traversal.
2. The four architectures
| neo4j | memgraph | kuzu | FalkorDB | |
|---|---|---|---|---|
| store | fixed-size records on disk, linked lists | in-memory skip lists per vertex | columnar CSR node groups on disk | sparse matrices (SuiteSparse) |
| Expand | chase rel-chain pointers | walk vertex’s edge vector | slice CSR + vectorized ops | matrix row extract / SpMV |
| pattern match | one expand at a time | one expand at a time | binary joins + WCOJ intersect | masked matrix multiply |
| MVCC | page-based + txn state | delta chains per object (topic 8’s N2O) | MVCC on node groups | single-writer + matrix COW (delta matrices) |
| updates | in-place records | in-memory, GC’d deltas | CSR rebuild per node group, delta buffers | delta matrices merged on sync |
The recurring tension: CSR-shaped structures are READ-optimal but hate
single-edge inserts (shift everything). Every system grows a
delta/buffer mechanism: kuzu’s in-mem CSR buffers, FalkorDB’s
Delta_Matrix (additions/deletions matrices over the main one —
src/graph/delta_matrix/), even GraphBLAS’s own pending-tuples. It’s
the LSM idea (topic 4) applied to adjacency: fast structure + small
mutable overlay + background merge.
3. Why pointer chasing loses (and when it doesn’t)
neo4j’s pitch was “index-free adjacency” — neighbors are pointers, no index lookup. But topic 0 taught the real cost model: a pointer chase is a ~110 ns DRAM miss; a CSR slice is a prefetchable stream. Chasing a 34-byte relationship record chain = one miss per edge; scanning a CSR row = bandwidth. Where records win: single-edge-centric OLTP mutations and uniform record updates. Where they lose: any traversal wider than a few edges — which is every interesting query.
4. Worst-case optimal joins (the kuzu angle)
Binary join plans can be asymptotically wrong for cyclic patterns: the
triangle (a)-[]->(b)-[]->(c)<-[]-(a) via pairwise joins materializes
O(m²) intermediates; the AGM bound says the output is at most m^1.5.
WCOJ (Generic Join): intersect one VARIABLE at a time —
for each edge (a,b), intersect N(a) ∩ N(b) for c. Kuzu ships this as an
Intersect operator on sorted CSR slices; EmptyHeaded showed the
GraphBLAS-adjacent insight that set intersection is the whole game.
FalkorDB’s masked A ⊙ A² triangle counting is the matrix spelling of
the same intersection.
5. LDBC (the referee)
- SNB Interactive: OLTP-ish — short reads (2-hop neighborhoods, paths) + inserts; scale factors with power-law degree (the correlated-data lesson from VLDB’15/topic 10 — uniform synthetic graphs hide planner sins).
- SNB BI: analytics — global scans, aggregations over the graph.
- Graphalytics: pure algorithms (BFS, PageRank, WCC) — topic 24.
- The benchmark’s real value: audited implementations + a spec that forces update handling (no read-only CSR cheating).
6. The query-language landscape
Six languages, three real fault lines — data model, matching semantics, composability:
| model | matching | composable? | pushdown-friendly? | |
|---|---|---|---|---|
| Cypher/openCypher | property graph | homomorphism, rel-trail for var-length | weak (CALL {} bolted on) | good |
| GQL (ISO 39075:2024) | property graph | configurable: ALL/TRAIL/ACYCLIC + quantified path patterns | graph tables | good |
| SQL/PGQ | property graph inside SQL | GQL’s MATCH in GRAPH_TABLE(...) | full SQL | inherits SQL |
| SPARQL | RDF triples | homomorphism (BGP) | subqueries | union-heavy plans |
| Gremlin | property graph | imperative traversal | pipelines | almost none — you ARE the plan |
| Datalog | relations | homomorphism + fixpoint | total — rules feed rules | recursion-native |
The trap: the same pattern returns different answers per language.
(a)-[]->(b)-[]->(c) under homomorphism lets a=c (Cypher: yes, nodes
may repeat); isomorphism forbids repeating nodes; trail forbids
repeating edges (Cypher’s var-length [*]). Count 2-paths in a
triangle and you get three different numbers. GQL makes the mode
explicit syntax; Cypher hard-codes a hybrid — a semantics decision
disguised as a default.
RDF’s edge-property hole: triples have no place for since: 2019 on
:alice :knows :bob — you reify (a statement-node per edge, 4 triples)
or use RDF-star. Property graphs made the edge a first-class citizen;
that single modeling choice is most of why they won the app market.
GQL is the first new ISO database language since SQL (1987). Its
quantified path patterns ((a) (-[:KNOWS]->){1,5} (b)) and path modes
are the parts worth building into an AST now — hence M13’s rule below.
→ guide: reading-query-languages.md
Experiments (experiments/)
2-hop neighborhood (distinct nodes at distance 1 or 2, excluding self) over three representations, same power-law graph:
adj_list.rs— PROVIDED:Vec<Vec<u32>>walk. The oracle.csr.rs— YOU implement: build (counting sort) + two_hop over slices with a reusable visited bitmap.matrix.rs— YOU implement: boolean SpMV two_hop — frontier vector × CSR with a mask; structurally identical to csr.rs but written as y = xA then z = yA (feel where the algebra earns its keep and where it’s overhead).hop_bench— PROVIDED: 1M-node/16M-edge preferential-attachment graph, 10K random sources (plus the 100 highest-degree — supernodes are the graph-shaped tail), ns/query for all three.- Compare externally: same query on FalkorDB
(
GRAPH.QUERY ... MATCH (a)-[*1..2]->(b) RETURN count(DISTINCT b)) and neo4j if handy — record in notes.md.
Reading guides
| guide | chapter |
|---|---|
| reading-graphblas-internals.md | GraphBLAS & Delta_Matrix: the graph as matrices |
| reading-neo4j-record-store.md | Neo4j’s record store: the price of index-free adjacency |
| reading-memgraph-storage.md | Memgraph: skip lists, edge vectors, delta MVCC |
| reading-kuzu.md | Kùzu: DuckDB for graphs |
| reading-wcoj.md | Worst-case optimal joins: intersect, don’t enumerate |
| reading-ldbc-snb.md | LDBC SNB: the graph benchmark referee |
| reading-query-languages.md | Graph query languages: semantics, not syntax |
Capstone M13
First graph core — the deliberately-naive baseline M20’s sparse-matrix core will replace (and be measured against):
- node + edge store over adjacency lists (CSR later — feel the update pain first and write it down)
- labels: node id sets (or bitmaps) per label
- basic pattern matching: single-directed-path patterns
(a:L)-[:R]->(b)via scan-anchor-then-expand (M10’s planner chooses the anchor) - wire into M11’s vectorized runtime: Expand fills batches
- bench: hop_bench queries through the whole engine vs the raw representation — the interpretation overhead is the M11 payoff measurement
- record the update-pain notes: what a CSR/matrix core must solve (delta overlay design → M20)
- language rule: target openCypher now, but keep the AST GQL-shaped — quantified path patterns and an explicit path-mode field (ALL/TRAIL/ACYCLIC) as first-class — so M10’s parser survives GQL compatibility without a rewrite
GraphBLAS & Delta_Matrix: the graph as matrices
FalkorDB stores the graph AS matrices; every Cypher expand becomes a GraphBLAS call. Two things make that fast rather than academic: SuiteSparse picks storage format and mxm algorithm per matrix at runtime, and FalkorDB layers a delta overlay on top so single-edge writes don’t rebuild CSR. This chapter walks both codebases — it’s also the topic-20/M20 preview: read for the shape now, the kernels later.
1. Four sparsity formats, chosen automatically
Include/GraphBLAS.h:
:1664GxB_HYPERSPARSE— offsets only for NON-empty rows (graphs where most node IDs have no edges of a given type)GxB_SPARSE— plain CSR/CSC:1666GxB_BITMAP— dense bitmap of present entries + values array (fast random writes, no structure to shift)GxB_FULL— every entry present, no index arrays at all
Switch thresholds: :1556 GxB_HYPER_SWITCH, :1559
GxB_BITMAP_SWITCH — density crossing a threshold flips the format on
the next wait/computation.
density → hypersparse | sparse (CSR) | bitmap | full
~n rows m ≈ O(n) m/n²>τ m = n²
This is the same menu as topic 12’s encodings: representation follows data shape, chosen by measurement, invisible above the API.
2. Dot vs saxpy — two mxm algorithms
Source/mxm/GB_AxB_meta.c:20-21:
generic: for any semiring; dot2/dot3: does
C=A'*B,C<M>=A'*B… saxpy: Gustavson + Hash
- dot (
dot2/dot3/dot4files inSource/mxm/): C(i,j) = A(:,i)’·B(:,j) — good when C is small/masked (compute only needed entries; dot3 is the masked variant driven BY the mask). - saxpy/Gustavson: scatter each A(i,k)·B(k,:) row into an accumulator — good when C is big and dense-ish; the hash variant when the accumulator would be too sparse to justify a dense scratch row.
BFS mapping: frontier vector × adjacency = one SpMV; the visited
complement mask makes dot3 only compute unvisited entries. The
mask is a predicate pushed INTO the kernel — topic 10’s pushdown,
one level down.
3. Masks
Source/mask/GB_masker.c:2,10 — computes R = masker(C, M, Z), i.e.
R<M> = Z: entries of Z where M is true, entries of C elsewhere.
Masks are how GraphBLAS fuses filter ∘ compute into one pass — no
materialized intermediate. Triangle counting C<A> = A² never builds
A², it only evaluates A² at positions where A has an edge.
4. FalkorDB’s Delta_Matrix
~/repos/FalkorDB/src/graph/graph.h:48-52 — the graph IS matrices:
Delta_Matrix adjacency_matrix; // all connections
Delta_Matrix *labels; // one boolean matrix per label
Delta_Matrix node_labels; // node id → label id mapping
Tensor *relations; // one matrix per relation type
src/graph/delta_matrix/delta_matrix.h:17-22 — a Delta_Matrix is
THREE GraphBLAS matrices (+ optionally the same trio transposed):
M main matrix (read-optimized, CSR inside)
delta_plus pending adds
delta_minus pending deletes
read(i,j) = (M(i,j) OR DP(i,j)) AND NOT DM(i,j)
The header’s ASCII state diagrams (delta_matrix.h:26-80) enumerate
legal states: an entry may be in M, in DP, or in M+DM (deleted but not
yet flushed) — never in both DP and DM.
The whole contract in three functions:
#![allow(unused)]
fn main() {
// read = (M ∪ DP) ∖ DM — three probes, never a flush
fn get(g: &DeltaMatrix, i: u64, j: u64) -> bool {
(g.m.get(i, j) || g.dp.get(i, j)) && !g.dm.get(i, j)
}
fn set(g: &mut DeltaMatrix, i: u64, j: u64) {
if g.dm.remove(i, j) { return; } // re-add of a pending delete
if !g.m.get(i, j) { g.dp.insert(i, j); } // never touch the CSR
}
fn wait(g: &mut DeltaMatrix) { // the LSM compaction:
g.m = (&g.m | &g.dp) - &g.dm; // whole-matrix rebuild —
g.dp.clear(); // expensive, so DEFERRED
g.dm.clear(); // behind a sync policy
}
}
delta_set_element_bool.c— writes go to DP (or clear DM if re-adding a deleted edge)delta_remove_element.c— deletes set DM (or clear DP)delta_wait.c/delta_will_wait.c— the flush: M = (M ∪ DP) ∖ DM, triggered by sync policy (graph.h:46SyncMatrixFunc)delta_mxm.c— mxm that accounts for pending deltas without flushing
This is topic 4’s LSM applied to adjacency: read-optimal main
structure + small mutable overlay + background merge. GraphBLAS itself
has the same idea internally (“pending tuples” merged on
GrB_wait) — FalkorDB adds its own layer to control WHEN the
(expensive, whole-matrix) wait happens and to make deletes cheap.
Questions (answer in notes.md)
- Why does FalkorDB need delta_minus at all — why not delete directly from M? (What does deleting one entry from CSR cost?)
- dot3 vs saxpy for a BFS step at frontier size 10 vs 10⁶ on a 1M-node graph — which algorithm and why?
- When is BITMAP the right format for a label matrix? Relate to the density thresholds.
- The
read = (M ∪ DP) ∖ DMidentity means every read touches three matrices. Why is this still a win vs flushing on every write? - Map Delta_Matrix states to LSM vocabulary: what’s the memtable, the SST, the tombstone, the compaction?
References
Papers
- Davis — “Algorithm 1000: SuiteSparse:GraphBLAS: Graph Algorithms in the Language of Sparse Linear Algebra” (ACM TOMS 2019) — optional companion; the code comments below cover the same ground
Code
- GraphBLAS
(SuiteSparse, shallow clone) —
Include/GraphBLAS.hfor the four formats and switch thresholds,Source/mxm/GB_AxB_meta.c(the header comment is the algorithm menu),Source/mask/GB_masker.c - FalkorDB —
src/graph/graph.h,src/graph/delta_matrix/delta_matrix.h(the ASCII state diagrams in the header are the spec), plusdelta_set_element_bool.c,delta_remove_element.c,delta_wait.c,delta_mxm.c
Kùzu: DuckDB for graphs
kuzu is “DuckDB for graphs”: columnar disk-based storage, vectorized execution — and two graph-specific ideas worth stealing: CSR that survives updates via node groups, and a worst-case-optimal Intersect operator embedded in an otherwise binary-join plan. This chapter walks the C++ alongside the CIDR ’23 system paper.
1. Columnar CSR node groups
src/include/storage/table/csr_node_group.h:
:165-171— the design comment: persistent data (checkpointed, CSR format,persistentChunkGroup) + transient data (in-memory chunked groups, append-only, with acsrIndexmapping bound node → row indices). Reads merge both. Same LSM-shaped answer as Delta_Matrix: read-optimal core + mutable overlay.:172class CSRNodeGroup final : public NodeGroup— rel tables reuse the node-group machinery (a node group ≈ DuckDB row group, topic 12): edges are just columns (:162-163— column 0 is neighbor id, column 1 is rel id, properties follow) sorted by source node, with a CSR header (offsets) per group.InMemChunkedCSRHeader(:117,:141) — offsets+lengths being built in memory; checkpoint merges transient rows into a rebuilt CSR for that node group only (oldHeader/newHeader,:152-153). Update pain is bounded per node group, not per graph.
So: adjacency = a columnar table clustered by src with a CSR index on top. Zone maps, compression (topic 12 encodings!), and vectorized scans all apply to edges for free.
2. Worst-case optimal joins: the Intersect operator
src/include/processor/operator/intersect/intersect.h:29
class Intersect : public PhysicalOperator (+ intersect_build.h:35
building sorted adjacency lists into a hash table keyed by node).
The plan shape for a triangle (a)->(b), (b)->(c), (a)->(c):
binary-join to get (a,b) pairs, then for each pair intersect N(a) ∩
N(b) to produce c — never materializing the O(m²) intermediate
(a,c)×(b,c) pairs. This is Generic Join specialized: intersect one
variable at a time, cost bounded by the AGM output bound (m^1.5 for
triangles). See reading-wcoj.md for the theory.
Note it’s a hybrid: kuzu’s optimizer picks Intersect only where cyclic patterns make binary joins asymptotically wrong; chains/trees stay binary hash joins (the topic-10/11 machinery).
3. Factorization (CIDR ’23 §vectorization)
One-to-many expands multiply rows: MATCH (a)-[]->(b)-[]->(c) flat =
Σ deg(a)·deg(b) tuples. kuzu keeps vectors FACTORIZED — an “unflat”
vector holds a group (e.g. all b’s for one a) with a multiplicity,
deferring the cross product. A DataChunk carries flat + unflat vectors
(the vector-type flags of topic 11, pushed further). Aggregations like
count(*) can multiply group sizes without ever flattening.
#![allow(unused)]
fn main() {
// factorized count(*) for a 2-hop: multiply group SIZES, never
// materialize the Σ deg(a)·deg(b) tuples a flat plan would build
fn two_hop_count(csr: &Csr) -> u64 {
(0..csr.n)
.map(|a| {
csr.neighbors(a).iter()
.map(|&b| csr.degree(b) as u64) // multiplicity, not rows
.sum::<u64>()
})
.sum() // matrix spelling: the grand sum of A²'s path counts
}
}
FalkorDB’s matrix spelling of the same fact: A² holds PATH COUNTS as values — the algebra factorizes for you.
Questions (answer in notes.md)
- Rebuild-per-node-group bounds update cost. What’s the worst case — which insert pattern still hurts? (Hint: supernode crossing groups.)
- Why must adjacency lists be SORTED for Intersect? What does the
build side (
intersect_build.h) have to guarantee? - Triangle count on m=16M edges: estimate intermediates for binary join vs AGM bound. How many × saved?
- Factorized
count(*)for 2-hop = Σ over a of deg-products. Write the matrix expression that computes the same number. (This is hop_bench’s count!) - Kuzu compresses neighbor-id columns with topic-12 encodings. Which encoding wins for CSR targets sorted by src, and why? (Think about what’s monotonic within a run and what isn’t.)
References
Papers
- Feng, Gupta, Jin, et al. — “KÙZU Graph Database Management System” (CIDR 2023) — the §vectorization/factorization discussion is the part the code doesn’t narrate
Code
-
- kuzu (shallow clone) —
src/include/storage/table/csr_node_group.h(the design comment at - 165-171 is the storage story),
src/include/processor/operator/intersect/intersect.h+intersect_build.h(the WCOJ operator)
- kuzu (shallow clone) —
LDBC SNB: the graph benchmark referee
A benchmark only referees if it forces the hard parts: updates flowing during reads, power-law data with real correlations, audited full disclosure. LDBC SNB is that referee for graph engines — this chapter maps its three workloads, why its correlated data generator is the whole point, and what M22’s shootout should steal from the spec.
Why this matters
M22 runs an LDBC-style shootout against FalkorDB. Read this now so M13’s baseline engine grows toward queries a referee will actually ask — and so you recognize which benchmark claims in vendor blogs are apples-to-oranges (topic 0’s Fair Benchmarking lesson, graph edition).
1. The three workloads
SNB Interactive OLTP-ish: 2-hop neighborhoods, short paths,
+ concurrent inserts (people, posts, likes)
SNB BI analytics: global scans/aggregations over the graph
Graphalytics pure algorithms: BFS, PageRank, WCC, CDLP, SSSP
Interactive is the one FalkorDB-shaped engines care about: latency per query with updates flowing. Complex reads (IC1–IC14) are mostly anchored multi-hop patterns with property filters and aggregation — i.e. exactly scan-anchor-then-expand plus M12’s property columns.
2. Correlated data is the point
The datagen produces a power-law social graph WITH correlations: people named “Wang” cluster in China, friendships correlate with universities, activity spikes around events. Topic 10’s Leis lesson (uniform synthetic data hides planner sins) applied to graphs:
- degree distribution is power-law → supernodes exist → your engine’s tail latency is a graph-shape property (hop_bench’s 100 highest-degree sources make the same point)
- correlated properties → cardinality estimation errors compound through multi-hop patterns even faster than in JOB
3. What the spec forces that benchmarketing skips
- updates run during reads — no read-only frozen CSR; this is why every architecture in this topic grew a delta mechanism
- audited implementations + full disclosure (drivers, warmup, scale factors) — results are reproducible or they’re not results
- scale factors (SF1 … SF30K) with defined seed — comparisons pin SF
4. What to steal for M22 (record decisions in notes.md)
- the operation mix idea: complex reads + short reads + inserts at a spec’d ratio, driven by a workload generator with dependency tracking (an insert must be visible to later reads)
- 2-3 representative queries rather than all 14: one anchored 2-hop with filters (IC-style), one path query, one aggregation
- report: throughput at bounded p99, not just mean — the supernode tail is the honest number
Questions (answer in notes.md)
- Why does Interactive schedule inserts with timed dependencies instead of firing them as fast as possible?
- Pick IC5-ish “recent posts of friends-of-friends”: write the pattern, mark the anchor, count the expands. Which topic-13 representation hurts most?
- Uniform-degree graph, same edge count: which of this topic’s four architectures looks RELATIVELY better than it deserves, and why?
- What’s the graph analogue of JOB’s “cardinality errors dwarf cost model errors” — at which hop does estimation die?
- Which SNB scale factor fits in this Mac’s RAM as (a) memgraph objects, (b) CSR, (c) Delta_Matrix? Rough per-edge byte estimates.
References
Papers
- Erling et al. — “The LDBC Social Network Benchmark: Interactive Workload” (SIGMOD 2015)
- LDBC SNB specification (ldbcouncil.org/benchmarks/snb) — skim the query set, read the data-generation section
- Iosup et al. — “LDBC Graphalytics” (VLDB 2016) — topic 24’s referee; noted here for the boundary
Code
- ldbc_snb_datagen_spark and the audited implementations under github.com/ldbc — the driver’s dependency-tracking is the part worth reading for M22
Memgraph: skip lists, edge vectors, delta MVCC
memgraph is the “in-memory, pointer-rich, OLTP-first” corner of the
design space: no CSR anywhere. It shows what you get when you optimize
for concurrent mutation instead of scan bandwidth — and it reuses two
things you’ve already read: the lazy-locking skip list (topic 9) and
delta-chain MVCC (topic 8’s N2O ordering). Focus: src/storage/v2/.
1. The vertex is the store
src/storage/v2/vertex.hpp:32 — the whole per-node state in one
struct:
struct Vertex {
const Gid gid;
utils::small_vector<LabelId, ...> labels; // :41 inline until it spills
Edges in_edges; // :43 small_vector of triples
Edges out_edges; // :44
PropertyStore properties; // :46 packed blob, not columns
mutable utils::RWSpinLock lock; // :47 per-vertex latch
utils::PointerPack<Delta, 2> delta_; // :66 MVCC chain head + 2 flag bits
};
vertex.hpp:29Edges = small_vector<tuple<EdgeTypeId, Vertex*, EdgeRef>>— each edge appears in BOTH endpoints’ vectors (like neo4j’s two chains, but contiguous per vertex). Expand = walk one vector: better locality than neo4j’s scattered records, still not CSR — eachVertex*dereference is a fresh miss.PointerPack<Delta, 2>— the delta pointer withkDeletedBitandkNonSeqDeltasBitsmuggled in the low bits (:62-63). Bit-packing ledger entry: flags in pointer alignment bits.- Vertices live in a concurrent skip list keyed by Gid (topic 9’s accessor/GC design) — the “table” is the skip list, no pages.
2. Delta MVCC (topic 8 cashed in)
vertex.hpp:33-37 — the constructor asserts a new vertex starts with a
DELETE_OBJECT delta: memgraph stores the NEWEST version in place and
deltas UNDO backwards (N2O). A fresh vertex’s undo is “didn’t exist.”
Readers walk vertex.delta() chains until they hit their snapshot;
old deltas are GC’d. Per-vertex RWSpinLock + delta chain = writers
don’t block readers, exactly topic 8’s design, at vertex granularity.
#![allow(unused)]
fn main() {
// N2O read: start from the newest (in-place) state and UNDO backwards
// until the chain is old enough for this reader's snapshot
fn read_vertex(v: &Vertex, snapshot_ts: u64) -> VertexView {
let mut view = v.current_state(); // newest version, in place
let mut d = v.delta_head(); // PointerPack: flags in low bits
while let Some(delta) = d {
if delta.ts <= snapshot_ts { break; } // committed before us: done
delta.undo(&mut view); // ADD_LABEL undoes REMOVE, etc.
d = delta.next(); // older
}
view // fresh readers pay 0 hops; laggards pay the chain — N2O's bet
}
}
3. What this architecture buys / costs
memgraph CSR/matrix engines
add edge push to 2 vectors delta overlay + merge
delete edge swap-remove tombstone (DM)
expand 1 node walk contiguous vec slice (same-ish!)
expand frontier pointer soup SpMV, streams
memory ptr-heavy, per-obj offsets+targets, dense
durability snapshot + WAL checkpoint matrices
The per-vertex edge vector is actually FINE for single-node expand — it’s contiguous. The loss is at frontier scale: 10K frontier nodes = 10K scattered vector headers + Vertex* targets that point anywhere. No batch-level structure to stream.
Questions (answer in notes.md)
- Why must an edge live in both endpoints’ vectors? What query breaks with out-only? What does FalkorDB maintain instead (see Delta_Matrix transposed trio)?
small_vectorinlines a few elements before heap-spilling. Which degree distribution fact (power law) makes this a big win?- Delta chains are per-OBJECT here, per-VERSION-ROW in postgres. Which is better for a graph supernode under concurrent edge inserts, and why?
- memgraph’s Expand of one vertex vs kuzu’s CSR slice: both contiguous. Where does kuzu still win? (Hint: what’s IN the vector — 16-byte triples with a pointer vs 8-byte offsets.)
- Sketch what an analytics query (PageRank) costs on this layout vs a matrix. Where does the memory bus time go?
References
Code
- memgraph (cloned for
topic 9) —
src/storage/v2/vertex.hppis the whole chapter in one struct; the skip-list vertex store and delta GC are the topic-9 machinery reused
Neo4j’s record store: the price of index-free adjacency
neo4j is the architecture FalkorDB most directly positions against, and this chapter reads its data layout (Java, but you’re reading layout, not code style). “Index-free adjacency” = neighbors are direct pointers, no index lookup. The bet made sense on 2010 spinning disks (seek = 10 ms, so any pointer beats a B-tree descent). On DRAM it inverts: a pointer chase is a ~110 ns cache miss (topic 0), a CSR slice is a prefetchable stream.
1. Fixed-size records
format/standard/NodeRecordFormat.java:32:
public static final int RECORD_SIZE = 15;
format/standard/RelationshipRecordFormat.java:35:
public static final int RECORD_SIZE = 34;
Fixed size ⇒ record address = id * RECORD_SIZE — the store IS the
index. Read the readRecord methods in both files to see the layouts:
Node (15 B): inUse | nextRel(35b) | nextProp(36b) | labels(40b) | flags
Rel (34 B): inUse | firstNode | secondNode | type
| firstPrevRel | firstNextRel ← chain @ first node
| secondPrevRel | secondNextRel ← chain @ second node
| nextProp
35-bit pointers: high bits are smuggled into the inUse byte — the bit-packing ledger again (compare postgres’s tuple header, topic 8).
2. Relationship chains
record/RelationshipRecord.java:39-44 — each relationship sits on TWO
doubly-linked lists simultaneously (one per endpoint):
node A ──nextRel──> rel1 ──firstNextRel──> rel4 ──> rel9 ──> NULL
│
node B ──nextRel────rel1 ──secondNextRel─> rel2 ──> ...
Expand(A) = walk A’s chain: one 34-byte record read — one potential
cache/page miss — per edge. The records for one node’s chain are
scattered wherever insertion order put them; there is no locality
guarantee. A supernode with 100K edges = 100K dependent loads.
Contrast CSR: targets[offsets[i]..offsets[i+1]] — one range,
hardware prefetcher does the rest.
#![allow(unused)]
fn main() {
// expand(A) in a record store: a linked-list walk where every hop
// is a dependent load — the CPU cannot prefetch what it hasn't read
fn expand(rels: &[RelRecord], node: &NodeRecord) -> Vec<u64> {
let mut out = Vec::new();
let mut r = node.next_rel;
while r != NIL {
let rec = &rels[r as usize]; // scattered: likely a miss
if rec.first_node == node.id {
out.push(rec.second_node);
r = rec.first_next_rel; // ← next hop unknown until
} else { // THIS record arrives
out.push(rec.first_node);
r = rec.second_next_rel; // same record, other chain
}
}
out // CSR spelling: targets[offsets[i]..offsets[i+1]] — one slice
}
}
Also note the chain problem neo4j itself acknowledges: deleting a
relationship must unlink from BOTH chains (up to 4 neighbor records
touched), and finding a specific relationship between two nodes means
walking the shorter chain (they store degree for “dense” nodes to pick
the side — see RelationshipGroup records).
3. Where records WIN
Be fair (topic 0’s benchmarking lesson):
- single-edge insert: write one record + patch 2-4 chain pointers — no CSR shifting, no delta machinery needed
- update-in-place: fixed-size slots never move; MVCC/undo is page-based, not copy-the-adjacency
- uniform record access (“get relationship by id”) is O(1) arithmetic
The trade: neo4j optimized the OLTP mutation path and pays on every traversal; CSR/matrix engines optimize traversal and need an overlay (kuzu buffers, Delta_Matrix) to survive writes.
Questions (answer in notes.md)
- Compute Expand cost for a 1000-edge node: chain walk (assume every record is a DRAM miss, ~110 ns) vs CSR slice (assume 10 GB/s effective stream, 4 B per neighbor). How many × ?
- Why 15 B for nodes but 34 B for relationships? What does each field buy?
- The doubly-linked chain gives O(1) delete-given-record. What does delete cost in CSR? In Delta_Matrix?
- neo4j stores properties in a separate chain (
nextProp). How does that compare to M12’s columnar property storage forWHERE n.age > 65? - “Index-free adjacency” was a disk-era argument. State the modern version of the argument that still holds, and the part that died with DRAM.
References
Code
- neo4j (shallow clone) — everything
lives under
community/record-storage-engine/src/main/java/org/neo4j/kernel/impl/store/:format/standard/NodeRecordFormat.java,format/standard/RelationshipRecordFormat.java(read bothreadRecordmethods for the layouts),record/RelationshipRecord.java
Graph query languages: semantics, not syntax
Six languages query graphs, and the differences that matter are not
surface syntax but three fault lines: data model, matching semantics,
and composability. The same two-hop pattern returns three different
counts depending on semantics the language may not even let you spell.
This chapter maps the family tree — Cypher through GQL (the first new
ISO database language since SQL) — and what each language lets a
planner do; keep kuzu’s src/antlr4/Cypher.g4 open as the concrete
grammar (a full Cypher in 690 lines).
One pattern, three answers
graph: a triangle 1 ──► 2 ──► 3 ──► 1
query: MATCH (a)-[]->(b)-[]->(c) — count the 2-paths
homomorphism (nodes+edges may repeat): 1-2-3, 2-3-1, 3-1-2,
and a=c ones like 1-2-1? no edge 2→1 — but
add a back-edge and a=c matches appear
isomorphism (no repeated nodes): only node-distinct walks
trail (no repeated edges): Cypher's [*] var-length rule
This is the SIGMOD’22 paper’s core: matching semantics is a language
parameter, not folklore. GQL/SQL-PGQ make it syntax — MATCH ALL TRAIL (a)-[]->{1,5}(b) — with restrictors (TRAIL/ACYCLIC/SIMPLE) and
selectors (ANY SHORTEST, ALL SHORTEST, ANY k). Cypher fixed one hybrid
(homomorphism for nodes, trail for var-length edges) in 2012 and every
engine since has had to reverse-engineer the corner cases.
The family tree
graph TD
SQL["SQL (ISO 9075)"] --> PGQ["SQL/PGQ 2023<br/>GRAPH_TABLE(...)"]
C[Cypher 2012] --> OC[openCypher] --> GQL["GQL ISO 39075:2024"]
G[G-CORE 2018<br/>research consensus] --> GQL
PGQ <-->|"same MATCH grammar<br/>(shared committee)"| GQL
SPARQL["SPARQL 1.1 (W3C, RDF)"] -.->|"paths, not property graphs"| GQL
- SQL/PGQ: property graphs as views over tables; MATCH returns a table you join like any other. DuckDB ships it (duckpgq); Oracle too.
- GQL: standalone language, same pattern grammar, plus graph DDL, graph-to-graph queries, and quantified path patterns as first-class.
- SPARQL: pattern = basic graph pattern over triples; edge
properties need reification or RDF-star (
<< :a :knows :b >> :since 2019). - Gremlin: the traversal IS the plan —
g.V().out().out()names an execution order; optimizers can only peephole it. - Datalog: the composability ceiling — every rule’s output is a
relation usable by any other rule; recursion is native (semi-naive
evaluation, topic 27’s incremental cousin). What Cypher’s
CALL {}subqueries chase.
What each language lets the planner do
- Cypher/GQL/PGQ declare what; planner picks join order, direction, index — kuzu’s WCOJ (reading-wcoj.md) is legal because MATCH is declarative.
- Gremlin’s imperative order forbids most of that.
- SPARQL’s triple-at-a-time model tends to plan as many small self-joins (the “SPARQL is 10 joins where Cypher is 2 expands” effect).
- Datalog exposes recursion to the optimizer (magic sets, demand transformation) — no other family can rewrite through a fixpoint.
Questions
- Count the 2-paths in the triangle above under each of homomorphism / isomorphism / edge-trail. Then check FalkorDB’s actual answer — which semantics does it implement, and where is that decided in the code?
- Write
filtered 2-hop(this topic’s experiment query) in Cypher, GQL, SPARQL, and Gremlin. Which versions force a plan shape rather than describe a result? - RDF reification: model
(:alice)-[:KNOWS {since: 2019}]->(:bob)as plain triples. How many triples? What does thesince > 2015filter look like, and what index does it now need? - GQL’s quantified path pattern
(a)(-[:R]->){2,4}(b)with TRAIL — why does naive expansion explode on supernodes (hop_bench’s high-degree tail), and what does the restrictor let the engine prune? - Datalog can express “friend-of-friend excluding direct friends” as two rules with negation. What ordering constraint does negation impose (stratification), and what’s the Cypher equivalent’s cost?
- M13 mapping: the capstone keeps the AST GQL-shaped — quantified
path patterns + explicit path-mode. Sketch the enum/struct for a
path pattern that can represent Cypher’s
[*1..5]AND GQL’sALL ACYCLIC (a)(-[:R]->){1,5}(b)without a parser rewrite.
References
Papers
- Deutsch et al. — “Graph Pattern Matching in GQL and SQL/PGQ” (SIGMOD 2022, arXiv:2112.06217) — the matching-semantics-as-parameter argument
- Angles et al. — “G-CORE: A Core for Future Graph Query Languages” (SIGMOD 2018, arXiv:1712.01550) — the research consensus GQL absorbed
- GQL overview at gqlstandards.org
Code
- kuzu
src/antlr4/Cypher.g4— a full Cypher grammar in one 690-line file; keep it open while reading
Worst-case optimal joins: intersect, don’t enumerate
For cyclic patterns, binary join plans are asymptotically wrong — they can overshoot the true output size by a √m factor, and no join order fixes it, because the operator SET is the problem. This chapter covers the AGM bound that proves it, the Generic Join algorithm that fixes it, and the intersection kernels that make the fix fast. Pure paper material — the code anchor is kuzu’s Intersect operator (reading-kuzu.md) and FalkorDB’s masked matrix multiply (reading-graphblas-internals.md).
1. Why binary joins are asymptotically wrong
Triangle query: Q(a,b,c) = R(a,b) ⋈ S(b,c) ⋈ T(a,c), each relation m
edges. ANY pairwise plan first joins two relations:
R ⋈ S → all paths a->b->c → can be Θ(m²) rows
(star: hub connects everyone)
…then filter by T → output was ≤ m^1.5 all along
AGM bound: |output| ≤ product of relation sizes raised to a fractional edge cover. For the triangle: m^(3/2). Binary plans can overshoot the bound by √m — on a 16M-edge graph that’s 4000× intermediates you didn’t need. No join ORDER fixes it (topic 10’s optimizer is innocent; the operator SET is the problem).
2. Generic Join: intersect one variable at a time
for a in R.a ∩ T.a: # values for variable a
for b in R[a].b ∩ S.b: # b's consistent with this a
for c in S[b].c ∩ T[a].c: # ← THE intersection
emit (a,b,c)
Runtime O(m^1.5) — matches AGM (worst-case optimal). The whole trick: never enumerate (a,b,c-candidates) pairs that a later relation kills; intersect FIRST. Requirement: each relation accessible sorted/hashed by any prefix — i.e. sorted adjacency = CSR slices. Intersection of two sorted lists sized d1 ≤ d2: merge O(d1+d2) or galloping O(d1 log d2) — skew (supernodes) decides which.
#![allow(unused)]
fn main() {
// the inner kernel of every WCOJ engine: sorted-set intersection.
// galloping wins when d1 ≪ d2 — on power-law graphs (leaf ∩ supernode)
// that's the common case, and skew is exactly what WCOJ defends against
fn intersect(small: &[u32], big: &[u32], out: &mut Vec<u32>) {
let mut lo = 0;
for &x in small { // O(d1 log d2)
let mut step = 1; // exponential probe…
while lo + step < big.len() && big[lo + step] < x { step *= 2; }
let end = (lo + step + 1).min(big.len());
match big[lo..end].binary_search(&x) { // …binary-search the bracket
Ok(i) => { out.push(x); lo += i + 1; }
Err(i) => lo += i,
}
}
}
}
3. EmptyHeaded and the matrix connection
EmptyHeaded compiled queries to set intersections over a trie/CSR-like layout and picked intersection algorithm by density: uint arrays vs bitsets — SIMD both ways (topic 17 preview). Its lesson: WCOJ is only fast if the intersection kernel is hardware-conscious; the asymptotics get you in the door, bandwidth wins the fight.
FalkorDB’s spelling: masked matrix multiply. C<A> = A² computes, for
every EXISTING edge (a,b), |N(a) ∩ N(b)| — the mask prevents the O(m²)
blowup exactly like Generic Join’s intersect-first. Same algorithm,
three syntaxes:
kuzu: Intersect(N(a), N(b)) operator in the plan
EmptyHeaded: compiled SIMD set intersection
GraphBLAS: C<A> = A·A with a PAIR/AND semiring
Questions (answer in notes.md)
- Star graph, hub degree 1M: count R⋈S intermediates vs triangle output. Where did they go?
- Fractional edge cover for the triangle is (½,½,½) → m^1.5. What’s
the bound for the 4-cycle
R(a,b)S(b,c)T(c,d)U(d,a)? - Galloping search wins when d1 ≪ d2. Which real-graph fact makes this the common case?
- Why does
C<A> = A²with a boolean/PAIR semiring never materialize A²? Which GraphBLAS mechanism from reading-graphblas-internals.md does the work (dot3!)? - M10 planner question: how would YOUR optimizer decide binary-join vs intersect for a pattern — what’s the detectable trigger? (Cyclicity of the pattern graph.)
References
Papers
- Atserias, Grohe, Marx — “Size Bounds and Query Plans for Relational Joins” (FOCS 2008) — the AGM bound
- Ngo, Ré, Rudra — “Skew Strikes Back: New Developments in the Theory of Join Algorithms” (SIGMOD Record 2013, arXiv:1310.3314) — the readable survey; read THIS one
- Aberger et al. — “EmptyHeaded: A Relational Engine for Graph Processing” (SIGMOD 2016) — the hardware-conscious intersection kernels
Code
- No repo for this chapter — the code anchors are kuzu’s Intersect operator (reading-kuzu.md) and FalkorDB’s masked mxm (reading-graphblas-internals.md)
Topic 13 notes — graph engines
Predictions (fill BEFORE implementing csr.rs / matrix.rs)
Baseline (provided, measured): adj_list 3484 ns/query random, 294885 ns/query supernodes (85× tail); graph 1M nodes / 16M directed edges, max degree 6565, p50 degree 11.
| impl | sources | predicted vs adj_list (×) | actual ns/query |
|---|---|---|---|
| csr | random | ||
| csr | supernodes | ||
| matrix (SpMV) | random | ||
| matrix (SpMV) | supernodes |
| question | prediction | actual |
|---|---|---|
| does csr beat adj_list at all? (per-node vecs are already contiguous — where’s the win?) | ||
| matrix vs csr: what does the frontier materialization cost? | ||
| supernode ratio: does CSR shrink the 85× tail or just shift it? | ||
| CSR build time vs adj_list build time |
Implementation log
- csr.rs: counting-sort build + slice two_hop; all 5 tests green
- matrix.rs: spmv_masked + two-SpMV two_hop; all 4 tests green
- hop_bench full table recorded above (checksums match: random=10220457, supernodes=7890665)
- external: same 2-hop count on FalkorDB
(
MATCH (a)-[*1..2]->(b) WHERE id(a)=<src> RETURN count(DISTINCT b)) — ns/query here: - optional: neo4j same query — ns/query:
Surprises / dead ends:
Questions from the reading guides
GraphBLAS + Delta_Matrix (reading-graphblas-internals.md)
- Why delta_minus instead of deleting from M (CSR delete cost):
- dot3 vs saxpy at frontier 10 vs 10⁶:
- When BITMAP fits a label matrix:
- Why (M ∪ DP) ∖ DM reads beat flush-per-write:
- Delta_Matrix → LSM vocabulary map:
neo4j record store (reading-neo4j-record-store.md)
- 1000-edge Expand: chain (~110 ns/edge) vs CSR stream — ×:
- Why 15 B nodes / 34 B rels — field inventory:
- Delete cost: chain vs CSR vs Delta_Matrix:
- Property chains vs M12 columns for
WHERE n.age > 65: - Index-free adjacency: what survives DRAM, what died:
memgraph storage (reading-memgraph-storage.md)
- Why edges in both endpoints’ vectors; FalkorDB’s transposed trio:
- small_vector inline + power-law degrees:
- Per-object vs per-row delta chains under supernode edge inserts:
- memgraph vector vs kuzu CSR slice — 16 B triples vs 8 B ids:
- PageRank on pointer soup vs matrix — where the bus time goes:
kuzu (reading-kuzu.md)
- Node-group-bounded rebuilds — which insert pattern still hurts:
- Why Intersect needs sorted adjacency:
- Triangle intermediates: binary join vs AGM at m=16M:
- Factorized count(*) 2-hop as a matrix expression:
- Best topic-12 encoding for CSR target columns:
WCOJ (reading-wcoj.md)
- Star-graph R⋈S intermediates vs triangle output:
- AGM bound for the 4-cycle:
- Galloping and power-law skew:
- Why
C<A>=A²never materializes A² (dot3): - M10 trigger for intersect vs binary join (pattern cyclicity):
LDBC SNB (reading-ldbc-snb.md)
- Why timed dependency-tracked inserts:
- IC5-ish pattern: anchor + expand count + worst representation:
- Which architecture flatters itself on uniform-degree graphs:
- Where multi-hop cardinality estimation dies:
- SF that fits this Mac per representation (bytes/edge each):
Cross-topic threads
- Pointer chase vs stream = topic 0’s cache ladder deciding graph architecture: neo4j chains pay ~110 ns/edge, CSR pays bandwidth.
- Delta overlay everywhere (Delta_Matrix, kuzu transient groups, GraphBLAS pending tuples) = topic 4’s LSM applied to adjacency.
- Masks = predicate pushdown (topic 10) into the kernel; SpMV frontier = topic 11’s batch, spelled algebraically.
- memgraph = topic 8’s N2O deltas + topic 9’s skip list, at vertex granularity; bit-smuggling ledger: deleted-flag in delta pointer low bits (PointerPack), neo4j’s 35-bit pointers in the inUse byte.
- Supernodes = the graph-shaped tail: same lesson as Zipfian keys (topic 2) and JOB correlations (topic 10) — uniform data lies.
M13 log (naive adjacency core — the baseline M20 must beat)
- node/edge store over adjacency lists + label bitmaps
- scan-anchor-then-expand for
(a:L)-[:R]->(b) - Expand fills M11 batches; bench engine-vs-raw (interpretation overhead = the M11 payoff measurement)
- update-pain notes for the M20 delta overlay design
Done when
- csr + matrix tests green; hop_bench table filled with matching checksums; FalkorDB external comparison recorded.
- Reading-guide questions answered.
- M13 update-pain notes written.
Topic 14 — Vector Search
qdrant territory, and every DB is adding it. The ANN problem: return the k nearest vectors WITHOUT scanning everything, trading exactness for speed. The whole field is one curve — recall@k vs QPS — and every algorithm is a point-generator on it.
1. The problem shape
Exact k-NN over n vectors of dimension d = n·d multiply-adds per query: memory-bound streaming (topic 12’s lesson: 100K × 128-d f32 = 51 MB per scan). Indexes buy sublinear queries with three currencies: RAM, build time, and recall.
recall@10
1.0 ┤ brute force ●
│ HNSW ef=256 ●
│ HNSW ef=64 ● ← the curve every ANN
│ HNSW ef=16 ● paper/bench plots
│ IVF nprobe=1 ●
0.5 ┤
└──────────────────────────► QPS (log)
2. HNSW anatomy
A skip list generalized to proximity graphs (topic 2’s ladder, in metric space):
L2: ●────────────────● sparse "highways"
\ \
L1: ●──●─────●────────●──● each node: level ~ -ln(U)·1/ln(M)
\ \ \ \ \
L0: ●─●─●─●─●─●─●─●─●─●─●─●─● dense base layer, M0 = 2M links
- search: greedy-descend upper layers (ef=1), then best-first search on L0 with a candidate heap of size ef — ef IS the recall/latency knob, per query
- insert: draw a level, search down to it, at each level connect to M nearest found — but with the heuristic: keep a candidate only if it’s closer to the new point than to any already-kept neighbor (prunes clustered edges, keeps “spread” — this is what makes HNSW navigable, not just M-NN)
- memory hunger: links = n·(M0 + M·E[levels]) ids + the raw vectors — RAM-resident by design
3. The quantization ladder
Compression IS performance again (topic 12), now with a recall knob:
| scheme | bytes/dim (f32=4) | distance on encoded | recall cost |
|---|---|---|---|
| scalar u8 | 1 | integer dot + affine postprocess | tiny |
| PQ (m chunks × 256 centroids) | ~0.06–0.5 | LUT sums — d/m table lookups | real |
| binary | 1 bit | XOR + popcount | big, needs rescore |
The standard trick: search quantized with oversampling (fetch
3–4× top), then rescore the shortlist with full-precision vectors
(qdrant get_oversampled_top, search.rs:57). Late materialization,
vector edition.
4. IVF and DiskANN — the other two families
- IVF: k-means the space into nlist cells; query probes nprobe nearest cells. An index on the DATA distribution, not a graph; pairs naturally with PQ (IVF-PQ = Faiss’s workhorse). Cheap build, worse curve at high recall.
- DiskANN/Vamana: one flat graph (no levels), robust-pruned with slack α > 1 so greedy search converges in few hops; graph + full vectors on SSD, PQ codes in RAM steer the walk — one SSD read per hop visits a node’s vectors+links together. The B-tree/LSM disk-layout lesson (topics 3-4) applied to ANN: layout = access pattern.
5. Filtered search — the actually hard part
WHERE category = X AND vec NEAR q breaks graph indexes: filtering
DURING traversal cuts edges → the graph disconnects below a
selectivity threshold (percolation: a graph with avg degree K falls
apart when ~1/K of nodes survive — qdrant estimates this literally,
build.rs:378-386). The menu qdrant implements (search.rs:59-84):
selectivity ~1.0 → HNSW, filter as you score
selectivity low → plain scan of the filtered ids (index useless)
in between → ACORN (traverse 2-hop through blocked nodes)
or extra category-aware links (payload_m)
The planner-shaped decision (topic 10!): estimate filter cardinality → pick the algorithm. M14 inherits this: graph query + vector similarity = the anchor-selection problem again.
Experiments (experiments/)
brute.rs+data.rs+distance.rs— PROVIDED: exact top-k oracle over seeded clustered vectors; the recall referee.hnsw.rs— YOU implement: insert (level draw, greedy descent, heuristic neighbor selection, M/M0/ef_construction) + search (ef knob). Tests pin recall@10 ≥ 0.9 at ef=128 vs the oracle.quant.rs— YOU implement: global-min/max scalar u8 quantization + integer-dot distance + rescoring pipeline. Tests pin the error bound and rescored recall.ann_bench— PROVIDED: 100K × 128-d, 1K queries; brute-force baseline, then your HNSW recall/QPS across ef ∈ {16..256}, then quantized+rescore. Plot the curve, compare qdrant on same data (optional, via docker) in notes.md.
Reading guides
| guide | chapter |
|---|---|
| reading-hnsw-paper.md | HNSW: a skip list in metric space |
| reading-qdrant-hnsw.md | Qdrant’s HNSW: filtered search is a planner problem |
| reading-qdrant-quantization.md | The quantization ladder: shrink, search, rescore |
| reading-usearch.md | usearch: HNSW with the fat trimmed |
| reading-pq.md | Product quantization: 2^128 centroids in 16 bytes |
| reading-diskann.md | DiskANN: one SSD read per hop |
(helix-db was on the menu but its public repo now ships only CLI/SDKs — engine source no longer readable; qdrant + usearch cover the territory.)
Capstone M14
Vector index on node properties + distance kernels:
-
vectorproperty type on nodes; distance kernels (l2, dot, cosine) — scalar now, SIMD in M17 - HNSW index built from experiments/hnsw.rs, wired as an index type next to M3’s range indexes
- Cypher surface:
CALL db.idx.vector.query(label, prop, vec, k)(FalkorDB-compatible shape) - the filtered-search decision: label+property filters over the vector index — start with post-filter + oversampling, record the percolation cliff for M22
- bench: recall/QPS curve inside the engine vs raw index (the M11 interpretation-overhead measurement again)
DiskANN: one SSD read per hop
The paper that put billion-point ANN on SSDs without giving up recall — topics 3/4’s disk-layout discipline applied to graphs. Three ideas carry it: a flat graph built for provably few hops (Vamana’s α-slack pruning), a block layout that co-locates a node’s vector and links so each hop is exactly one read, and PQ codes in RAM that steer the walk while exact f32 distances rank the results.
1. Why HNSW can’t just go to disk
HNSW search = a beam of dependent point lookups; on disk each hop is a random read of (vector + links) living in different places. With ~200-500 hops per query and SSD reads at ~100 µs, naive paging is dead on arrival. DiskANN’s redesign targets exactly the metric that matters: number of SSD round trips per query.
2. Vamana: a flat graph built for few hops
No hierarchy — one graph, degree bound R, built with RobustPrune:
RobustPrune(p, candidates, α, R):
while candidates and |out(p)| < R:
p* = closest remaining candidate; add edge p→p*
remove every c with α·d(p*, c) ≤ d(p, c) ← the α slack
α = 1 gives HNSW’s Alg-4-style directional pruning. α > 1 (≈1.2) keeps LONGER edges — each greedy hop must shrink the distance to target by ≥ α, so hop count is O(log_α) — the graph trades extra degree for provably fewer hops. Build: two passes over random-order points (second pass with final α), each: greedy search from the medoid entry point, RobustPrune the visited set, add back-edges.
Levels vs slack: HNSW buys few hops with a hierarchy (extra RAM); Vamana buys it with edge slack (extra degree, flat layout — exactly what disk wants).
3. The layout + the steering trick
RAM: PQ codes for ALL points (~16-32 B each) ← steers the walk
SSD: per-node block: [ full f32 vector | R neighbor ids ]
node's data + links CO-LOCATED — one read per hop
Search: beam search (width W ≈ 4-8) — pick next candidates by PQ distance (RAM, free), fetch their SSD blocks (batched, async — MLP for disks!), compute EXACT distances from the fetched f32 vectors to rank results. PQ error only affects WHERE YOU WALK, not the final ranking — rescoring fused into traversal.
#![allow(unused)]
fn main() {
// the disk loop: PQ (RAM) decides where to walk, f32 (SSD) decides the
// ranking — the approximation never touches the final order
fn search(q: &[f32], k: usize, w: usize) -> Vec<(f32, Id)> {
let mut cands = MinHeap::from([(pq_dist(q, MEDOID), MEDOID)]);
let mut seen = HashSet::from([MEDOID]);
let mut results = Vec::new();
while let Some(beam) = cands.pop_n(w) { // W best, by PQ distance
for blk in ssd_read_batch(&beam) { // W reads IN FLIGHT at once
results.push((l2(q, &blk.vector), blk.id)); // exact f32 ranks
for &n in &blk.neighbors { // links came in the SAME read
if seen.insert(n) { cands.push((pq_dist(q, n), n)); }
}
}
if converged(&cands, &results, k) { break; }
}
top_k(results, k)
}
}
The topic-13 echo is exact: node + adjacency co-located per block = kuzu’s CSR node groups; PQ-in-RAM = the sparse index steering to the right block (ClickHouse marks, topic 12).
4. Numbers to retain
- ~5 ms mean latency, 95%+ recall@1 on billion-scale SIFT, one 64 GB machine — the headline
- beam width W trades SSD parallelism for wasted reads (the ef of the disk world)
- ~R·4 + d·4 bytes per SSD block: R=64, d=128 → ~768 B — pad to one 4 KB page, alignment IS the schema (topic 3’s slotted-page lesson)
Questions (answer in notes.md)
- Count SSD reads: HNSW-on-disk (links and vectors separate) vs DiskANN per hop. Where did the factor go?
- Why α > 1 provably shortens greedy walks — sketch the geometric argument (each hop shrinks distance by α).
- Beam search issues W reads concurrently. Connect to topic 0’s MLP: what’s the SSD equivalent of “10 outstanding misses”?
- Why is it fine that PQ steers but f32 ranks? What recall failure remains possible (PQ error > neighbor spacing → wrong REGION)?
- M28 preview: DiskANN blocks over object storage — what breaks when a “read” is 50 ms S3 GET instead of 100 µs NVMe? Which knob moves?
References
Papers
- Subramanya, Devvrit, Kadekodi, Krishnaswamy, Simhadri — “DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node” (NeurIPS 2019) — §2 Vamana + RobustPrune, §3 the SSD design; the eval headline numbers are in §4
Code
- DiskANN — Microsoft’s production implementation of the paper (optional; the paper is self-contained)
HNSW: a skip list in metric space
The index behind nearly every production vector store is topic 2’s skip list generalized to proximity graphs: express layers over a navigable base graph, greedy descent, and one query-time knob (ef) that buys recall with latency. This chapter reads the paper’s five algorithms; they map almost line-for-line onto usearch’s implementation (reading-usearch.md), so read the two together.
The skip-list lens (topic 2 cashed in)
NSW (the predecessor) was one navigable graph: greedy routing from a random entry, O(log n)-ish hops but polylog degree growth and a dependence on insertion order. HNSW’s fix IS the skip-list fix:
skip list: express lanes over a linked list, level ~ Geometric(p)
HNSW: express graphs over a proximity graph, level ~ ⌊-ln(U)·mL⌋
with mL = 1/ln(M) — chosen so level occupancy drops by factor M,
exactly a skip list’s p = 1/M. Search cost: O(log n) descent + a
constant-quality local search at L0.
The algorithms (paper numbering)
- Alg 1 INSERT: draw level ℓ; from the top entry point greedily descend (ef=1) to layer ℓ+1; from layer ℓ down to 0 run SEARCH-LAYER with ef_construction, connect to M selected neighbors, shrink any neighbor that now exceeds M_max (M0 = 2M at layer 0).
- Alg 2 SEARCH-LAYER: best-first over a min-heap of candidates and a max-heap of results, both bounded by ef; stop when the nearest candidate is farther than the worst result. The visited set is the hot structure — qdrant/usearch both pool it (topic 13’s stamp trick).
- Alg 4 SELECT-NEIGHBORS-HEURISTIC: the load-bearing detail.
Take candidates nearest-first; keep c only if
d(c, new) < d(c, kept)for all already-kept. Effect: neighbors cover DIRECTIONS, not just distances — clusters get one representative edge plus a long link outward. Without it (simple M-nearest), inter-cluster navigability dies.extendCandidatesandkeepPrunedConnectionsare the paper’s own knobs over it.
The whole query path (Alg 5 = descent + Alg 2), condensed:
#![allow(unused)]
fn main() {
fn search(idx: &Hnsw, q: &[f32], k: usize, ef: usize) -> Vec<Id> {
let mut ep = idx.entry_point;
for level in (1..=idx.max_level).rev() {
ep = greedy_closest(idx, level, ep, q); // upper layers: ef=1, just descend
}
let mut cands = MinHeap::from([(dist(q, ep), ep)]); // nearest candidate on top
let mut best = BoundedMaxHeap::new(ef); // worst-of-ef on top
let mut visited = VisitedSet::from([ep]); // THE hot structure
while let Some((d, c)) = cands.pop() {
if d > best.worst() { break; } // nearest cand can't improve: stop
for n in idx.neighbors(0, c) {
if !visited.insert(n) { continue; }
let dn = dist(q, idx.vec(n));
if dn < best.worst() || !best.full() {
cands.push((dn, n));
best.push_evicting((dn, n)); // ef bounds BOTH heaps
}
}
}
best.take_top(k) // hence ef ≥ k
}
}
Parameters, with defaults the ecosystem agreed on
| param | paper | usearch default | meaning |
|---|---|---|---|
| M | 5-48 | 16 (connectivity) | links/node upper layers |
| M0 | 2M | 32 | links at layer 0 |
| ef_construction | ~100 | 128 (expansion_add) | build-time beam |
| ef | ≥ k | 64 (expansion_search) | query-time beam — THE knob |
What to notice
- ef is per-QUERY: the recall/latency trade is decided at search time, not build time — nothing in the index changes.
- The heuristic (Alg 4) is where implementations differ or cheat;
qdrant’s
use_heuristicflag (graph_layers_builder.rs:41-42) makes it optional, usearch always applies it. - Distance metric only enters via comparisons — HNSW works for any metric-ish function, which is why cosine/dot/l2 are one codebase.
- Deletes are the unsolved wart: the paper has none; real systems tombstone + rebuild (qdrant has a graph_layers_healer.rs) — the CSR-update-pain story (topic 13) again.
Questions (answer in notes.md)
- Derive why mL = 1/ln(M) gives expected max level ln(n)/ln(M).
- What breaks if you connect to the M NEAREST instead of Alg 4’s heuristic on two well-separated clusters? Draw it.
- Why must ef ≥ k? What happens at ef = k exactly?
- Where does HNSW’s memory go for n=1M, d=128, M=16 (f32)? Vectors vs links — which dominates and by how much?
- The paper claims robustness to dimensionality vs NSW. What’s the skip-list analogue of “the entry point is always the same node”?
References
Papers
- Malkov, Yashunin — “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs” (IEEE TPAMI 2018, arXiv:1603.09320) — Algorithms 1-5 are the chapter; the eval is skimmable
Code
- usearch — the paper’s algorithms map to functions almost line-for-line; walked in reading-usearch.md
- qdrant — the production version, walked in reading-qdrant-hnsw.md
Product quantization: 2^128 centroids in 16 bytes
The paper that made billion-scale ANN affordable — and the “PQ” in
IVF-PQ, DiskANN, and qdrant’s encoded_vectors_pq.rs. One move does
all the work: quantize a PRODUCT of subspaces, so codebook size grows
exponentially while storage stays linear. Topic 12’s dictionary
encoding, but the dictionary is learned and the code is a
concatenation.
1. The core move: quantize a PRODUCT of subspaces
A vector quantizer with k centroids costs k·d to store and can’t exceed ~2²⁰ centroids in practice. PQ splits d dims into m chunks and quantizes each chunk independently with k* = 256 centroids:
x (d=128) → [x¹ | x² | ... | x¹⁶] m=16 chunks of 8 dims
q¹(x¹) q²(x²) ... — each an 8-bit centroid id
effective centroids: 256¹⁶ = 2¹²⁸ stored: 16 bytes/vector
codebook cost: m · 256 · (d/m) = 256·d floats — tiny
The exponential codebook for linear storage is the whole paper. Same energy as topic 12’s dictionary encoding, but the dictionary is LEARNED (k-means per subspace) and the code is a concatenation.
2. SDC vs ADC — where you eat the approximation
- SDC (symmetric): quantize the query too; distance = precomputed centroid-to-centroid tables. Fastest, two approximations.
- ADC (asymmetric): keep the query exact; per query build the
[m × 256]table of‖qʲ - cⱼ,ᵢ‖², then any database vector’s distance ≈ m table lookups + adds. One approximation — strictly better recall for the same codes. Everyone ships ADC (qdrant’sEncodedQueryPQ, encoded_vectors_pq.rs:39-41).
#![allow(unused)]
fn main() {
// ADC: pay m·256 exact sub-distances ONCE per query…
fn adc_table(q: &[f32], cb: &Codebook) -> Vec<[f32; 256]> {
(0..cb.m).map(|j| {
let qj = &q[j * cb.sub_d..(j + 1) * cb.sub_d];
std::array::from_fn(|i| l2_sq(qj, cb.centroid(j, i)))
}).collect() // [m × 256] f32 — small enough to live in L1
}
// …then EVERY candidate costs m byte-indexed lookups, zero float math
fn adc_dist(code: &[u8], table: &[[f32; 256]]) -> f32 {
code.iter().zip(table).map(|(&c, t)| t[c as usize]).sum()
}
}
The paper also derives the distance ESTIMATOR bias (ADC underestimates on average) and a correction — worth knowing it exists; most systems skip the correction and oversample instead.
3. IVFADC — the system the paper actually ships
Coarse quantizer (k-means, nlist cells) → residual x - c(x) →
PQ-encode the RESIDUAL. Query: probe nprobe cells, ADC-scan their
inverted lists.
query ─► nearest nprobe cells ─► ADC over residual codes ─► top-k
(coarse index) (16 B/vector, L1 LUTs)
Residuals matter: they’re centered around 0 with much smaller variance than raw vectors, so 256 centroids per subspace go further. This is frame-of-reference (topic 12’s FOR bit-packing) in learned form: subtract the predictable part, encode the residual cheaply.
4. What survived twenty years
- ADC lookup tables — unchanged everywhere
- residual encoding — DiskANN keeps PQ codes in RAM to steer SSD reads (reading-diskann.md)
- OPQ (rotate before chunking so subspaces decorrelate) — the main refinement worth knowing exists
- the recall gap at high k — why oversample+rescore became standard (reading-qdrant-quantization.md §4)
Questions (answer in notes.md)
- m=16 vs m=64 at fixed 16 bytes/vector total (256 vs 4 centroids per chunk?? — work out what actually changes): which knob trades what?
- Why must chunks be (roughly) statistically independent for PQ to work well? What does OPQ’s rotation fix — connect to BYTE_STREAM_SPLIT (topic 12).
- ADC table build is m·256·(d/m) float ops per query. At what shortlist size does table build dominate scanning?
- Why encode residuals instead of raw vectors in IVFADC? State it in FOR terms.
- SDC would let you precompute ALL tables once (no per-query work). Why does nobody care?
References
Papers
- Jégou, Douze, Schmid — “Product Quantization for Nearest Neighbor Search” (IEEE TPAMI 2011) — §2 the quantizer, §3 SDC/ADC and the estimator, §4 IVFADC; the paper everyone builds on
- Ge, He, Ke, Sun — “Optimized Product Quantization” (CVPR 2013) — optional; the rotation refinement worth knowing exists
Code
- qdrant
lib/quantization/src/encoded_vectors_pq.rs— the production ADC, walked in reading-qdrant-quantization.md
Qdrant’s HNSW: filtered search is a planner problem
Production HNSW: the paper plus five years of scar tissue — and
filtering, qdrant’s actual specialty. The payoff of this chapter is
watching a query planner appear inside an index: estimate the
filter’s cardinality, then pick HNSW / brute force / ACORN per query,
with the percolation threshold measured (not assumed) at build time.
Everything lives under lib/segment/src/index/hnsw_index/.
1. The graph, split into build and serve shapes
graph_layers_builder.rs:35GraphLayersBuilder— per-nodeRwLock’d link lists (parallel build),ef_construct(:38),level_factor = 1/ln(M)(:317 — the paper’s mL),get_random_layer(:385,-ln(sample) * level_factorat :391),link_new_point(:414) — Alg 1.:41-42use_heuristic— Alg 4 as a flag; findselect_candidates_with_heuristicbelow it and match the paper.graph_layers.rs:74GraphLayers— the FROZEN serve-side graph:search_on_level(:109),search_entry(:248 — the ef=1 greedy descent). Build structure ≠ serve structure — the same builder/immutable split as CSR (topic 13).search_context.rs:8SearchContext— the two bounded heaps of Alg 2.visited_pool.rs:9VisitedListHandle— pooled visited sets reused across queries (:14 comment says exactly this): your hop_bench stamp trick, productionized with a pool because queries are concurrent.
2. The filtered-search decision (the good part)
hnsw/search.rs:55-84 — per-query algorithm choice:
#![allow(unused)]
fn main() {
let mut algorithm = SearchAlgorithm::Hnsw;
if acorn_enabled && let Some(filter) = filter {
let query_point_cardinality =
payload_index.with_view(|v| v.estimate_cardinality(filter, ...))?; // :74
let selectivity = cardinality / available_vector_count; // :80
if selectivity <= acorn_max_selectivity { algorithm = Acorn; }
}
}
Topic 10 inside the vector index: estimate cardinality, then pick the plan. The full menu:
- selectivity high → normal HNSW,
FilteredScorerrejects non-matching points during traversal - selectivity low →
search_plain_batched(:264) — brute-force the filtered id list; belowfull_scan_thresholdthe graph can’t help - middle → ACORN (
search_on_level_acorn, graph_layers.rs:155): traverse through blocked nodes by expanding to 2-hop neighbors, so the filtered subgraph stays connected without extra links
3. Percolation, measured not assumed
hnsw/build.rs:378-386:
#![allow(unused)]
fn main() {
// According to percolation theory, random graph becomes disconnected
// if 1/K points are left, where K is average number of links per point
let percolation = 1. - 2. / (average_links_per_0_level_int as f32);
}
Build-time: sample subgraph connectivity at the 2/K survival point
(:390-392, three samples, take max) and add extra links
(payload_m, hnsw.rs:93) for indexed payload categories if the
main graph would shatter under filtering. The failure mode is
MEASURED during build — topic 0 discipline inside an index builder.
4. Odds and ends worth grepping
hnsw/build.rs:95-109—full_scan_thresholdderives the “indexing threshold”: tiny segments never build a graph at allgraph_links.rs— serialized link format: delta-compressed, topic 12 encodings applied to graph edgesgpu/— GPU-built HNSW (topic 18 preview)graph_layers_healer.rs— repairing the graph around deleted points instead of rebuilding: the deletes wart, patched
Questions (answer in notes.md)
- Why does the visited pool matter more here than in hop_bench? (Concurrency + allocation, name both.)
- ACORN’s 2-hop expansion: what does it cost in scoring work vs payload_m’s extra links in RAM? When is each the right buy?
estimate_cardinalitycomes from the payload index. What’s the M14 equivalent — which structure estimates label selectivity? (M13’s label bitmaps.)- Why is
full_scan_thresholdin BYTES-ish terms (kB) rather than a point count? (Think d and the real cost unit.) - The build/serve split (Builder with RwLocks → frozen GraphLayers): map it onto topic 13’s transient/persistent kuzu split and Delta_Matrix. What’s the graph-index “flush”?
References
Papers
- Patel, Kraft, Guestrin, Zaharia — “ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data” (SIGMOD 2024, arXiv:2403.04871) — optional; the 2-hop-expansion idea qdrant adopted
- The HNSW paper itself is reading-hnsw-paper.md
Code
- qdrant — everything under
lib/segment/src/index/hnsw_index/:graph_layers_builder.rs,graph_layers.rs,search_context.rs,visited_pool.rs,hnsw/search.rs(the per-query algorithm choice),hnsw/build.rs(the percolation measurement),graph_links.rs,graph_layers_healer.rs
The quantization ladder: shrink, search, rescore
Topic 12’s thesis — compression IS performance — with a new twist:
here compression is LOSSY, so the system needs machinery to claw the
recall back (oversample + rescore). This chapter climbs qdrant’s
three-rung ladder (scalar u8, PQ, binary) and the pipeline that makes
lossy codes safe; that pipeline shape is what M14 copies. The
encoders live in their own crate, lib/quantization/src/; the wiring
into search is lib/segment/src/vector_storage/quantized/.
1. Scalar u8 (encoded_vectors_u8.rs)
The affine trick: store alpha/offset (:86-87), quantize
i = (value - offset) / alpha (:95). The clever part is scoring
WITHOUT decode — expand the dot product:
dot(q, v) ≈ Σ (α·qᵢ + off)(α·vᵢ + off)
= α² Σ qᵢvᵢ + α·off·(Σqᵢ + Σvᵢ) + d·off²
↑ integer dot ↑ per-vector precomputed sums
postprocess_score (:61, :100) applies the affine correction using
per-vector offsets stored alongside the codes. Integer dot on u8 =
4× fewer bytes moved AND SIMD-friendlier (topic 17 will vectorize
exactly this). Quantile-based range (quantile.rs) clips outliers
so alpha isn’t wasted on the tails.
#![allow(unused)]
fn main() {
// score u8 codes WITHOUT decoding: integer dot + affine correction
fn dot_u8(q: &Encoded, v: &Encoded, alpha: f32, off: f32, d: usize) -> f32 {
let int_dot: u32 = q.codes.iter().zip(&v.codes)
.map(|(&a, &b)| a as u32 * b as u32)
.sum(); // the u8 loop SIMD loves
alpha * alpha * int_dot as f32
+ alpha * off * (q.sum + v.sum) // Σqᵢ, Σvᵢ: stored per vector
+ d as f32 * off * off // constant for the whole index
}
}
2. Product quantization (encoded_vectors_pq.rs)
:30CENTROIDS_COUNT = 256— one byte per chunk, by construction;:27-29k-means over a 10K sample (BtrBlocks-style sampling, topic 12), max 100 iterations:32EncodedVectorsPQ— codes = chunk-wise centroid ids;:46Metadata.centroids:39-41EncodedQueryPQ— THE trick (ADC): per query, precompute a[chunks × 256]table of distances from each query sub-vector to every centroid; scoring a vector = d/chunk_size table lookups + adds, no float math per candidate
PQ trades multiply-adds for L1-resident lookups. Note what it does to HNSW: distances become approximate EVERYWHERE, so graph traversal itself degrades — which is why qdrant defaults to scalar for HNSW and PQ mostly for memory-starved setups.
3. Binary (encoded_vectors_binary.rs)
:26EncodedVectorsBin, one bit per dim (sign):144xor_popcnt— Hamming distance as XOR + popcount, with SSE/NEON paths (:165-190): 32× compression, distances in a few cycles- only sane with rescoring, and mainly for high-d embeddings where signs carry most of the angle information
4. Oversample + rescore (the recall clawback)
lib/segment/src/index/hnsw_index/hnsw/search.rs:57
get_oversampled_top — search the quantized index for
top × oversampling, then rescore that shortlist with original f32
vectors and cut to top. Late materialization (topic 12): cheap
representation for the scan, expensive one only for survivors.
quantized_scorer_builder.rs picks the scorer; storage variants
(RAM/mmap/chunked) live next to it.
query ──► HNSW over u8/PQ/bin codes ──► top·x candidates
│ rescore with f32
▼
top k
Questions (answer in notes.md)
- Derive the u8 dot-product expansion above; what must be stored per vector for it to work? (Σvᵢ.)
- Why does PQ hurt HNSW traversal more than it hurts a flat IVF scan? (Where do approximate distances compound?)
- Binary quantization of a 1536-d embedding vs u8 of a 128-d one: bytes, distance cost, expected recall — which needs more oversampling and why?
- The ADC lookup table is [m × 256] f32. For d=128, m=16: does it fit in L1? What happens to the trick when m=64?
- M14 decision: which rung of the ladder for graph node embeddings, given M17 SIMD comes later — commit + reason.
References
Papers
- Jégou, Douze, Schmid — the PQ paper (IEEE TPAMI 2011) — gets its own chapter: reading-pq.md
Code
- qdrant — encoders in
lib/quantization/src/(encoded_vectors_u8.rs,encoded_vectors_pq.rs,encoded_vectors_binary.rs,quantile.rs); wiring inlib/segment/src/vector_storage/quantized/(quantized_scorer_builder.rsand the storage variants) andlib/segment/src/index/hnsw_index/hnsw/search.rs(get_oversampled_top)
usearch: HNSW with the fat trimmed
qdrant’s HNSW is production plumbing; usearch is the algorithm with the fat trimmed — same paper, ~10× less code, essentially all of it in one header. Read it as the reference implementation for YOUR hnsw.rs. The interesting part is the memory layout: one contiguous “tape” per node.
1. The node tape
:2242class index_gt— the whole index: a vector of node pointers + per-node tapes:2404neighbors_ref_t— a view over raw bytes (tape_, :2416): each node’s storage is[level | links-L0 | links-L1 | ... ], counts inline, slots preallocated to connectivity limits
node tape: ┌───────┬────────────────┬──────────┬─────┐
│ level │ L0: cnt + M0×id │ L1: cnt+M×id │ ... │
└───────┴────────────────┴──────────┴─────┘
one allocation, all levels adjacent
Compare qdrant (per-level Vec<Vec<_>> in the builder, serialized
compressed later) and neo4j’s scattered records (topic 13): usearch
picks “everything about a node in one place” — one pointer chase per
node visit, then streaming.
#![allow(unused)]
fn main() {
// the tape: level header, then per-level slots preallocated to the
// connectivity limit — neighbors(l) is offset arithmetic, not Vec hops
struct NodeTape<'a> { bytes: &'a [u8] } // one allocation per node
impl NodeTape<'_> {
fn neighbors(&self, l: usize, m: usize, m0: usize) -> &[u32] {
let slot = |links: usize| (1 + links) * 4; // count + ids
let start = 2 + if l == 0 { 0 } // 2 = level header
else { slot(m0) + (l - 1) * slot(m) };
let cnt = read_u32(self.bytes, start) as usize;
cast_u32(&self.bytes[start + 4..start + 4 + cnt * 4])
} // one miss to reach the tape; the rest prefetches
}
}
2. Defaults = the paper’s advice, frozen
:1563default_connectivity() = 16(M):1591connectivity_base = 2 × M(M0) — computed at :1604:1568default_expansion_add() = 128(ef_construction):1573default_expansion_search() = 64(ef)
3. The three core walks
:3234search_to_insert_— Alg 1’s per-level beam during insert;:3239form_links_to_closest_(defined :4262) applies the Alg 4 heuristic and back-links (shrinking overfull neighbors):3446search_to_find_in_base_— Alg 2 on layer 0 with an optionalpredicate— filtering exists here too, but ONLY as filter-during-traversal (no cardinality planner, no ACORN: compare qdrant’s search.rs:55-84 — that gap IS qdrant’s moat):3232,:3354— the greedy descent loops (level >= 0; --level), including the update path (usearch supports in-place vector updates — rare among HNSW libs)
4. Concurrency
:664-717 striped_locks_gt — insertions take striped per-node
locks (~threads × connectivity stripes), not one big lock; searches
are lock-free over published tapes. Simpler than qdrant’s
RwLock-per-node builder; the cost is update-vs-read races handled by
slot versioning in index_dense.hpp.
Questions (answer in notes.md)
- Bytes per node for M=16, M0=32, avg 1.06 levels, u32 slots — tape vs qdrant-builder Vec-of-Vecs (count headers, capacity slack, allocator overhead).
- Why preallocate link slots to the max instead of growing? What does it cost in memory, and what does it buy under concurrent insert?
- Filter-during-traversal with a 1% predicate on usearch: what happens, and which qdrant mechanism was built to fix exactly this?
- usearch templates the metric; qdrant enum-dispatches scorers. Map this to topic 11’s compiled-vs-vectorized argument — who wins where?
- For YOUR hnsw.rs: steal the tape or use
Vec<Vec<u32>>per level? Decide, justify with expected access pattern, and note what M17’s SIMD needs.
References
Papers
- Malkov, Yashunin — the HNSW paper (arXiv:1603.09320) — gets its own chapter: reading-hnsw-paper.md
Code
- usearch — all of it in
include/usearch/index.hpp(+index_dense.hppfor the type-erased/quantized wrapper); C++ templates, but small enough to hold in your head
Topic 14 notes — vector search
Predictions (fill BEFORE implementing hnsw.rs / quant.rs)
Baseline (provided, measured): brute force 185 QPS at recall 1.0 (100K × 128-d f32 = 51 MB per scan, 500 queries in 2.70 s).
| config | predicted recall@10 | predicted QPS | actual recall | actual QPS |
|---|---|---|---|---|
| hnsw ef=16 | ||||
| hnsw ef=64 | ||||
| hnsw ef=256 | ||||
| u8 scan+rescore ×1 | ||||
| u8 scan+rescore ×4 |
| question | prediction | actual |
|---|---|---|
| hnsw build time for 100K (vs 2.7 s for one brute sweep) | ||
| ef=16→256: how many × QPS lost for how much recall gained? | ||
| u8 scan ×4: above or below the hnsw curve? (it’s O(n) but 4× fewer bytes) | ||
| max_level with m=16 on 100K points (ln n / ln m ≈ ?) |
Implementation log
- hnsw.rs: level draw + insert (Alg 1/4) + search (Alg 2); all 5 tests green
- quant.rs: affine u8 + symmetric distance + rescore pipeline; all 5 tests green
- ann_bench curve recorded above
- optional: qdrant docker on the same data — its ef curve vs mine:
- stretch: sift-1m from ann-benchmarks; recall/QPS there:
Surprises / dead ends:
Questions from the reading guides
HNSW paper (reading-hnsw-paper.md)
- Why mL = 1/ln(M) ⇒ E[max level] = ln(n)/ln(M):
- M-nearest vs Alg-4 heuristic on two clusters (draw it):
- Why ef ≥ k; what happens at ef = k:
- 1M × 128-d, M=16: vectors vs links bytes:
- Skip-list analogue of the fixed entry point:
qdrant HNSW + filtering (reading-qdrant-hnsw.md)
- Why the visited pool matters more than in hop_bench:
- ACORN 2-hop vs payload_m extra links — cost each, when each:
- M14’s estimate_cardinality equivalent (label bitmaps):
- Why full_scan_threshold is in kB not points:
- Build/serve split → kuzu transient/persistent + Delta_Matrix map:
qdrant quantization (reading-qdrant-quantization.md)
- u8 dot expansion derivation + what’s stored per vector:
- Why PQ hurts HNSW traversal more than IVF scans:
- binary 1536-d vs u8 128-d: bytes/distance/recall/oversampling:
- ADC LUT [m×256] in L1 — d=128 m=16 vs m=64:
- M14 quantization rung — commit + reason:
usearch (reading-usearch.md)
- Bytes/node: tape vs Vec-of-Vecs (headers, slack, allocator):
- Why preallocate max link slots:
- 1% filter on usearch traversal — what happens, qdrant’s fix:
- Template metric vs enum scorer → compiled-vs-vectorized map:
- My hnsw.rs layout decision + M17 SIMD needs:
PQ (reading-pq.md)
- m=16 vs m=64 at fixed bytes — which knob trades what:
- Chunk independence + OPQ ↔ BYTE_STREAM_SPLIT:
- When ADC table build dominates:
- Residual encoding in FOR terms:
- Why nobody ships SDC:
DiskANN (reading-diskann.md)
- SSD reads per hop: naive HNSW-on-disk vs DiskANN:
- α > 1 shortens walks — geometric argument:
- Beam width W ↔ topic 0’s MLP:
- PQ steers / f32 ranks — the remaining failure mode:
- M28: DiskANN over S3 — what breaks, which knob:
Cross-topic threads
- Recall/QPS curve = the RUM triangle with a new axis: you can now buy speed with CORRECTNESS, not just space.
- HNSW = topic 2’s skip list in metric space; level ~ -ln(U)/ln(M) is Geometric(1/M) in disguise.
- Quantization = topic 12’s compression-IS-performance, lossy edition; oversample+rescore = late materialization.
- Filtered search = topic 10’s planner inside an index: estimate cardinality → pick HNSW / ACORN / plain scan; percolation is the measured failure mode.
- DiskANN block layout = topic 3’s pages + topic 0’s MLP for SSDs; visited-set pooling = hop_bench’s stamp trick, concurrent.
M14 log (vector index + distance kernels)
- vector property type + l2/dot/cosine kernels (scalar; M17 SIMD)
- HNSW index type alongside M3 range indexes
-
CALL db.idx.vector.query(...)surface - filtered search: post-filter + oversampling first; record the percolation cliff for M22
- engine-vs-raw recall/QPS bench (M11 overhead measurement)
Done when
- All hnsw + quant tests green; ann_bench table filled; HNSW beats brute-force QPS by >10× at recall ≥ 0.9.
- Reading-guide questions answered; M14 quantization decision committed.
Topic 15 — Replication, Consensus & Distribution
From single node to system. Raft is table stakes; the interesting part is what each system does differently: valkey ships commands asynchronously and calls it a day, qdrant wraps tikv’s raft-rs around cluster METADATA only, and everyone chooses a different point on the consistency/latency line.
1. The topology menu
leader/follower, async valkey default: fast, loses acked writes on failover
leader/follower, semi-sync WAIT n: ack after n replicas confirm — bounded loss
consensus (Raft/VSR) majority ack BEFORE commit: no acked-write loss,
pays a round trip
leaderless / multi-master topic 31 (CRDTs) — merge instead of order
The axis is WHO can acknowledge: leader alone (async), leader+n (semi-sync), majority (consensus). Everything else is bookkeeping to survive the failure cases each choice creates.
2. Raft in one diagram
stateDiagram-v2
Follower --> Candidate: election timeout\n(randomized!)
Candidate --> Leader: votes from majority
Candidate --> Follower: saw higher term /\ncurrent leader
Candidate --> Candidate: split vote, new term
Leader --> Follower: saw higher term
Three sub-problems, deliberately separable:
- election: terms are logical clocks; one vote per term per node (persisted!); randomized timeouts break symmetry. Vote-granting rule carries safety: only vote for candidates whose log is at least as up-to-date (last term, then length).
- log replication: leader appends,
AppendEntriescarries(prev_index, prev_term)— follower rejects on mismatch, leader decrements and retries → logs converge (Log Matching). Commit = replicated on majority AND from the current term (§5.4.2’s subtle rule — the one every homegrown Raft gets wrong). - safety: a committed entry survives any future election because the voters’ logs contain it and voters only elect up-to-date candidates. Quorum intersection does all the work.
3. The write path, three ways
valkey: client → leader (ack NOW) → repl buffer → followers RTT: 0
WAIT 1: client → leader → follower ack → client ack RTT: 1 (opt-in)
raft: client → leader → majority fsync+ack → commit → apply → client
Replication lag is the async design’s currency — the repl_lag experiment measures how fsync policy (topic 5’s ladder) sets its floor.
4. Consistency models (the ladder, briefly)
linearizable → sequential → causal → eventual. Raft gives linearizable writes; linearizable READS need care (leader leases or ReadIndex — a heartbeat round to prove leadership before serving). Async replicas serve stale reads by design; “read your writes” requires session stickiness or tracking offsets (DDIA ch. 5).
5. Sharding
- hash slots (valkey cluster: 16384 slots, CRC16(key) mod 16384): uniform spread, cheap rebalancing at slot granularity, no range scans
- ranges (tikv, FoundationDB): locality + range scans, but hot ranges need splitting (the graph analogue: hash by node id is easy; BUT traversals cross shards — M29’s problem)
Experiments (experiments/)
sim.rs— PROVIDED: deterministic in-process network — lockstep ticks, seeded delivery, partition/heal injection. No threads, no time: reproducible distributed failures (topic 16’s DST preview).raft.rs— YOU implement: election + log replication over the sim (tick/receive/propose state machine — the raft-rs shape without the Ready plumbing). Tests pin: single leader, one leader per term, replication, minority-partition commit freeze, stale leader overwrite.partition_test— PROVIDED: 5-node cluster timeline under partition/heal, prints who leads, what commits when.repl_lag— PROVIDED (runs without stubs): leader→follower log shipping over channels with REAL fsync per policy (every entry / 8 / 64 / none) — measures throughput and ack latency; topic 5’s fsync ladder becomes replication lag.
Reading guides
| guide | chapter |
|---|---|
| reading-raft-paper.md | Raft: logs converge by construction |
| reading-valkey-replication.md | Valkey replication: ack first, replicate later |
| reading-raft-rs.md | raft-rs: consensus with the I/O left out |
| reading-qdrant-consensus.md | Qdrant’s consensus: raft for metadata, replica sets for data |
| reading-vsr.md | Viewstamped Replication: same invariants, opposite choices |
| reading-ddia-repl.md | Lag, lies, and linearizability |
Capstone M15
Ship the WAL to a follower; then upgrade to Raft:
- stage 1: M5’s WAL streamed to a follower over M7’s RESP server (PSYNC-shaped: full snapshot + offset-tagged stream + partial resync from the backlog)
-
WAIT-style ack levels; measure acked-write loss on kill -9 failover (the crash harness from topic 5, now distributed) - stage 2: experiments/raft.rs promoted to the WAL commit path — entries commit through majority ack
- measure: async vs WAIT 1 vs raft commit latency, same workload
- read path decision: stale follower reads allowed? Record for M22/M29
Lag, lies, and linearizability
The concepts layer over this topic’s code — Kleppmann’s three
chapters give the vocabulary for everything valkey and Raft do:
replication lag and its anomalies (ch. 5), why partial failure and
lying clocks make distribution hard (ch. 8), and what
linearizability/consensus actually promise (ch. 9). Read ch. 5
alongside valkey’s replication.c and ch. 9 alongside the Raft
paper; ch. 8 is the connective tissue.
Ch. 5 — Replication: the anomaly catalog
The valuable part is the taxonomy of what LAG does to readers:
anomaly fix
──────────────────────────────────────────────────────────
read-your-writes session stickiness, or read-after
(I posted, refresh, -my-offset (track repl offset per
it's gone) session — valkey WAIT-ish)
monotonic reads pin session to one replica
(time goes backward
across refreshes)
consistent prefix causally-ordered delivery (or
(answer before single-partition ordering)
question)
Question per anomaly: which does our M15 stage-1 follower exhibit, and what does the fix cost?
Also from ch. 5: statement vs WAL vs logical (row) replication —
valkey ships statements (post-propagateNow rewrite), our M15 ships
the physical WAL, and the tradeoff table maps onto topic 5’s logging
choices. Multi-leader and leaderless sections preview topic 31
(CRDTs) — skim.
Ch. 8 — The trouble: partial failure
The chapter is one argument: in a distributed system you cannot distinguish {slow node, dead node, slow network, lost packet}, and clocks lie. Extract:
- Timeouts are the only failure detector, and every timeout is a
guess (our sim.rs makes this concrete:
election_timeoutticks). - Process pauses: a GC pause makes a live leader dead-then-alive — the fencing-token problem. Question: how do Raft terms act as fencing tokens? What does valkey have instead? (nothing — hence split-brain during failover.)
- Clock skew: why leader leases need bounded clock error, while ReadIndex needs none (it uses a message round instead of time).
Ch. 9 — Linearizability and consensus
- Linearizability = single-copy illusion: once a read returns a value, all later reads return it or newer. Test-worthy definition: there is a single total order consistent with real-time.
- Raft gives linearizable WRITES; reads need ReadIndex or leases (README §4). Question: why is reading from the leader WITHOUT ReadIndex not linearizable? (Deposed leader serving stale reads during a partition — walk the timeline.)
- CAP, properly: during a Partition choose Available-but-stale
or Consistent-but-unavailable-on-the-minority-side. valkey chose
A; Raft chose C. Our
minority_partition_cannot_committest IS the C choice, executed. - Consensus ≡ atomic broadcast ≡ CAS: the equivalence proofs. FLP says async consensus can’t be guaranteed to terminate — randomized timeouts are the practical dodge, not a refutation.
Questions for notes.md
- Build the 2×3 matrix: {async, semi-sync, raft} × {read-your- writes, monotonic reads, consistent prefix} — which combos hold?
- A client’s WAIT 1 returns success, then the primary dies and a NON-acked replica is promoted. Which ch. 5 guarantee broke, and which ch. 9 property would have prevented it?
- Fencing tokens: sketch how M15’s follower rejects a stale leader’s WAL stream using terms.
- Why does FLP not doom Raft in practice? One sentence.
- Linearizable-read options: leader lease vs ReadIndex vs quorum read — cost per read of each, and which M22 (the capstone’s read-path milestone) should pick.
References
Papers / Books
- Kleppmann — “Designing Data-Intensive Applications” (O’Reilly 2017) — ch. 5 (Replication), ch. 8 (The Trouble with Distributed Systems), ch. 9 (Consistency and Consensus); pair ch. 5 with reading-valkey-replication.md and ch. 9 with reading-raft-paper.md
Qdrant’s consensus: raft for metadata, replica sets for data
The architectural decision worth studying: qdrant runs Raft over
cluster METADATA only — collection schemas, shard placement, peer
membership. The vectors themselves replicate OUTSIDE raft, through
replica sets with an ack-count knob. This chapter walks
src/consensus.rs (the raft-rs driving loop from
reading-raft-rs.md, in production) and the
weaker data-path contract in lib/collection.
The split
┌─ raft (consensus.rs) ──────────────────────────┐
│ topology: which peers exist, which shard lives │
│ where, collection create/drop, replica state │
│ (Active/Dead/Partial) — LOW volume │
└────────────────────────────────────────────────┘
┌─ data path (NO raft) ──────────────────────────┐
│ point upserts → forwarded to ALL replicas of │
│ the shard; ack policy = write_consistency │
│ _factor — HIGH volume │
└────────────────────────────────────────────────┘
Why: pushing every vector write through raft = majority RTT + log fsync per upsert on a bulk-ingest workload. Metadata changes are rare and MUST be agreed on; point writes are frequent and can tolerate replica-set semantics with repair. Same call as kafka (controller raft vs ISR data path).
Anchor map
| anchor | what it is |
|---|---|
| consensus.rs:36 | type Node = RawNode<ConsensusStateRef> |
| consensus.rs:48 | struct Consensus — the driving loop owner |
| consensus.rs:537 | the ready loop: tick / step / process |
| consensus.rs:877 | on_ready — drain the Ready bundle |
| consensus.rs:885/928/1017 | Ready vs LightReady handling |
1. The driving loop (:537)
Exactly the raft-rs contract from reading-raft-rs.md, in production:
a thread that selects over {incoming raft messages, proposal
channel, tick timer}, calls step/tick, then on_ready.
#![allow(unused)]
fn main() {
// the whole of consensus.rs, condensed: raft-rs decides, this loop does
fn run(&mut self) {
loop {
match self.select_with_timeout(TICK) {
Recv::RaftMsg(m) => self.node.step(m).ok(), // network in
Recv::Propose(op) => self.node.propose(vec![], op.encode()),
Recv::Timeout => self.node.tick(), // clock in
}
if !self.node.has_ready() { continue; }
let mut rd = self.node.ready();
self.storage.persist(rd.entries(), rd.hs()); // 1. fsync FIRST
self.transport.send(rd.take_messages()); // 2. then talk
for e in rd.take_committed_entries() {
self.topology.apply(e); // 3. committed → cluster metadata
}
self.node.advance(rd); // 4. done
}
}
}
Question: find where snapshots trigger — what happens when a new peer joins and the log has been compacted?
2. on_ready (:877-1017)
Follow the ordering: persist entries → send messages → apply
committed entries (which mutate the consensus state = the cluster
topology map) → advance. LightReady (:928) is the
advance_append optimization — messages that can go out without
waiting for a fresh persistence round.
3. The data path’s weaker contract
Shard replication (lib/collection): writes go to all replicas of a
shard; write_consistency_factor of them must ack. A replica that
misses writes is marked Dead via raft and re-synced (transfer)
before serving again. Question: this is valkey’s WAIT plus
membership-through-consensus — which failure mode of plain WAIT does
the raft-managed replica-state machine close, and which remains
(hint: acked-but-not-on-all-replicas writes during a failover race)?
Questions for notes.md
- Why is metadata volume low enough for raft but point writes not? Estimate: 10K upserts/s × majority fsync (topic 5 numbers) = ?
- Replica states Active/Dead/Partial — map each to a Raft Progress state (replicate/probe/snapshot). Same problem, different layer?
- What consistency does a qdrant READ get on vectors? Is it linearizable? Under what config?
- For the capstone: M15 puts the WAL itself through raft (stage 2) — qdrant chose not to. Which is right for a graph database’s write volume, and why might FalkorDB’s answer differ from qdrant’s?
- Where does qdrant persist the raft log and HardState? Find the Storage impl behind ConsensusStateRef.
References
Code
- qdrant —
src/consensus.rs(the driving loop; the anchor map above) andlib/collection(shard replication,write_consistency_factor, replica states) - The library it embeds is raft-rs — walked in reading-raft-rs.md
Raft: logs converge by construction
Paxos won the theory; Raft won the industry (etcd, tikv, CockroachDB, consul, qdrant’s metadata, …). The pitch is decomposition: leader election, log replication, and safety as separable concerns, plus a strong-leader design that forbids the log-repair cases Paxos allows. Read the extended version — the ATC ’14 paper is a cut-down of the tech report; ~18 pages, but §5 is the whole game.
Paxos: any replica can propose → logs converge by proof gymnastics
Raft: ONLY the leader appends → logs converge by construction
(entries flow one direction: leader → followers)
Reading order
| section | what to extract |
|---|---|
| §5.1 | the three states + RPC menu (only 2 RPCs!) |
| §5.2 | elections: terms, randomized timeouts |
| §5.3 | log replication: the consistency check + repair |
| §5.4 | safety — read TWICE, especially §5.4.2 |
| §6 | membership changes (joint consensus) — skim |
| §7 | log compaction / snapshots — skim, topic 5 déjà vu |
| Fig 2 | the whole algorithm on one page — print it |
§5.2 — Elections
- A term is a logical clock: monotonically increasing, exchanged on every RPC; a node seeing a higher term immediately becomes follower and adopts it.
- One vote per term, and
voted_foris PERSISTED before answering. Question: what double-vote scenario does a crash+restart create ifvoted_forwere volatile? - Randomized election timeouts (150–300 ms in the paper) break the split-vote livelock. Question: why randomize per-election rather than assigning fixed distinct timeouts per node? (Hint: what happens after a partition heals with two live candidates?)
§5.3 — Log replication
The consistency check is the heart:
AppendEntries carries (prev_log_index, prev_log_term)
follower: my log has an entry at prev_log_index with prev_log_term?
yes → append (truncating any conflicting suffix)
no → reject; leader decrements next_index and retries
This induction gives the Log Matching Property: if two logs have the same (index, term) they are identical up to that index. Question: why must a follower truncate conflicting entries rather than skip them? Construct the divergent-log picture from Fig 7.
The follower side, in full:
#![allow(unused)]
fn main() {
// the consistency check — Log Matching by induction, one RPC at a time
fn handle_append(&mut self, m: AppendEntries) -> bool {
if m.term < self.term { return false; } // stale leader: fenced
match self.log.get(m.prev_index) {
None => false, // hole → leader backs up
Some(e) if e.term != m.prev_term => false, // divergent history
_ => {
for (i, new) in m.entries.iter().enumerate() {
let idx = m.prev_index + 1 + i as u64;
if self.log.term_at(idx) != Some(new.term) {
self.log.truncate(idx); // conflicting suffix DIES
self.log.push(new.clone()); // (it was never committed)
}
}
self.commit_index = m.leader_commit.min(self.log.last_index());
true
}
}
}
}
§5.4 — Safety (the part that matters)
Two mechanisms, and both are needed:
- Election restriction (§5.4.1): a voter refuses candidates whose log is less up-to-date — compare last term first, then length. So any elected leader already contains every committed entry (a committed entry is on a majority; a winning candidate got a majority; the two majorities intersect).
- §5.4.2 — the current-term commit rule: a leader only advances
commit_indexvia majority replication of an entry from its own term. Older-term entries commit indirectly when a current-term entry above them commits.
Figure 8 is the counterexample that makes rule 2 necessary — work it by hand:
term 2 entry replicated to 2/5 by S1 → S1 crashes
S5 elected (term 3), appends locally, crashes
S1 re-elected (term 4), replicates the OLD term-2 entry to 3/5
— is it committed? NO. S5 can still win (its term-3 entry
is "newer" by last-term comparison) and truncate it.
Our raft.rs test stale_leader_uncommitted_overwritten is exactly
this shape. Every homegrown Raft that skips §5.4.2 loses acked
writes here.
Questions to answer in notes.md
- Why persist
(current_term, voted_for, log)but NOTcommit_index? What recomputes commit_index after restart? - Fig 8 step-by-step: which specific quorum-intersection argument fails without the current-term rule?
- Why does a leader never overwrite/delete its OWN log entries, and what breaks if it could?
- §7: a snapshot at index i replaces the log prefix — what must the snapshot record besides the state? (last_included_index/term — why the term?)
- Map to valkey: which Raft properties does async replication give up, and what do you get back for each?
References
Papers
- Ongaro, Ousterhout — “In Search of an Understandable Consensus Algorithm” (USENIX ATC 2014) — read the extended version (the tech report); §5 twice, Fig 2 printed, Fig 8 worked by hand
- Ongaro — “Consensus: Bridging Theory and Practice” (Stanford PhD dissertation, 2014) — optional; the long-form version with the membership-change fixes
Code
- The production implementation is raft-rs — walked in reading-raft-rs.md
raft-rs: consensus with the I/O left out
The production Raft that tikv and qdrant embed, and the design worth
stealing: the library owns ONLY the state machine — no threads, no
I/O, no storage. You drive it with tick()/step(msg) and it hands
you a Ready bundle of work to do. That inversion is what makes
consensus testable — and what our sim-based raft.rs stub imitates.
The shape
┌────────────── your code ──────────────┐
│ timer → tick() network → step() │
│ ▼ │
│ RawNode<Storage> │
│ │ │
│ has_ready()? │
│ ▼ │
│ Ready { messages, entries-to-append, │
│ committed_entries, hs, ss } │
│ 1. persist entries + hardstate │
│ 2. send messages │
│ 3. apply committed_entries │
│ 4. advance() │
└───────────────────────────────────────┘
Sans-io before the name existed. Deterministic by construction —
which is exactly why our sim.rs can test consensus without threads
(and why topic 16’s DST loves this shape).
Anchor map
| anchor | what it is |
|---|---|
| raw_node.rs:293 | RawNode — the public wrapper |
| raw_node.rs:487 | ready() — collect pending work |
| raw_node.rs:562 | has_ready() — the poll predicate |
| raw_node.rs:663 | advance() — “I did the work” |
| raw_node.rs:678 | advance_append — split persistence ack |
| raft.rs:263 | Raft<T: Storage> — the actual state machine |
| raft.rs:939 | maybe_commit — §5.4.2 lives here |
| raft.rs:1148/1176/1226 | become_follower/candidate/leader |
| raft.rs:1283 | campaign |
| raft.rs:1346 | step — the message dispatch root |
| raft.rs:1539 | hup — election timeout fires |
| raft.rs:2045/2291/2348 | step_leader/candidate/follower |
| raft.rs:2499 | handle_append_entries |
| tracker/progress.rs:8-12 | Progress { matched, next_idx } |
1. The step_* dispatch (raft.rs:1346)
step() first handles term logic (higher term → become_follower,
lower term → mostly ignore/reject), THEN dispatches on role. Compare
with our raft.rs stub: same shape, match self.role. Question:
which messages must be handled before the role dispatch, and why?
(Term comparison is role-independent — Fig 2’s “all servers” rules.)
2. Progress tracking (tracker/progress.rs:8-12)
Per-follower, leader-side:
matched highest index KNOWN replicated on that follower
next_idx next index to send (optimistic; decremented on reject)
maybe_commit (raft.rs:939) sorts matched values, takes the
majority-th, commits if that entry’s term == current term — §5.4.2
as three lines of code. Question: probe/replicate/snapshot states in
Progress — what problem does each state solve for a lagging
follower?
#![allow(unused)]
fn main() {
// §5.4.2, executable: the majority-replicated index counts only if
// the entry there is from MY term — older entries then ride along
fn maybe_commit(&mut self) -> bool {
let mut matched: Vec<u64> =
self.progress.values().map(|p| p.matched).collect();
matched.sort_unstable_by(|a, b| b.cmp(a)); // descending
let quorum_idx = matched[self.quorum() - 1]; // majority-th highest
if quorum_idx > self.commit_index
&& self.log.term_at(quorum_idx) == Some(self.term)
{
self.commit_index = quorum_idx;
return true; // Fig 8 cannot happen
}
false
}
}
3. The Ready contract (raw_node.rs:487-678)
The ordering rules are load-bearing:
- persist entries + HardState BEFORE sending messages that reference them (a vote you didn’t persist can be double-cast after crash)
- apply committed_entries in order; never apply above what’s persisted
advance()tells the library the batch is done;advance_appendlets you ack persistence asynchronously (group-commit the raft log — topic 5’s ladder again)
Question: what specific safety violation occurs if you send the
vote-response message before fsyncing voted_for?
4. What our raft.rs keeps / drops
| raft-rs | our stub |
|---|---|
| Ready bundle + advance | direct send via Sim (no I/O to defer) |
| Storage trait + persistence | in-memory Vec<(term, cmd)> |
| Progress probe/snapshot states | just next_idx decrement |
| joint-consensus membership | fixed peer set |
| pre-vote, leases, learners | absent |
Same invariants pinned by tests; ~10× less plumbing.
Questions for notes.md
- Why does raft-rs contain no
fsync, no sockets, no threads — and what does that buy tikv/qdrant integration-wise? maybe_commit: write out the sorted-matched-index computation for 5 nodes with matched = [7,5,5,3,2]. Commit index?- next_idx decrement-and-retry is O(divergence) round trips — what optimization does the paper’s §5.3 footnote suggest, and does raft-rs implement it?
- advance_append: how does splitting the persistence ack enable pipelining, and what must you still NOT reorder?
- Map Ready → M15 stage 2: which parts of your WAL commit path play the roles of persist/send/apply/advance?
References
Papers
- The Raft paper itself is reading-raft-paper.md — Fig 2 is the spec this code implements
Code
- raft-rs —
src/raw_node.rs(the Ready contract),src/raft.rs(the state machine; the anchor map above),src/tracker/progress.rs; qdrant’s embedding of it is reading-qdrant-consensus.md
Valkey replication: ack first, replicate later
The canonical async leader/follower design: ack the client
immediately, ship the command stream best-effort, survive disconnects
with a backlog. Everything Raft pays for, valkey skips — this chapter
reads replication.c (~5600 lines, sliced by the anchor map) to see
the price of each skip.
The mental model
client write → primary executes → ack client ← ZERO repl RTT
│
▼
replication BUFFER (one copy, shared)
├──→ replica 1 socket
├──→ replica 2 socket
└──→ backlog (ring view, for partial resync)
The replication stream IS the command stream (statement-based, after
propagateNow rewrites nondeterminism, e.g. SPOP → SREM).
Anchor map
| anchor | what it is |
|---|---|
| replication.c:137 | createReplicationBacklog — the resync ring |
| replication.c:352-366 | feedReplicationBufferWithObject — one buffer, many readers |
| replication.c:449 | feedReplicationBuffer — append + wake replicas |
| replication.c:854 | primaryTryPartialResynchronization — PSYNC accept/deny |
| replication.c:1077 | syncCommand — full sync: fork + RDB + stream |
| replication.c:3731+ | replica-side REPL_STATE_* handshake machine |
| replication.c:4564 | replicaofCommand — topology is a runtime command |
| replication.c:4947 | replicationRequestAckFromReplicas |
| replication.c:4996 | waitCommand — the semi-sync opt-in |
| replication.c:5565 | failoverCommand — coordinated manual failover |
| server.c:3609 | propagateNow — the rewrite point |
1. One buffer, many cursors (:352-449)
Pre-6.2 lore: each replica had its own output buffer — N replicas = N copies of every write. Now one shared block list; each replica and the backlog hold a reference (block + offset). Question: what does this share with topic 7’s client output buffers, and why does a slow replica now cost O(1) memory instead of O(stream)?
2. PSYNC — partial resync (:854)
Replica reconnects and says PSYNC <replid> <offset>:
replid matches (or matches replid2 within second_replid_offset)
AND offset still inside the backlog ring
→ +CONTINUE: replay backlog from offset (cheap)
else
→ +FULLRESYNC: fork, RDB snapshot, then stream (expensive)
replid2 is the failover trick: a promoted replica keeps its old
primary’s replid as replid2, so siblings can partial-resync from
the new primary. Question: why is the pair (replid, offset) exactly
Raft’s (term, index) with weaker guarantees? What can it NOT detect
that (prev_index, prev_term) can?
#![allow(unused)]
fn main() {
// PSYNC: (replid, offset) is (term, index) with the safety stripped —
// a matching offset is ASSUMED to mean matching history, never checked
fn try_partial_resync(&self, replid: &str, offset: u64) -> Sync {
let id_ok = replid == self.replid
|| (replid == self.replid2 && offset <= self.second_replid_offset);
if id_ok && self.backlog.contains(offset) {
Sync::Continue(self.backlog.since(offset)) // replay the ring: cheap
} else {
Sync::Full(self.fork_rdb_snapshot()) // fork + RDB + stream
}
}
}
3. The replica handshake (:3731+)
REPL_STATE_CONNECT → CONNECTING → RECEIVE_PING_REPLY → ... → SEND_PSYNC → RECEIVE_PSYNC_REPLY → TRANSFER → CONNECTED. A
textbook nonblocking state machine driven by the event loop (topic
7). Note the replica flushes its ENTIRE dataset on full sync.
4. WAIT — bounded loss, opt-in (:4996)
WAIT numreplicas timeout: block the client until n replicas have
acked primary_repl_offset. Acks arrive via REPLCONF ACK <offset>
(requested at :4947). Crucial asymmetry vs Raft:
WAIT: execute → ack replicas → unblock client (write ALREADY applied)
Raft: replicate → majority ack → THEN apply/ack
Question: WAIT returns 1 (only 1 of 2 replicas acked in time). What does the client know? What does it NOT know? Can the write still be lost on failover?
5. Failover (:5565)
FAILOVER coordinates: pause writes → wait for target replica to
catch up → send it PSYNC FAILOVER → demote self. Without the
pause+catchup, acked writes die. Question: which Raft mechanism
replaces this entire dance, and what does it cost per write?
Questions for notes.md
- Replication is statement-shipping after
propagateNowrewrites — what’s the analogue of topic 5’s logical-vs-physical WAL choice? - Backlog sizing: repl-backlog-size vs write rate vs disconnect duration — write the inequality for “partial resync succeeds”.
- Chained replication (replica of a replica): how do offsets stay coherent down the chain?
- Why does full sync fork? Connect to topic 5’s copy-on-write snapshot discussion.
- For M15 stage 1: which parts of PSYNC do you keep (replid+offset, backlog ring, +CONTINUE/+FULLRESYNC) and which do you simplify?
References
Code
- valkey —
src/replication.c(~5600 lines; slice it with the anchor map above rather than reading linearly) andsrc/server.c(propagateNow, the statement-rewrite point)
Papers
- None — this is a pure code walk; the consensus counterpoint is reading-raft-paper.md
Viewstamped Replication: same invariants, opposite choices
The other consensus protocol — actually the FIRST (VR 1988 predates Paxos’s publication). Read it AFTER Raft: same invariants, opposite engineering choices at almost every fork — deterministic round-robin leadership instead of elections, logs shipped at view change instead of repaired after, and (the shocker) no disk required. TigerBeetle ships VSR in production, so this is not a museum piece.
Terminology decoder
| Raft | VSR |
|---|---|
| term | view |
| leader | primary |
| election | view change |
| log index | op-number |
| commit_index | commit-number |
| RequestVote / AppendEntries | STARTVIEWCHANGE / DOVIEWCHANGE / PREPARE / PREPAREOK |
The three sub-protocols
- Normal operation: client → primary → PREPARE to all → wait f PREPAREOKs (f+1 including self = majority) → commit → reply. Same wire shape as AppendEntries.
- View change: on suspicion, replicas send STARTVIEWCHANGE; on f+1, send DOVIEWCHANGE with their log to the new primary. The new primary picks the best log (highest view, then op-number) and installs it via STARTVIEW.
- Recovery: a restarted replica asks the group for state instead of reading disk.
The view change, condensed — note what’s missing (no votes, no randomized timeouts):
#![allow(unused)]
fn main() {
// the next primary is DETERMINED: view mod n. it just needs f+1 logs
fn install_view(&mut self, view: u64, msgs: &[DoViewChange]) {
assert!(msgs.len() >= self.f + 1); // quorum intersects commits
let best = msgs.iter()
.max_by_key(|m| (m.last_normal_view, m.op_number))
.unwrap(); // Raft's election restriction,
self.log = best.log.clone(); // applied AFTER the fact —
self.op_number = best.op_number; // logs ship at view change,
self.commit_number = // where Raft repairs later
msgs.iter().map(|m| m.commit_number).max().unwrap();
self.broadcast(StartView { view, log: &self.log });
}
}
The forks in the road (the reason to read this)
choice Raft VSR (Revisited)
─────────────────────────────────────────────────────────────
who leads next any up-to-date node ROUND-ROBIN: view mod n
that wins votes (deterministic!)
log transfer new leader repairs new primary RECEIVES logs
followers forward in DOVIEWCHANGE, picks best
durability fsync log before ack NO DISK REQUIRED —
durability from replication;
recovery protocol replaces it
vote persistence voted_for fsynced view number in memory;
recovery rejoins carefully
The no-disk claim is the shocker: VSR argues f+1 replicas holding an entry in MEMORY is durable (survives f failures), so fsync per write is optional. The catch: correlated failures (whole-cluster power loss) lose everything — which is why TigerBeetle adds disk back but uses VSR’s recovery thinking to handle corrupted disks (a fault model Raft ignores entirely).
Questions for notes.md
- Round-robin primary (view mod n): what does this remove from the protocol (no vote-splitting, no randomized timeouts) and what does it cost (a down node’s turn)?
- DOVIEWCHANGE ships whole logs to the new primary — Raft ships nothing at election, repairing later. Bandwidth vs latency: when is each better?
- The no-disk argument: write the failure sequence where VSR- without-disk loses committed data but Raft-with-fsync doesn’t.
- Why does the recovery protocol need a nonce?
- TigerBeetle: which VSR feature makes “disk can lie” (checksum fails, torn write) survivable, where Raft’s model assumes storage is faithful? Connect to topic 5’s torn-page discussion.
References
Papers
- Liskov, Cowling — “Viewstamped Replication Revisited” (MIT-CSAIL-TR-2012-021, 2012) — the version to read; the three sub-protocols plus the no-disk argument
- Oki, Liskov — “Viewstamped Replication: A New Primary Copy Method” (PODC 1988) — optional; the original, for the historical claim
Code
- tigerbeetle — VSR in
production Zig, with the storage-fault model bolted on;
src/vsr/if you want to see the protocol shipped
Topic 15 notes — replication, consensus & distribution
Predictions (fill BEFORE implementing raft.rs)
repl_lag baseline (provided, measured 2026-07-10, macOS F_FULLFSYNC): 2000 × 128 B entries, WAIT-1-style ack per entry:
| follower fsync | entries/s | ack p50 µs | ack p99 µs |
|---|---|---|---|
| every 1 | 339 | 2972.7 | 3506.5 |
| every 8 | 2431 | 18.5 | 3706.3 |
| every 64 | 9665 | 13.6 | 2880.1 |
| never | 18568 | 6.2 | 32.5 |
Topic 5’s fsync ladder, now visible as replication lag: the p50 drops 160× from every-1 to every-8 (most acks ride a group), but the p99 stays ~3 ms — someone always pays the F_FULLFSYNC.
| question | prediction | actual |
|---|---|---|
| ticks to first leader, 5 nodes (timeout 10-20, heartbeat 3) | ||
| how often does seed 0..10 hit a split vote (extra term)? | ||
| stale-leader test: how many ticks after heal until logs converge? | ||
| minority leader: does it stay Leader forever during partition? (it hears no higher term…) |
Implementation log
- raft.rs: tick (election timeout + heartbeats) — election tests green
- receive: RequestVote/Vote — one leader per term across seeds
- receive: AppendEntries consistency check + truncate; AppendResp next_idx repair — replicates_to_all green
- §5.4.2 commit rule — minority + stale-leader tests green
- partition_test timeline recorded here:
Surprises / dead ends:
Questions from the reading guides
Raft paper (reading-raft-paper.md)
- Why persist (term, voted_for, log) but not commit_index:
- Fig 8 without the current-term rule — which intersection fails:
- Why a leader never overwrites its own entries:
- Snapshot needs last_included_term because:
- valkey vs Raft: what async gives up, what it gets back:
valkey replication.c (reading-valkey-replication.md)
- Statement-shipping vs WAL-shipping ↔ topic 5 logical/physical:
- Backlog-size inequality for partial resync success:
- Chained replication offset coherence:
- Why full sync forks (COW):
- M15 stage 1: which PSYNC parts to keep:
raft-rs (reading-raft-rs.md)
- Why no fsync/sockets/threads in the library:
- maybe_commit on matched=[7,5,5,3,2] → commit index:
- next_idx decrement optimization (§5.3 footnote):
- advance_append pipelining — what still can’t reorder:
- Ready → M15 stage 2 mapping:
qdrant consensus (reading-qdrant-consensus.md)
- 10K upserts/s through raft = ? (use the 3 ms fsync above):
- Active/Dead/Partial ↔ Progress replicate/probe/snapshot:
- qdrant vector-read consistency:
- WAL-through-raft: right for a graph DB? FalkorDB’s answer:
- Storage impl behind ConsensusStateRef:
VSR (reading-vsr.md)
- Round-robin primary: removes / costs:
- DOVIEWCHANGE ships logs vs Raft repairs later — when each wins:
- VSR-no-disk loses committed data when:
- Recovery nonce prevents:
- TigerBeetle’s “disk can lie” ↔ topic 5 torn pages:
DDIA ch. 5/8/9 (reading-ddia-repl.md)
- {async, semi-sync, raft} × {RYW, monotonic, prefix} matrix:
- WAIT-1-then-wrong-promotion — which guarantee broke:
- Terms as fencing tokens in M15’s follower:
- Why FLP doesn’t doom Raft in practice:
- Linearizable reads: lease vs ReadIndex vs quorum — M22 pick:
Cross-topic threads
- repl_lag IS topic 5’s fsync ladder: the follower’s durability policy becomes the leader’s observable ack latency. Consensus makes this mandatory (majority must fsync before commit).
- sim.rs = topic 16’s DST in miniature: seeded delivery order + no wall clock ⇒ every partition bug replays from a u64.
- valkey’s replica handshake state machine = topic 7’s nonblocking event-loop pattern; the shared repl buffer = client output buffers.
- (replid, offset) vs (prev_index, prev_term): PSYNC can resume a stream but can’t DETECT divergence; Raft’s consistency check can.
- Hash slots vs ranges = topic 13’s problem in disguise: traversals (range scans) want locality, uniform load wants hashing.
M15 log (WAL shipping → Raft)
- stage 1: M5 WAL streamed over M7 RESP server, PSYNC-shaped (replid+offset, backlog ring, +CONTINUE/+FULLRESYNC)
- WAIT-style ack levels; kill -9 failover: measure acked-write loss per ack level
- stage 2: experiments/raft.rs → the WAL commit path
- latency: async vs WAIT 1 vs raft, same workload (compare against the repl_lag table above)
- read-path decision (stale follower reads?) recorded for M22/M29
Done when
- All 5 raft tests green across seeds; partition_test timeline shows commit freeze + truncation-on-heal; prediction table filled.
- Reading-guide questions answered; M15 stage-1 design sketched.
Topic 16 — Testing & Correctness Engineering
The topic that separates hobby DBs from production DBs. The unifying idea: a database is too big to test by example — you need oracles (what should be true) and generators (inputs you’d never write by hand), plus determinism so every failure replays.
generator ──→ SUT ──→ result
│ │
└──→ oracle ────────┴──→ equal? / invariant holds?
Every technique in this topic is one choice of generator + oracle:
| technique | generator | oracle |
|---|---|---|
| property testing | random ops | in-memory model |
| DST | random ops + FAULTS + sim clock | model + invariants |
| PQS (SQLancer) | random query around a pivot row | “pivot row must appear” |
| TLP / metamorphic | one query, three partitions | self-consistency |
| fuzzing | coverage-guided byte mutation | “doesn’t crash” |
| Jepsen/elle | concurrent client histories | linearizability checker |
| Z3 / Cosette | symbolic (ALL inputs at once) | UNSAT = proven equal |
1. Deterministic simulation testing (DST)
FoundationDB’s gift to the industry (turso, TigerBeetle, Antithesis built identities on it). Rule: the SUT owns NO nondeterminism — clock, network, disk, scheduling all come through interfaces backed by a seeded RNG in test.
real: code → syscalls → kernel (time, threads, fsync — nondeterministic)
DST: code → traits ──→ SimClock (ChaCha8 from seed)
├──→ SimFile (buffered; crash DROPS unsynced,
│ may TEAR the last write)
└──→ SimNet (topic 15's sim.rs already did this)
⇒ failure = a u64 seed. Re-run seed = same bug, every time.
turso’s simulator (testing/simulator/) generates interaction plans (workload-distributed SQL + property assertions), executes them over fault-injecting IO (pread/pwrite/sync faults, seeded latency), and double-checks by running the same plan twice. Fault coverage the kernel will never give you on demand: torn writes, short reads, fsync failures (topic 5’s crash matrix, automated).
2. Metamorphic oracles: SQLancer
The test-oracle problem: for a random query, who knows the right answer? SQLancer’s insight — you don’t need one. You need a second query whose result must RELATE to the first:
- PQS (pivoted query synthesis): pick a random existing row (the pivot), synthesize a WHERE clause that evaluates TRUE on it (rectify NULLs as you go), assert the pivot appears in the result. Finds: expression-evaluation bugs. Needs: an expression evaluator of your own (the cost of PQS).
- TLP (ternary logic partitioning): any predicate p splits rows
three ways —
p,NOT p,p IS NULL(SQL is 3-valued!). SoQ ≡ Q where p ∪ Q where NOT p ∪ Q where p IS NULL. Finds: optimizer logic bugs. Needs: nothing but a union. - NoREC: run the query optimized (
WHERE p) and unoptimized (SELECT (p) FROM tcounted as booleans) — counts must match. Finds: predicate-pushdown/index bugs.
3. Fuzzing
Coverage-guided byte mutation (libFuzzer/AFL via cargo-fuzz) for
anything that PARSES: Cypher text, RESP frames, page/SST decoders.
turso fuzzes expressions/casts/schemas; the capstone reference ships
fuzz/fuzz_targets/ for runtime + clauses + expressions. Structured
fuzzing (arbitrary-derived ASTs, like turso’s fuzz_target!(|expr: Expr|)) beats byte soup once the parser is solid.
4. Jepsen & elle
Black-box distributed testing: drive real concurrent clients against
a real cluster while injecting partitions (topic 15’s failure menu),
record the history, then check it against a consistency model.
elle finds cycles in the serialization graph (G0/G1c/G-single…)
in polynomial time by exploiting known list-append semantics.
The redis-raft analysis is the cautionary tale: acked writes lost on
failover — exactly our stale_leader test, found in production code.
5. SMT: proving instead of testing
Z3 answers “does there EXIST an input where P ≠ Q?” — testing all inputs at once. Encode two query plans as formulas over symbolic rows; UNSAT = rewrite proven, SAT = counterexample row (Cosette). Perfect fit for topic 10’s rewrite rules: filters/projections are pure logic, exactly Z3’s home turf. Z3 itself is a masterclass codebase: a high-performance search engine over logic (tactics = query plans for proofs).
Experiments (experiments/)
sim_fs.rs+kv.rs— PROVIDED: a tiny WAL-backed KV store over a simulated file system (buffered writes lost on crash, last record may TEAR) with four INJECTABLE BUGS:LostDelete,NoSyncOnCommit,TornWriteAccepted,StaleRead.dst.rs— YOU implement: the harness. Seeded op/crash-schedule generation, execute against kv + BTreeMap model, recover, verify. Tests pin: every injected bug caught within 200 seeds;Bug::Nonesurvives 500 seeds.shrink.rs— YOU implement: delta-debugging minimizer — a failing op sequence shrinks to a minimal reproducer that still fails.tlp.rs— YOU implement: 3-valued predicate evaluator + TLP check over a mini row-filter engine with a deliberately buggy “optimized” path (NULL-blind pushdown). TLP must catch it; the fixed path must pass.crash_matrix— PROVIDED (runs without stubs): sweeps crash-point × sync policy on the correct KV, reports recovery outcomes (topic 5’s crash harness, now simulated and exhaustive).
Reading guides
| guide | chapter |
|---|---|
| reading-turso-simulator.md | turso’s simulator: every failure is a u64 seed |
| reading-fdb-simulation.md | FoundationDB & Antithesis: the whole cluster in one thread |
| reading-sqlancer.md | SQLancer: 450+ bugs from three tiny oracles |
| reading-pqs-tlp-papers.md | PQS & TLP: solving the test-oracle problem twice |
| reading-jepsen.md | Jepsen & elle: isolation anomalies are cycles |
| reading-z3.md | Z3 & Cosette: testing every input at once |
Capstone M16
The correctness spine (reference bar: fuzz/ with runtime/clauses/
expressions targets, tck_done.txt, flow_tests_done.txt):
- openCypher TCK subset runner as the black-box oracle; track
tck_done.txt-style progress - proptest model-checking: graph ops (add/delete node/edge, property set) vs an in-memory model oracle
- DST harness: SimClock + fault-injecting IO under M5’s WAL — the crash matrix becomes exhaustive and seeded
- cargo-fuzz targets: Cypher parser, RESP framing, page/SST decoders
- Z3: verify two topic-10 rewrite rules equivalent; break one on purpose and get the counterexample row
FoundationDB & Antithesis: the whole cluster in one thread
FoundationDB made the most radical testing bet in databases: design
the entire distributed system so it can run — every node, disk, and
network — inside one deterministic thread, then spend the saved
debugging time injecting compressed chaos. This chapter walks that
design philosophy, the Flow language that makes it possible, and
Antithesis, where the same founders push the determinism boundary
down to a hypervisor so unmodified systems get it for free. It’s the
“in the large” version of what our dst.rs stub does in miniature.
The FDB bet
FoundationDB (2010s) decided the database and its test harness are ONE artifact: the entire distributed system — every node, disk, network — runs single-threaded inside one process, scheduled by a seeded event loop.
┌─ one OS process, one thread ────────────────────────┐
│ simulated cluster: N "machines" as actor sets │
│ SimClock — logical time, jumps to next event │
│ SimNetwork — seeded delays, drops, PARTITIONS │
│ SimDisk — seeded corruption, torn writes, │
│ "disk that lies" (bit rot) │
│ + BUGGIFY(p) — code-embedded chaos macros │
└──────────────────────────────────────────────────────┘
Flow (their C++ dialect) exists to make this possible: actors +
futures compile to deterministic state machines; wait() yields to
the simulator’s scheduler. No pthreads in the data path — the same
discipline raft-rs reaches by being sans-io (reading-raft-rs.md).
The three pillars
- Determinism: one seed reproduces a whole-cluster failure, including the partition timings. (Our topic 15 sim.rs in the large.)
- BUGGIFY: ~800 macros in the FDB codebase that, in simulation only, make rare paths common — “pretend the buffer is full”, “return commit_unknown_result”. The SUT cooperates with the tester. Question: why is injecting at the semantic level (commit_unknown_result) more powerful than at the syscall level (EIO)?
- Test oracles as workloads: swizzled clogging, machine kills mid-recovery, dumb sanity workloads — each asserts invariants (e.g., a read at version v sees all commits ≤ v) rather than specific outputs.
The whole architecture reduces to a seeded event loop plus one macro:
#![allow(unused)]
fn main() {
// the "cluster" advances by popping the next event — no threads, no sleeps
fn run(seed: u64) {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut events = BinaryHeap::new(); // min-heap on fire_time
while let Some((t, ev)) = events.pop() {
clock.jump_to(t); // logical time TELEPORTS
for follow_up in step(ev, &mut rng) { // deliver, drop, delay, corrupt…
events.push(follow_up);
}
}
}
fn buggify(rng: &mut impl Rng, p: f64) -> bool {
cfg!(simulation) && rng.random_bool(p) // rare paths made common;
} // compiled out in production
}
The famous claim: FDB found so few bugs in production because the simulator ran millions of cluster-years of compressed chaos — CPU-bound, so faster than real time.
Antithesis: the generalization
Same founders, next act: if you can’t rewrite your system in Flow, put the WHOLE VM under a deterministic hypervisor — every syscall, interrupt, and thread interleaving is replayable. Coverage-guided exploration (“multiverse debugging”) decides which random branches to explore deeper. turso runs its Dockerfile.antithesis image there.
approach determinism boundary rewrite cost
──────────────────────────────────────────────────────────
FDB / Flow language runtime total (Flow)
turso simulator IO/clock traits moderate (DI)
topic-15 sim.rs message passing small (sans-io)
Antithesis hypervisor ZERO
Questions for notes.md
- FDB tests “the disk lies” (corruption, torn writes) — which of Raft’s assumptions does this violate, and how does VSR/ TigerBeetle thinking (reading-vsr.md) address it?
- BUGGIFY is compiled out in production. What’s the argument that test-only branches DON’T invalidate what you tested?
- Simulation can’t catch: (a) a compiler bug, (b) a kernel fsync lie, (c) a race in the simulator itself, (d) real-clock dependencies. For each: which layer of the table above catches it, if any?
- Why does deterministic simulation get FASTER than real time for IO-bound workloads (logical clock jumps to next event)?
- For M16: our engine already isolates IO behind traits (M5 WAL, M6 buffer pool). List the remaining nondeterminism sources to corral (threadpool from M9! HashMap iteration! rand in plans!).
References
Papers & docs
- FoundationDB — “Simulation and Testing” + “Testimony” docs (apple.github.io/foundationdb) — the design-philosophy source; no clone needed
- Antithesis blog (antithesis.com/blog) — by the FDB founders; the deterministic-hypervisor generalization and “multiverse debugging”
Code
- foundationdb —
flow/README.md— the Flow language: actors + futures compiled to deterministic state machines; skim for thewait()-yields-to- scheduler discipline rather than the C++ details
Jepsen & elle: isolation anomalies are cycles
Jepsen believes nothing you tell it: it drives real concurrent clients against a real cluster while breaking the network, records the history, and only afterwards decides whether that history was even possible under the claimed consistency model. The checker is the hard part — this chapter covers elle’s trick for making it polynomial, plus two analyses worth reading in full: Redis-Raft (the catalog of consensus-plumbing bugs) and Dgraph (the graph-DB cautionary tale).
The method
Jepsen is black-box and brutal: real cluster, real network, real clients.
generators → concurrent client ops (read/write/cas/txn)
→ against a REAL cluster
→ while nemesis injects: partitions, clock skew,
process kills/pauses (SIGSTOP = the GC-pause stand-in)
→ record HISTORY: [{op, start, end, result}, ...]
→ checker: is this history linearizable / serializable?
The checker is the hard part. Linearizability checking is NP-complete in general (Knossos exploded on long histories); elle is the escape.
elle’s trick
Don’t check arbitrary histories — DESIGN the workload so the serialization graph is recoverable:
- ops are list-appends:
append(k, v)with unique v, reads return the whole list - a read of
[1,3]on k tells you: 1 preceded 3 (ww), this read saw 3 (wr), and any txn appending 4 comes after (rw, inferred) - build the dependency graph from these facts; a cycle = an isolation anomaly, and the cycle TYPE names it (G0 dirty write, G1c cyclic info flow, G-single = read skew…)
The whole checker, structurally:
#![allow(unused)]
fn main() {
// a read of k = [1, 3] by txn T makes dependency edges OBSERVABLE:
fn check(history: &History) -> Result<(), Cycle> {
let mut g = Graph::new();
for read in history.reads() {
for w in read.list.windows(2) {
g.add(writer(w[0]), writer(w[1]), Ww); // list order = write order
}
if let Some(&last) = read.list.last() {
g.add(writer(last), read.txn, Wr); // T saw last's write
}
// and T -> writer(v) for any v appended after: an rw anti-dep
}
g.find_cycle() // a cycle = an anomaly; its edge types NAME it
}
}
Polynomial time, and the counterexample is human-readable (“this txn read state that implies it ran both before and after that one”). Question: why do unique values + list semantics make wr/ww edges directly observable where plain registers hide them?
The redis-raft analysis (2020)
Read for the catalog of consensus-integration bugs — none were in the Raft paper’s math, ALL were in the plumbing:
- acked writes lost on failover (stale-leader window)
- reads served by deposed leaders (no ReadIndex — topic 15 §4!)
- log divergence after membership changes
- the infamous “Raft on top of a system with its own replication” impedance
Question: for each finding, which of our topic-15 raft.rs tests (or which MISSING test) covers it?
The Dgraph analysis is the graph-DB cautionary tale: per-key Raft groups + cross-group txns = lost writes and read skew — a preview of topic 29’s distributed-transaction problems.
Jepsen vs DST (the comparison that matters for M16)
| Jepsen | DST (turso/FDB) | |
|---|---|---|
| SUT | unmodified binary | instrumented / DI’d |
| faults | real (iptables, SIGSTOP) | simulated |
| reproducibility | statistical, flaky | perfect (seed) |
| finds | integration + env bugs | logic bugs, deep interleavings |
| checker | elle (history-based) | model/invariant (state-based) |
They’re complements: DST explores deeper, Jepsen believes nothing you told it.
Questions for notes.md
- Why does Jepsen use SIGSTOP/SIGCONT instead of kill -9 for one nemesis class — which production failure does a pause model that a crash doesn’t (fencing! DDIA ch. 8)?
- elle needs append+read-full-list ops. What can it NOT check about a system that only exposes get/set registers?
- An elle cycle of pure rw edges (write skew) — which isolation level permits it and which forbids it? (Topic 8 refresher.)
- Redis-raft served stale reads from deposed leaders. Write the ReadIndex fix in one sentence and its cost per read.
- For M15+M16: sketch a mini-elle for our sim: unique-value appends via propose(), reads of committed(), cycle check over the history. What does the deterministic sim make TRIVIAL that real Jepsen fights (total real-time order is known!)?
References
Papers
- Kingsbury & Alvaro — “Elle: Inferring Isolation Anomalies from Experimental Observations” (VLDB 2020, arXiv:2003.10554)
- Jepsen analyses (jepsen.io/analyses) — read TWO: “Redis-Raft 1b3fbf6” (2020) and a graph one, “Dgraph 1.0.2” (2018)
Code
- elle — the checker itself; the README’s anomaly taxonomy is the fastest G0/G1/G2 refresher
PQS & TLP: solving the test-oracle problem twice
Random query generation was stuck for decades on one question: you can generate a million queries, but who knows the right answers? Manuel Rigger and Zhendong Su answered it twice in one year — PQS by verifying a single pre-chosen row, TLP by making the DBMS check itself. Read PQS first; TLP is partly a response to PQS’s costs. Pair with reading-sqlancer.md — the code makes the papers concrete.
PQS (OSDI ’20)
The problem statement is the keeper: random query generation was stuck on the test-oracle problem — you can generate a million queries, but who knows the right answers? Prior art (RAGS) compared multiple DBMSs against each other — but dialects diverge, and shared bugs hide.
PQS’s move: don’t verify the whole result set. Verify ONE row you chose in advance:
pick pivot row r
synthesize predicate p with eval(p, r) = TRUE ← the hard part
if r ∉ result(SELECT ... WHERE p) → bug
§ on rectified queries is the algorithmic core: generate a random
expression tree, evaluate it bottom-up on r’s concrete values under
the DBMS’s semantics (dialect-specific NULL rules, casts, collation
— all of it), then rectify: TRUE → keep, FALSE → wrap NOT, NULL →
wrap IS NULL. Question: why does rectification make EVERY randomly
generated expression usable rather than discarding the ~2/3 that
aren’t TRUE?
#![allow(unused)]
fn main() {
// rectify: ANY random predicate becomes TRUE-on-the-pivot
fn rectify(p: Expr, pivot: &Row) -> Expr {
match eval3(&p, pivot) { // eval under the DBMS's OWN dialect rules
True => p,
False => not(p),
Null => is_null(p), // SQL's third value gets its own wrapper
}
}
// then: pivot ∉ result(SELECT * FROM t WHERE rectify(p, pivot)) → BUG
}
Results to internalize: ~100 bugs across SQLite/MySQL/Postgres in ~4 months, most in SQLite — which then fixed its test suite. Note what PQS canNOT see: a bug that returns the pivot row plus GARBAGE rows passes (containment, not equality).
TLP (OOPSLA ’20)
PQS’s costs: an evaluator per dialect (weeks of work each) and single-row blindness. TLP removes both with self-consistency:
Q ≡ Q' where TRUE
partition by any predicate p:
result(Q) = result(Q_p) ⊎ result(Q_NOT_p) ⊎ result(Q_p_IS_NULL)
The ternary part is the SQL-specific insight: two-valued partitioning (p / NOT p) is WRONG in SQL — NULL rows vanish from both branches, and real optimizer bugs live exactly in that gap (NULL-blind predicate pushdown, our tlp.rs stub’s injected bug).
The paper generalizes beyond WHERE: aggregate TLP (MAX over partitions = MAX of partition MAXes; AVG needs SUM/COUNT recombination), DISTINCT, GROUP BY. Each needs a recombination operator ⊎ appropriate to the clause. Question: why is AVG the canonical example of a non-decomposable aggregate, and what does that echo from topic 11’s partial aggregation?
The meta-lesson (both papers)
A metamorphic oracle trades completeness for portability: PQS knows ground truth for one row of one query; TLP knows only that three queries must reconcile. Both beat differential testing because they need ONE system — no second implementation to disagree with. This is the design space our M16 Cypher oracles live in.
Questions for notes.md
- PQS §evaluation: why must the pivot evaluator implement the DBMS’s dialect semantics (MySQL 0/1 booleans, SQLite type affinity) rather than the SQL standard’s?
- Containment-not-equality: construct a bug PQS provably misses and TLP provably catches, and vice versa.
- TLP with p =
col = col— why is this predicate USELESS for partitioning, and what does that say about predicate generation? - Both papers fuzz SCHEMAS and DATA too (random tables, indexes, collations). Why do index-present vs index-absent runs of the same query make NoREC/TLP sharper?
- For M16: pick the first three TLP recombinations to implement for Cypher (WHERE / count(*) / collect?) and write the ⊎ for each.
References
Papers
- Rigger & Su — “Testing Database Engines via Pivoted Query Synthesis” (OSDI 2020, arXiv:2001.04174) — the rectified-queries section is the algorithmic core
- Rigger & Su — “Finding Bugs in Database Systems via Query Partitioning” (OOPSLA 2020) — Ternary Logic Partitioning; read after PQS
Code
- sqlancer — both papers as running code; walked in reading-sqlancer.md
SQLancer: 450+ bugs from three tiny oracles
SQLancer turned the PQS/TLP papers into running code and found 450+
bugs in SQLite/MySQL/Postgres/DuckDB/CockroachDB — and each oracle’s
core check is a handful of lines. This chapter walks the oracle base
classes (src/sqlancer/common/oracle/), not the per-DBMS adapters;
the comparative table at the end is what you carry into M16’s Cypher
oracles.
Anchor map
| anchor | what it is |
|---|---|
| PivotedQuerySynthesisBase.java:14 | the PQS skeleton |
| PivotedQuerySynthesisBase.java:30 | pivotRow — the chosen row |
| PivotedQuerySynthesisBase.java:37-51 | check(): rectified query → containment query → “pivot missing” = bug |
| TLPWhereOracle.java:76-92 | check(): original result vs 3-way partition union |
| NoRECOracle.java (reproducer) | optimizedQuery != unoptimizedQuery → bug |
| TernaryLogicPartitioningOracleBase.java | generates p / NOT p / p IS NULL |
1. PQS — PivotedQuerySynthesisBase.check() (:37)
1. pick pivotRow from an existing table (random row)
2. getRectifiedQuery(): synthesize WHERE that is TRUE on pivotRow
— generate a random expression, EVALUATE it yourself on the
pivot; if it's FALSE wrap NOT, if NULL wrap IS NULL (rectify)
3. getContainmentCheckQuery(): wrap the DB's own result to ask
"is pivotRow in there?"
4. containsRows == false → reportMissingPivotRow → BUG
The price: step 2 requires SQLancer to implement its OWN expression evaluator per DBMS dialect (constant folding over one concrete row). That’s why PQS finds evaluation bugs — it re-implements evaluation, and disagreement is a bug in one of the two. Question: whose bug? How does SQLancer triage false positives where its OWN evaluator is wrong?
2. TLP — TLPWhereOracle.check() (:76)
Q: SELECT * FROM t [JOIN ...]
Q_p: ... WHERE p
Q_notp: ... WHERE NOT p
Q_null: ... WHERE p IS NULL
assert multiset(Q) == Q_p ⊎ Q_notp ⊎ Q_null
No evaluator needed — the DB is checked against ITSELF. The 3-way split exists because SQL is three-valued: WHERE keeps only TRUE rows, so FALSE and NULL rows must land in the other partitions.
#![allow(unused)]
fn main() {
// TLP: no ground truth needed — the DB is its own oracle
fn tlp_check(db: &Db, q: &Query, p: &Pred) -> Result<(), Bug> {
let whole = db.run(q); // SELECT * FROM t …
let mut parts = db.run(&q.filter(p)); // WHERE p
parts.extend(db.run(&q.filter(¬(p)))); // WHERE NOT p
parts.extend(db.run(&q.filter(&is_null(p)))); // WHERE p IS NULL ← 3-valued!
if multiset(&whole) != multiset(&parts) {
return Err(Bug::PartitionMismatch); // optimizer changed RESULTS
}
Ok(())
}
}
Extensions in the codebase: TLP for aggregates (SUM over partitions
must sum), DISTINCT, GROUP BY. Question: why does TLP need the
partitioning predicate p to be deterministic and side-effect free —
what breaks with random() > 0.5?
3. NoREC — the reproducer lambda
optimized: SELECT COUNT(*) FROM t WHERE p (planner ON)
unoptimized: SELECT SUM(CASE WHEN p THEN 1 ELSE 0) (scan + eval)
Forcing the predicate into the SELECT list defeats index use and pushdown — same semantics, no optimizer. A count mismatch means the optimizer changed RESULTS, not just speed. Question: which of our topic 10 rewrite rules would NoREC exercise, and which are invisible to it (ordering? LIMIT?)?
4. The comparative table
| oracle | needs own evaluator | finds | blind to |
|---|---|---|---|
| PQS | YES (per dialect) | expression eval bugs | bugs off the pivot row |
| TLP | no | optimizer logic bugs | bugs symmetric across partitions |
| NoREC | no | pushdown/index bugs | anything both paths share |
They compose: run all three on the same generated schema/data
(CompositeTestOracle.java).
Questions for notes.md
- PQS checks ONE row per query. Why is that enough in expectation (think: bugs are input-conditioned, generation is cheap)?
- Rectification: predicate evaluates NULL on the pivot. Show why
WHERE ploses the row butWHERE p IS NULLkeeps it. - Write the TLP identity for
COUNT(*)and forMAX(c)— which aggregate makes the partition check subtle, and why? - turso’s
SelectSelectOptimizer/WhereTrueFalseNullproperties (reading-turso-simulator.md) — map each to PQS/TLP/NoREC. - Cypher TLP for M16: partition
MATCH (a)-[e]->(b) WHERE p— what plays the role of NULL in a graph pattern (missing property!), and what’s the union assertion?
References
Papers
- Rigger & Su — the PQS (OSDI 2020) and TLP (OOPSLA 2020) papers behind these classes — see reading-pqs-tlp-papers.md
Code
- sqlancer —
src/sqlancer/common/oracle/— read the base classes (PivotedQuerySynthesisBase,TLPWhereOracle,TernaryLogicPartitioningOracleBase,NoRECOracle), not the per-DBMS adapters
turso’s simulator: every failure is a u64 seed
The most readable production DST codebase in Rust: seeded clock,
fault-injecting IO, metamorphic properties, and a shrinker, all in
one testing/simulator/ tree. Read it as the reference
implementation for our dst.rs stub and for M16 — every piece here
has a miniature counterpart in the experiments.
Layout
testing/simulator/
main.rs entry: seed → config → plan → execute → check
runner/
clock.rs SimulatorClock — time is an RNG stream
io.rs SimulatorIO — fault injection switchboard
file.rs SimulatorFile — per-op faults + seeded latency
execution.rs drive the plan, catch assertion failures
doublecheck.rs run the same plan twice, diff outputs
bugbase.rs known-bug corpus (regression seeds)
generation/ plan/property/query generators
model/ the in-memory oracle + interaction model
shrink/ plan minimization
Anchor map
| anchor | what it is |
|---|---|
| runner/clock.rs:8-13 | SimulatorClock { curr_time, rng: ChaCha8Rng, min_tick, max_tick } |
| runner/clock.rs:25-34 | now() ADVANCES time by a seeded random tick — time is data |
| runner/io.rs:14 | fault: Cell<bool> — the injection master switch |
| runner/io.rs:64-77 | inject_fault / inject_fault_selective (per-file stem!) |
| runner/io.rs:135-138 | per-op fault counters: pread/pwrite/sync faults |
| runner/file.rs:40 | latency_probability — seeded IO delay |
| runner/file.rs:100-110 | generate_latency_duration — random_bool from the file’s rng |
| runner/file.rs:149-233 | every op (read/write/sync) can be delayed into a DelayedIo queue |
| generation/property.rs:270 | FsyncNoWait / FaultyQuery — fault-flavored properties |
| generation/property.rs:276-282 | the metamorphic set: SelectSelectOptimizer, WhereTrueFalseNull, UnionAllPreservesCardinality, ReadYourUpdatesBack |
| fuzz/fuzz_targets/expression.rs:299 | fuzz_target!(|expr: Expr|) — STRUCTURED fuzzing via arbitrary |
1. Time is an RNG stream (clock.rs:25)
Every now() call advances the clock by random_range(min_tick.. max_tick) — no wall clock anywhere.
#![allow(unused)]
fn main() {
// time is data: every now() consumes seeded randomness and ADVANCES
struct SimClock {
curr: Duration,
rng: ChaCha8Rng, // portable, versioned — never the default RNG
min_tick: Duration,
max_tick: Duration,
}
impl SimClock {
fn now(&mut self) -> Instant {
self.curr += self.rng.random_range(self.min_tick..self.max_tick);
Instant::from(self.curr) // monotone progress: timeout loops terminate
}
}
}
Question: why must now()
ADVANCE time rather than return a fixed value? (What loops forever
if time never moves? Think timeout code.)
2. Fault injection lives in the FILE (file.rs)
Not “kill the process” — per-operation faults: a pwrite can fail, a
sync can fail, any op can be delayed and reordered via the
DelayedIo queue. This is the fault model our sim_fs.rs copies
(buffered-until-sync + tear-on-crash). Question: which topic 5
crash-matrix cell does each of {pwrite fault, sync fault, delayed
write + crash} correspond to?
3. Properties = metamorphic oracles (generation/property.rs)
SelectSelectOptimizer is TLP-shaped: two spellings of the same
query must agree. ReadYourUpdatesBack is a session guarantee
(DDIA ch. 5 — same anomaly, single node). DoubleCreateFailure
pins error-path behavior. Note the generation trick: unrelated
random queries are interleaved WITHOUT breaking property invariants
— coverage and oracles coexist.
4. Doublecheck (runner/doublecheck.rs)
Run the identical plan twice; outputs must match byte-for-byte. This is the cheapest oracle of all: it needs NO model — it only needs determinism. Question: what class of bug does doublecheck catch that the model oracle misses? (Hint: iteration order, uninitialized memory, hidden wall-clock reads.)
5. The bug base (runner/bugbase.rs)
Found bugs persist as seeds — the regression suite is a list of u64s. Compare: our topic 15 sim tests hardcode seeds 42/7/11/13.
Questions for notes.md
- ChaCha8 everywhere, not the default RNG — why does DST need a portable, versioned RNG? What breaks on rand upgrades?
inject_fault_selectivetargets file stems (WAL vs db file) — which bug class needs faults on ONE file only?- Where does turso’s simulator sit vs Antithesis (whole-VM determinism)? What can each test that the other can’t?
- The shrink/ module: why is shrinking HARDER for stateful op sequences than for pure inputs (proptest’s integrated shrinking vs delta debugging)?
- For M16: which three properties from generation/property.rs port directly to Cypher? Sketch the graph equivalents.
References
Code
- turso —
testing/simulator/(clock/io/file fault injection, interaction plans, properties, doublecheck, shrink) plusfuzz/fuzz_targets/expression.rsfor structured fuzzing viaarbitrary— clone it; the anchor map above is your reading order
Z3 & Cosette: testing every input at once
Everything else in this topic samples the input space; SMT quantifies over it — “does there EXIST a row where these two plans disagree?” UNSAT means the rewrite is proven for all databases. This chapter reads Z3 the way PLAN.md says to: as a masterclass high-performance search engine over LOGIC whose architecture rhymes with a query engine, then applies it Cosette-style to verify our topic-10 rewrite rules.
SMT in one box
SAT solver: boolean skeleton (CDCL: decide → propagate →
conflict → learn clause → backjump)
+
theory solvers: linear arithmetic, bitvectors, arrays,
uninterpreted functions, strings...
=
SMT: SAT proposes boolean assignments; theories veto with
conflict explanations ("x<3 ∧ x>5 is impossible") that
become learned clauses
The DB analogy: CDCL = adaptive execution with feedback; learned clauses = materialized negative results; theory propagation = predicate pushdown into specialized engines.
Codebase anchors
| anchor | what it is |
|---|---|
| src/solver/solver.h:58 | class solver — check_sat over assertions |
| src/smt/smt_context.h:89 | smt::context — the CDCL(T) core loop |
| src/tactic/tactic.h:34 | class tactic — composable transformers |
| src/tactic/portfolio/default_tactic.cpp | the default strategy: probe → dispatch by logic |
| src/tactic/portfolio/smt_strategic_solver.cpp | tactic → solver bridge |
| src/ast/ | hash-consed terms (one node per distinct expr — topic 2’s interning) |
| src/smt/mam.cpp | matching abstract machine for quantifier triggers — a compiled pattern matcher (topic 19 vibes) |
Tactics ARE query plans for proofs: (then simplify solve-eqs bit-blast sat) is a pipeline of rewrites ending in an executor,
chosen by a probe (cardinality estimation!). default_tactic.cpp
dispatches on the detected logic the way a planner dispatches on
statistics.
Cosette: proving SQL rewrites
Cosette answers “are Q1 and Q2 equivalent for ALL databases?” — it compiles SQL to K-relations (rows with multiplicities, so bag semantics work), then splits: easy fragments → SMT for counterexamples, hard equivalences → Coq proof search over HoTT encodings. Our use is the SMT half:
symbolic row: (a: Int, b: Int, a_null: Bool, b_null: Bool)
P1 = compile(plan1's filter chain) — a formula
P2 = compile(plan2's filter chain)
ask Z3: ∃ row. P1(row) ≠ P2(row)
UNSAT → rewrite proven for all rows
SAT → the model IS the counterexample row
Three-valued logic is the trap AND the point: encode each nullable column as (value, is_null) and define AND/OR/NOT/comparison per SQL Kleene semantics — most real optimizer bugs (TLP’s bread and butter) are exactly NULL-semantics violations, and Z3 finds them as SAT models in milliseconds.
#![allow(unused)]
fn main() {
// verify a rewrite for ALL rows by asking for ONE disagreeing row
let a = Int::fresh("a"); let a_null = Bool::fresh("a_null");
let b = Int::fresh("b"); let b_null = Bool::fresh("b_null");
let row = Row { a, a_null, b, b_null };
let p1 = compile(plan_before, &row); // Kleene 3-valued AND/OR/NOT/cmp
let p2 = compile(plan_after, &row);
match solver.check(p1.keeps_row().xor(p2.keeps_row())) {
Unsat => Proven, // no row distinguishes the plans
Sat(m) => Counterexample(m), // the model IS the failing row
}
}
Questions for notes.md
- TACAS ’08: what does Z3 do with quantifiers (E-matching + triggers via mam.cpp), and why do DB rewrite proofs mostly avoid needing them (finite row schemas → quantifier-free)?
- Hash-consing in src/ast: same trick as our string interning (topic 2) and Arrow dictionary encoding — what operation becomes O(1) pointer compare?
- Encode
WHERE NOT (a = b)vsWHERE a <> bover nullable a, b in Kleene logic — equivalent or not? (Do it on paper, then check what Z3 says in the z3 rewrite exercise.) - Why does Cosette need K-relations (bags) rather than sets — which standard rewrite is set-valid but bag-INVALID? (DISTINCT pushdown…)
- For M16: our two topic-10 rules to verify — filter reordering (commute σ_p σ_q) and filter-past-projection. Write the symbolic encoding for each; which needs the (value, is_null) pair and which doesn’t?
References
Papers
- de Moura & Bjørner — “Z3: An Efficient SMT Solver” (TACAS 2008) — 4 pages, read whole
- Chu, Wang, Weitz, Cheung, Suciu — “Cosette: An Automated Prover for SQL” (CIDR 2017) — read for the K-relations encoding and the SMT/Coq split; our use is the SMT half
Code
- z3 —
src/— start fromsrc/solver/solver.handsrc/smt/smt_context.h, then the tactic machinery insrc/tactic/(tactics ARE query plans for proofs)
Topic 16 notes — testing & correctness engineering
Meta-surprise (recorded before you even start)
The provided crash_matrix caught a REAL bug in this crate’s own “correct” KV during development: recovery didn’t truncate the WAL tail, so torn leftover records silently joined the NEXT commit’s batch (Bug::None showed 72.7% divergence). Tail repair fixed it to 0.0%. The tooling paid for itself before the exercises began — that’s the whole thesis of the topic.
Baseline (provided, measured 2026-07-10)
crash_matrix: 5000 seeds × 40 ops (50/20/20/10 put/del/commit/crash), inline oracle harness:
| bug | caught | rate | first seed |
|---|---|---|---|
| None | 0 | 0.0% | — |
| LostDelete | 3738 | 74.8% | 0 |
| NoSyncOnCommit | 4980 | 99.6% | 0 |
| TornWriteAccepted | 2442 | 48.8% | 3 |
| StaleRead | 4706 | 94.1% | 0 |
Each sweep ≈ 0.02 s for 5000 seeds — 200K simulated crash-recoveries per second. This is why DST beats kill -9 loops (topic 5’s harness took seconds per crash).
Predictions (fill BEFORE implementing dst.rs / shrink.rs / tlp.rs)
| question | prediction | actual |
|---|---|---|
| will your dst.rs rates match the table above? (same weights, same seeds — should be exact) | ||
| shrink: 40-op LostDelete failure → how many ops after ddmin? (theoretical min = 5) | ||
| ddmin replay calls to shrink one case | ||
| TLP: % of 100 random preds (depth 3, 25% NULLs) that expose the null-blind engine | ||
| which bug needs the MOST seeds to catch with only 5% crash weight? |
Implementation log
- dst.rs: gen_ops + run_case + lockstep post-crash check — all 6 tests green (incl. determinism replay)
- shrink.rs: ddmin — 1-minimal repro ≤ 10 ops
- tlp.rs: Kleene eval3 + partition check — correct engine passes 100 preds, null-blind engine caught
- dst_run output (shrunk repros per bug) recorded here:
- optional: cargo-fuzz a real target (needs nightly)
Surprises / dead ends:
Questions from the reading guides
turso simulator (reading-turso-simulator.md)
- Why now() must ADVANCE time:
- Per-file-stem fault targeting — which bug class:
- turso sim vs Antithesis — what each can’t test:
- Why stateful shrinking is harder than pure-input shrinking:
- Three properties to port to Cypher:
FDB / Antithesis (reading-fdb-simulation.md)
- Disk-lies vs Raft’s assumptions (+ VSR/TigerBeetle answer):
- Why BUGGIFY branches don’t invalidate the test:
- The four escapes (compiler/kernel/sim-bug/wall-clock) — who catches:
- Why simulation outruns real time:
- Our engine’s remaining nondeterminism sources for M16:
SQLancer code (reading-sqlancer.md)
- PQS false-positive triage (whose evaluator is wrong):
- Why TLP’s p must be deterministic:
- NoREC-visible vs -invisible topic 10 rules:
- turso properties → PQS/TLP/NoREC mapping:
- Cypher TLP: what plays NULL in a graph pattern:
PQS + TLP papers (reading-pqs-tlp-papers.md)
- Why rectification wastes no generated expressions:
- A bug PQS misses but TLP catches (and vice versa):
- Why AVG doesn’t decompose (↔ topic 11 partial aggregation):
- Why index-present/absent runs sharpen the oracles:
- First three Cypher TLP recombinations + their ⊎:
Jepsen / elle (reading-jepsen.md)
- What SIGSTOP models that kill -9 doesn’t:
- What elle can’t check over plain registers:
- Pure-rw cycle = write skew — permitted/forbidden where:
- ReadIndex fix in one sentence + cost:
- Mini-elle over topic 15’s sim — what determinism trivializes:
Z3 / Cosette (reading-z3.md)
- Why DB rewrite proofs stay quantifier-free:
- Hash-consing → what becomes pointer compare:
- NOT (a = b) vs a <> b under NULLs — Z3’s verdict:
- The set-valid bag-invalid rewrite:
- Encodings for filter-commute and filter-past-projection:
Cross-topic threads
- sim_fs’s buffered/synced/torn model = topic 5’s crash matrix made exhaustive; the WAL-tail-truncation bug it caught is topic 5’s “recovery must repair the tail” lesson, relearned the hard way.
- The topology of every oracle here = topic 15’s sim.rs: seed → deterministic world → invariant check. DST is the single-node version of what sim.rs does for clusters.
- TLP’s 3-valued trap is topic 10/11’s expression semantics; the null-blind engine is a predicate-pushdown bug in miniature.
- Z3 tactics = query plans for proofs (probe = cardinality estimate, tactic pipeline = rewrite rules, solver = executor).
- elle’s dependency-graph cycles = topic 8’s serialization graph testing, recovered from history instead of tracked in the engine.
M16 log (correctness spine)
- TCK subset runner + tck_done.txt tracking (reference has both)
- proptest: graph ops vs BTreeMap-of-adjacency model oracle
- DST: SimClock + fault-injecting IO under M5’s WAL
- cargo-fuzz: Cypher parser, RESP framing, page/SST decoders (reference bar: fuzz_target_runtime + expressions/ + clauses/)
- Z3: two topic-10 rewrites verified; one broken on purpose → counterexample row recorded here:
Done when
- All dst/shrink/tlp tests green; dst_run prints ≤10-op repros for all four bugs; prediction table filled.
- Reading-guide questions answered; M16 fuzz-target list committed.
Topic 17 — SIMD & Hardware-Conscious Data Processing
The last 10× on a single core. Topic 11 bought vectorization at the OPERATOR level; this topic goes down to the LANES. You’re on ARM (Apple Silicon): NEON’s 128-bit vectors are the home ISA, AVX2/ AVX-512’s 256/512-bit the contrast to know.
1. The mental model
scalar: f32 + f32 → 1 result / instr
NEON: f32x4 + f32x4 → 4 results / instr (128-bit)
AVX2: f32x8 + f32x8 → 8 (256-bit)
AVX-512: f32x16 + f32x16 → 16 + per-lane MASKS
but the real currency is PORTS × LATENCY:
M-series: 4 FMA ports, ~3cy latency ⇒ need ≥12 independent
FMA chains in flight to saturate — ONE accumulator uses 1/12
of the machine. (SimSIMD's headers document exactly this.)
Data-parallel loops don’t make you fast; independent dependency chains do. SIMD width × ports × latency = the accumulator count every fast kernel hardcodes.
2. Why autovectorization fails (the four classics)
- Float reductions:
xs.iter().sum()is a serial dependency chain — reassociation changes the answer, so LLVM won’t (without-ffast-math). Fix: N explicit accumulators (polars float_sum.rs STRIPE=16 blocks + pairwise recursion above 128). - Data-dependent control flow:
if x > t { out.push(x) }— branches don’t vectorize. Fix: branchless append (out[k]=x; k += (x>t) as usize) or real compress instructions. - Gather/indirection:
vals[idx[i]]— hardware gathers exist but cost ~1 load/lane anyway (topic 13’s pointer-chasing tax). - Aliasing doubt: the optimizer can’t prove in/out don’t overlap — slices + iterators (not raw pointers) give LLVM the guarantee.
3. Branchless selection: the filter kernel
The DB kernel (SIGMOD ’15’s centerpiece). Three shapes:
branchy: if v[i] < t { out[k++] = v[i] } ← mispredicts at 50% sel
branchless: out[k] = v[i]; k += (v[i] < t) as usize ← always-store, no branch
compress: mask = v .< t
AVX-512: vpcompressd (polars filter/avx512.rs:59 — hardware)
NEON: no compress! 4-bit mask → LUT of shuffle masks →
vqtbl1q (simdjson arm64/simd.h:267-276 does exactly
this for 8-byte compaction)
Selectivity decides the winner: branchy wins at ~0%/100% (predicted perfectly), branchless/compress win in the middle. Measure it — that’s the experiments’ centerpiece curve.
4. Masks and movemask on ARM
x86 movemask (bitmask from lanes) has no NEON equivalent —
the idiom is vshrn (shift-right-narrow) folding 16 lanes into a
64-bit “4 bits per lane” mask (hashbrown group/neon.rs, memchr’s
Vector::movemask). SwissTable = SIMD probing: 16 control bytes per
group, one vceqq+narrow gives candidate slots in 2 instructions —
topic 2’s hash table, now explained at lane level.
5. The masterclass codebases (per reading guide)
- simdjson: byte classification via
vqtbl1qnibble lookups, carry-less multiplyprefix_xorfor quote parity — branch-free STATE MACHINES over 64-byte blocks. - polars-compute: the production Rust shape — scalar body +
#[cfg]AVX-512 compress + STRIPE’d sums. - hashbrown: one abstraction (
Group) with sse2/neon/generic backends — the portability pattern to copy. - SimSIMD: distance kernels with port/latency tables in the comments; multiple ISA files per kernel (haswell/skylake/neon/ sve…) dispatched at runtime.
- memchr:
Vectortrait over ISAs; the 4×-unrolled search loop. - Mojo:
SIMD[type, width]as a first-class parametric type — whatstd::simdwants to be with a compiler behind it.
6. FastLanes (bit-packing at SIMD speed)
Topic 12 decoded bit-packed values scalar. FastLanes’ trick: an
interleaved “transposed” layout so unpacking any width is the SAME
shift/mask kernel across lanes, no cross-lane shuffles — decode at
memory bandwidth. Our unpack4 stub is the baby version: 32
nibbles per 16-byte vector via shift+mask, no LUT needed.
Experiments (experiments/)
Four kernels × four rungs (scalar / autovec-friendly / portable
wide / NEON intrinsics — std::simd is nightly, wide is its
stable stand-in):
dot.rs— dot product. PROVIDED: naive (1 chain) + unrolled-8 (autovec). YOU implement:widef32x4 and NEONvfmaq_f32with 4 accumulators. Tests: equivalence within 1e-2 relative.filter.rs— count + compact under a threshold. PROVIDED: branchy + branchless scalar. YOU implement: NEON count (vcltq+vshrnpopcount) and LUT-compress compact (simdjson’s trick, f32 edition). Tests: exact match vs branchy oracle.unpack.rs— 4-bit unpack to u32. PROVIDED: scalar (topic 12’s). YOU implement: NEON shift/mask. Tests: round-trip.bin/simd_bench— PROVIDED (runs the provided rungs before panicking at stubs): GB/s per rung per kernel; the selectivity sweep (1/25/50/75/99%) for filter shapes.
Reading guides
| guide | chapter |
|---|---|
| reading-simdjson.md | simdjson: parsing without branches |
| reading-polars-compute.md | polars-compute: shipping SIMD in stable Rust |
| reading-hashbrown-simd.md | hashbrown & memchr: movemask without movemask |
| reading-simsimd.md | SimSIMD: the port/latency table is the design doc |
| reading-sigmod15-vectorization.md | SIMD for databases: two primitives, four operators |
| reading-fastlanes.md | FastLanes: bit-unpacking at memory bandwidth |
| reading-mojo-simd.md | Mojo’s SIMD[type, width]: width as a type parameter |
Capstone M17
- NEON kernels behind the M11 vectorized runtime: filter compact, hash probe, dot/l2 distances (M14’s kernels get their promised SIMD)
- scalar fallbacks kept +
is_aarch64_feature_detected!dispatch - bench: engine-level speedup per kernel (not just microbench) — record where Amdahl eats the 4×
- SIMD-ize one topic 12 decoder (bit-unpack) and re-run the compression-IS-performance table
FastLanes: bit-unpacking at memory bandwidth
Topic 12 decoded bit-packed integers one value at a time; FastLanes (Afroozeh & Boncz) redesigns the STORAGE LAYOUT so that decoding any bit width is the same straight-line SIMD kernel — no shuffles, no per-width special cases — and hits memory bandwidth on every ISA from NEON to AVX-512, including scalar code that autovectorizes. The punchline for this whole topic: layout, not intrinsics, is the win.
1. The problem with sequential bit-packing (topic 12’s layout)
3-bit values packed sequentially in a u64:
|v0 |v1 |v2 |v3 |v4 ... v20|v21⟨spans the word boundary⟩
decode v21: load TWO words, shift both, OR, mask ← branchy, serial,
and lane i+1 depends on where lane i ended ← unvectorizable
Values straddle word boundaries and each value’s position depends on all previous widths — a serial dependency chain, the enemy from README §1.
2. The fix: interleaved (transposed) layout
FastLanes packs a block of 1024 values as if the machine had 1024 bit-serial lanes (“the 1024-bit virtual ISA”):
1024 values, width w → w × 128-byte "bit-planes":
word j of plane b holds bit b of values {j, j+64, j+128, ...}
(transposed order, via the "unified 04261537" permutation)
decode = for each output vector:
acc = (plane_word >> shift) & mask ← same shift for ALL lanes
no value ever crosses a lane boundary
no cross-lane shuffle, EVER
Because every lane does the identical shift+mask at every step, the kernel is the same for NEON’s 128-bit vectors, AVX-512’s 512-bit, or a u64 scalar loop — the vector width just decides how many of the 1024 virtual lanes you process per instruction. Wider ISA = same code, fewer iterations.
#![allow(unused)]
fn main() {
// 1024 values as 16 u64 lanes advancing in LOCKSTEP — every lane runs the
// identical shift+mask, which is all autovectorization needs to see
fn unpack(planes: &[[u64; 16]], w: u32, out: &mut [[u64; 16]; 64]) {
let mask = (1u64 << w) - 1;
let (mut word, mut shift) = (0usize, 0u32);
for group in out.iter_mut() {
for lane in 0..16 { // ← the vectorized dimension
group[lane] = (planes[word][lane] >> shift) & mask;
}
shift += w;
if shift + w > 64 { word += 1; shift = 0; }
// (real FastLanes stitches the boundary bits with one extra
// OR instead of padding — still the same shift for ALL lanes)
}
}
}
3. The unified transposed order
The permutation 04261537 reorders values so that ALL of {8,16,32,64}- bit lane types see a consistent order — so you can bit-unpack u8s, then delta-decode as u16s, without re-permuting between kernels. Question: why does delta (a PREFIX dependency) need this at all? Their answer: delta is computed per-lane over the transposed order — each lane keeps its own running base, turning a serial prefix-sum into 1024/W independent short chains. Same trick as multi- accumulator dot: break the chain by restructuring the data.
4. Results worth remembering
- Decode at RAM bandwidth: unpacking is FREE relative to the memory it saves — the final word on topic 12’s “compression IS performance” table.
- Scalar Rust/C compiled with autovec reaches ~the intrinsic version, BECAUSE the layout removed everything autovec chokes on (README §2’s four failures — all four absent by construction).
- The same layout accelerates delta, RLE, dictionary, and FOR — it’s a compression layout, not a codec.
5. Our baby version: unpack.rs
The experiments’ 4-bit unpack keeps topic 12’s sequential layout (values don’t straddle bytes at w=4 — the one width where sequential is already SIMD-friendly):
16 bytes = 32 nibbles: lo = bytes & 0x0F, hi = bytes >> 4
→ interleave/widen to u32 lanes. No LUT. Two ops + widening.
Question: at which widths does the sequential layout stop being this easy (hint: w ∤ 8), and what does FastLanes’ transposition buy exactly there?
Questions for notes.md
- Block = 1024 values regardless of width. What two constraints pick 1024 (largest vector ISA lanes × smallest type, and cacheline alignment of every plane)?
- Interleaved decode touches w planes 128B apart — is that still sequential enough for the prefetcher (topic 13’s stride limits)?
- Delta-decode with per-lane bases: what’s the ratio of chain length, 1024 sequential vs transposed on 128-bit NEON (16 u64 lanes… derive it)?
- Random access to value i now needs w bit-plane reads — what did we trade away vs sequential packing, and why doesn’t an analytic scan care (topic 12’s block-granularity access)?
- For M17’s checklist item “SIMD-ize one topic 12 decoder”: ours is w=4 sequential. Predict GB/s scalar vs NEON before running simd_bench — then reconcile with FastLanes’ claim that layout, not intrinsics, is the win.
References
Papers
- Afroozeh & Boncz — “The FastLanes Compression Layout: Decoding
100 Billion Integers per Second with Scalar Code“ (VLDB 2023) — read §3-4 for the interleaved layout and the unified transposed order; the eval confirms the autovectorization claim
Code
- FastLanes — CWI’s reference implementation of the layout (optional; the paper’s kernels are self-contained)
hashbrown & memchr: movemask without movemask
Two crates, one question: how do you get an x86 movemask (one bit
per lane) on ISAs that don’t have it — and when should you not even
try? hashbrown answers by shrinking the SwissTable group to 8 bytes
so the comparison result already is the mask; memchr answers with
the vshrn nibble-mask idiom. Between them sits the portability
pattern every SIMD kernel layer copies.
Anchor map
| anchor | what it is |
|---|---|
| hashbrown group/mod.rs:8-30 | the cfg_if! backend choice + the famous “NEON wasn’t worth it” comment |
| hashbrown group/sse2.rs:20 | Group(__m128i) — 16 control bytes |
| hashbrown group/sse2.rs:73-84 | match_tag = _mm_cmpeq_epi8 + _mm_movemask_epi8 → BitMask(u16) |
| hashbrown group/neon.rs:16 | Group(uint8x8_t) — EIGHT bytes, not 16! |
| hashbrown group/neon.rs:68-75 | match_tag = vceq_u8 + reinterpret as u64 — NO movemask at all |
| hashbrown group/neon.rs:85-99 | match_empty_or_deleted via vcltz_s8 (sign bit test) |
| hashbrown group/generic.rs:41 | Group(GroupWord) — SWAR on a plain u64 |
| hashbrown group/generic.rs:105-109 | SWAR match_tag: x ^ repeat(tag), then the zero-byte trick |
| memchr vector.rs:25-64 | the Vector trait: splat/load/cmpeq/movemask over 3 ISAs |
| memchr vector.rs:322-328 | NEON movemask: vshrn_n_u16(_, 4) → u64 with 4 bits/lane |
| memchr arch/generic/memchr.rs:107 | LOOP_SIZE = 4 * V::BYTES — the 4× unroll |
| memchr arch/generic/memchr.rs:171-206 | the unrolled search loop (OR-combine 4 cmpeqs, one movemask check) |
1. Three answers to “one bit per lane”
SSE2 (16B group): vceqq → PMOVMSKB → u16, 1 bit/lane. Native. Done.
NEON, memchr style (16B): no PMOVMSKB. Idiom:
vceqq_u8 → 16 lanes of 0xFF/0x00
vshrn_n_u16(,4) → narrow each u16 pair, keeping 4 bits per byte
vget_lane_u64 → u64 where each lane owns a NIBBLE
& 0x8888... → keep 1 bit per nibble (vector.rs:322-328)
position = trailing_zeros() >> 2 ← note the /4!
NEON, hashbrown style (8B group): don't narrow at all.
vceq_u8 on uint8x8_t → 8 lanes of 0xFF/0x00 = exactly one u64
vget_lane_u64 → done. BitMask where each lane owns a BYTE.
position = trailing_zeros() >> 3
hashbrown chose to SHRINK the group to 8 so the comparison result
is already the bitmask. memchr keeps 16 lanes and pays one vshrn.
Question: why does the right choice differ? (Hint: hash probing
expects to find its match in the first group — mod.rs’s comment:
“the probability of finding a match drops off drastically after the
first few buckets” — while memchr scans megabytes and amortizes.)
2. The generic SWAR backend (generic.rs:105-109)
No SIMD at all — a u64 is an 8-lane vector if you’re careful:
#![allow(unused)]
fn main() {
let cmp = self.0 ^ repeat(tag); // matching byte → 0x00
BitMask((cmp.wrapping_sub(repeat(0x01)) & !cmp & repeat(0x80)).to_le())
}
The classic “detect zero byte” trick: subtracting 1 borrows into bit 7 only where the byte was 0. Question: this can false-positive on adjacent-byte borrows — why is that acceptable here (what does the caller do with a candidate match)? Compare neon.rs which has no false positives.
3. SwissTable probing at lane level
h1(hash) → group index h2(hash) → 7-bit tag
┌────────────────────── one group (8 or 16 control bytes)
│ 0x51 0x7f EMPTY 0x51 DEL 0x12 ...
└── match_tag(0x51) → candidates 0b...01001 → probe those slots
match_empty() → can this group absorb an insert?
match_empty_or_deleted() → insertion slot (vcltz: top bit set)
Control bytes encode EMPTY=0xFF, DELETED=0x80, FULL=0..0x7f — all
three predicates are single-instruction because the encoding puts
the discriminator in the SIGN bit (neon.rs:85,94 use vcltz/vcgez).
#![allow(unused)]
fn main() {
// the probe loop at group granularity: ~3 instructions per 8-16 slots
fn find(&self, hash: u64, key: &K) -> Option<usize> {
let (mut g, tag) = (h1(hash) & self.mask, h2(hash)); // 7-bit tag
loop {
let group = Group::load(&self.ctrl[g]);
let mut m = group.match_tag(tag); // vceq + extract → BitMask
while let Some(i) = m.next_set() { // trailing_zeros() >> 3
if self.slot(g + i).key == *key { return Some(g + i); }
} // false positive? just loop
if group.match_empty().any() { return None; } // EMPTY ends the probe
g = (g + GROUP_SIZE) & self.mask; // (triangular in real code)
}
}
}
Question: this is topic 2’s hash table — rewrite your M2 probe loop’s
per-slot compare as a per-group match_tag and count instructions
per probed slot.
4. memchr’s 4× unroll (arch/generic/memchr.rs:171-206)
The search loop loads 4 vectors, cmpeqs each, ORs the results,
and calls movemask ONCE per 64 bytes; only on a hit does it
re-movemask the individual vectors to localize. Same shape as
polars’ one-branch-per-block filter and simdjson’s 64-byte stage 1.
Question: why OR-then-locate instead of 4 movemask+test — count the
instructions on the (overwhelmingly common) miss path.
5. The portability pattern to copy
Vector trait (memchr) / Group struct-per-file (hashbrown): the
ALGORITHM is written once against splat/cmpeq/movemask; each ISA
file is ~100 lines of intrinsics implementing the interface, chosen
by cfg_if! at COMPILE time (vs polars’ runtime dispatch — binding
times again). This is the shape for M17’s kernel layer.
Questions for notes.md
- hashbrown mod.rs says a 16-byte NEON Group lost to the generic u64 SWAR. What cost model explains that (latency of narrow + extract vs the SWAR’s 4 ALU ops)?
- The vshrn nibble-mask means
trailing_zeros()>>2; hashbrown’s byte-mask means>>3. What breaks if you forget the shift? (memchr wraps it inNeonMoveMasknewtype — why?) - SWAR match_tag tolerates false positives; match_empty (bit
pattern 0b1111_1111) doesn’t need the subtract trick — why
(generic.rs:119 uses
self.0 & (self.0<<1))? - For M2’s table: your tags are full hashes. What do you lose by truncating to 7 bits + sign-bit encoding, and what do you gain per probe?
- Compile-time cfg (here) vs runtime detect (polars) vs init-time fn pointers (SimSIMD): which fits a Cypher engine that ships one binary to unknown ARM servers?
References
Code
- hashbrown —
src/control/group/— one file per backend (sse2/neon/generic); thecfg_if!block inmod.rsand its “NEON wasn’t worth it” comment are the design doc - memchr —
src/vector.rs(theVectortrait + NEON movemask idiom) andsrc/arch/generic/memchr.rs(the 4× unrolled search loop)
Mojo’s SIMD[type, width]: width as a type parameter
What does SIMD look like when the TYPE SYSTEM, not a library, owns
it? In Mojo, scalars are literally width-1 vectors, and vector width
is a compile-time parameter you abstract over — the ergonomic ceiling
that std::simd and wide approximate from below. Read this chapter
for the language-design angle; it’s the contrast that explains why
our Rust experiments hand-write what Mojo’s vectorize generates.
1. The ladder of SIMD ergonomics
raw intrinsics vfmaq_f32(acc, a, b) per-ISA names, unsafe,
(core::arch) exact instruction control
│
portable library acc = a.mul_add(b, acc) one vocabulary, library
(std::simd, wide) Simd<f32, 4> / f32x4 picks instructions; width
│ is a const generic bolted on
│
language type SIMD[DType.float32, 4] scalars ARE SIMD[T,1];
(Mojo) fn foo[w: Int](x: SIMD[T,w]) width is a first-class
compile-time parameter
Mojo’s move: Float32 is literally an alias for
SIMD[DType.float32, 1]. Every scalar function is already the
width-1 instance of a width-generic function — vectorizing an
algorithm means changing a parameter, not rewriting the body.
2. Why parametric width matters (the polars contrast)
polars hardcodes STRIPE=16 and writes the AVX-512 filter twice (u8 and u32) because Rust’s const generics can’t cleanly abstract “the best width for this type on this target.” In Mojo:
fn kernel[w: Int](v: SIMD[DType.float32, w]) -> SIMD[DType.float32, w]
vectorize[kernel, simdwidthof[DType.float32]()](n)
simdwidthof is a compile-time query of the TARGET (4 on NEON, 16
on AVX-512); vectorize instantiates the kernel at that width plus
a scalar remainder at width 1 — the same body, monomorphized twice.
Question: what stops wide/std::simd from doing this today? (The
width is a const generic, but there’s no portable “native width”
query, and no autogenerated remainder loop — you write both by
hand in dot.rs.)
3. The matmul blog arc — our topic in miniature
The famous progression (numbers from Modular’s blog, M-series-class hardware, GFLOPS order-of-magnitude):
Python baseline ~0.002 ×1
naive Mojo (same loops) ~5 ×2000 compiled, typed
+ vectorize inner loop ~25 SIMD lanes
+ parallelize outer loop ~100 cores (topic 14, not 17)
+ tile + unroll ~200+ cache blocking (topic 13)
Note the ORDER: types/compilation first, lanes second, cores third, cache blocking last — and each step is a decorator/parameter, not a rewrite. The lesson isn’t “Mojo is fast”; it’s that the four layers (scalar semantics → lanes → threads → tiles) are SEPARABLE when width is parametric. Our experiments walk the same rungs by hand.
4. What Mojo does NOT solve
- compress/gather-shaped problems (simdjson’s LUT trick) still need per-ISA thought — a parametric width doesn’t conjure vpcompress on NEON; you still write the LUT or take the branchless store.
- ports × latency (the accumulator count) is still yours: the matmul blog manually unrolls 4 accumulator vectors, exactly like SimSIMD’s 4-state API. No compiler infers “12 chains” for you — reassociation of floats stays illegal in every language.
5. Translation table for our stack
| Mojo | stable Rust (our experiments) |
|---|---|
SIMD[DType.float32, 4] | wide::f32x4 |
simdwidthof[T]() | hardcode 4 on NEON (128-bit / 32) |
vectorize[kernel, w](n) | hand-written chunks_exact(w) + remainder |
@unroll / accumulator params | 4 named accumulator variables |
| width-1 fallback | your scalar fn, kept for dispatch |
Questions for notes.md
Float32 = SIMD[f32,1]: what does making scalars width-1 vectors buy for TESTING kernels (hint: run the same body at w=1 as the oracle for w=4 — steal this for dot.rs’s tests)?vectorizegenerates the remainder loop; where in our filter.rs is the equivalent, and why is the remainder the classic source of SIMD bugs (simdjson pads its input to 64 instead — compare)?- Rust’s std::simd
Simd<f32, N>has the parametric type but sits on nightly for years. What’s actually hard: the type, the portable ops (compress!), or stabilizing the ISA mapping? - The matmul arc gains more from tiling than from SIMD. Reconcile with this topic’s “last 10× on a single core” framing — when is topic 13 the bigger lever than topic 17?
- For M17: our engine will hardcode NEON width 4. Write the one sentence justifying that (deployment target) and the one-line escape hatch if SVE servers arrive.
References
Papers & docs
- Modular — Mojo stdlib docs for
SIMD(docs.modular.com) — the parametric type itself; no clone needed - Modular — the “Matrix Multiplication in Mojo” blog arc (matmul.mojo) — the ×2000-to-tiled progression walked in §3
polars-compute: shipping SIMD in stable Rust
The production-Rust answer to “how do I ship SIMD without a nightly
compiler or per-CPU binaries”: autovec-friendly scalar bodies,
explicit std::simd where it pays, raw intrinsics only for the one
instruction Rust can’t reach (vpcompress). This chapter walks the
two kernels every engine needs — the reduction and the filter — as
polars actually ships them.
Anchor map
| anchor | what it is |
|---|---|
| float_sum.rs:13-14 | STRIPE = 16, PAIRWISE_RECURSION_LIMIT = 128 |
| float_sum.rs:44 | vector_horizontal_sum — reduce lanes at the END only |
| float_sum.rs:67-90 | SumBlock: sum 128 elems as 16-lane chunks (chunks_exact) |
| filter/scalar.rs:12 | mask-bit loop: while m > 0 + trailing_zeros (simdjson’s flatten!) |
| filter/scalar.rs:90 | 64-element blocks — process a whole mask word |
| filter/avx512.rs:7 | simd_filter! macro — the shared loop skeleton |
| filter/avx512.rs:50-60 | filter_u8_avx512vbmi2: _mm512_maskz_compress_epi8 |
| filter/avx512.rs:87-95 | u32 via _mm512_maskz_compress_epi32 (AVX-512F) |
| filter/mod.rs | dispatch: runtime feature detect → avx512 or scalar |
| min_max/ | same pattern for min/max kernels |
1. float_sum: the reduction playbook
Two problems, two fixes:
- throughput: one accumulator = one dependency chain. Fix:
sum
[T; 128]blocks asSimd<T, 16>lanes — 16 chains, reduce to scalar only at block end (vector_horizontal_sum). - accuracy: naive left-to-right float sum accumulates O(n) error. Fix: pairwise recursion above 128 elements — O(log n) error, and it’s the same tree shape the SIMD blocking already built. One design, both wins.
Question: why is the null-masked variant (_with_mask) just
select(mask, x, 0) + the same sum, and what does that say about
null handling in vectorized engines generally (topic 11’s
validity-mask philosophy)?
2. filter: three rungs on one skeleton
simd_filter! (avx512.rs:7) fixes the loop: load 64 mask bits,
loop vectors of the value type, compress-store, advance out-ptr by
popcount. The compress instruction is the ONLY per-ISA part:
u8 → vbmi2 _mm512_maskz_compress_epi8 (needs Ice Lake+)
u32 → avx512f _mm512_maskz_compress_epi32
scalar fallback → while m > 0 { tz = m.trailing_zeros(); ... }
The scalar fallback (scalar.rs:12) is itself branch-light: iterate SET BITS with trailing_zeros instead of testing every element — selectivity-adaptive for free (low selectivity = few iterations).
#![allow(unused)]
fn main() {
// one 64-element block per mask word; cost ∝ popcount, not 64
fn filter_block(vals: &[T; 64], mut m: u64, out: &mut Vec<T>) {
while m > 0 {
let i = m.trailing_zeros() as usize; // next surviving element
out.push(vals[i]);
m &= m - 1; // clear lowest set bit
}
}
// AVX-512 replaces the whole loop with one compress-store per vector:
// _mm512_maskz_compress_epi32(mask, v); out_ptr += mask.count_ones();
}
Question: at 99% selectivity, which wins — bit-iteration or
copy-everything-then-truncate? What does polars do for the
mostly-true case (look for the is_simple / all-set fast path)?
3. What NEON gets instead
No vpcompress on ARM. Options polars doesn’t need but you do (M17):
simdjson’s LUT-shuffle compress (8 lanes max per vqtbl1q), or
branchless scalar append (often wins — measure!). This is the
experiments’ filter.rs stub.
4. The dispatch pattern
Runtime is_x86_feature_detected! at the kernel boundary — one
branch per 64+ elements, not per element. Compare hashbrown
(compile-time cfg per Group backend) and SimSIMD (function-pointer
tables at init). Question: when is each of the three binding times
right (compile / init / call)?
Questions for notes.md
- STRIPE=16 for f32 = 512 bits = 4 NEON registers. Why does a WIDER stripe than the vector width still help on ARM (ports × latency)?
- Pairwise limit 128: derive the error bound difference vs left-to-right for n = 10⁸ (hint: ~ε·log₂(n/128) vs ~ε·n).
- The
simd_filter!skeleton advancesoutby popcount without zeroing skipped lanes. Why is the trailing garbage safe (who truncates)? - Filter returns (values, validity) — how does the validity BITMAP itself get filtered (bit-level compress — the harder problem)?
- For M17: polars chose NOT to use
std::simdfor filter, only intrinsics + scalar. Why does compress specifically defeat portable SIMD abstractions?
References
Code
- polars —
crates/polars-compute/src/— start withfloat_sum.rsandfilter/(scalar.rs, avx512.rs, mod.rs);min_max/repeats the same pattern if you want a second lap
SIMD for databases: two primitives, four operators
Polychroniou, Raghavan & Ross’s SIGMOD ’15 paper turned “SIMD for
databases” from folklore into a catalog. It vectorizes the FOUR
fundamental operators — selection scan, hash probe, bloom filter,
partition — and shows each is a composition of two primitives:
selective store (compress) and selective load / gather. Read
it as the spec for our experiments/filter.rs and for M17’s engine
kernels.
The two primitives
selective STORE (compress): selective LOAD (expand/gather):
lanes: a b c d e f g h memory: p q r s ...
mask: 1 0 1 1 0 0 1 0 mask: 1 0 1 1 ...
memory: a c d g ────────► lanes: p . q r ... ◄────────
(filter output, partition out) (refill lanes after some finish)
gather: lanes = mem[idx[0..W]] (hash probe, dictionary decode)
scatter: mem[idx[0..W]] = lanes (partition, hash build)
Every operator in the paper is a loop of: compute masks → compress finished lanes out → refill from input. AVX-512 has all four as instructions; NEON has none natively (hence simdjson’s LUT compress and the gather cost model below).
1. Selection scan (§4) — our filter.rs, their Figure
Three implementations, one selectivity sweep:
branchy │ ns/elem peaks at ~50% sel (mispredict wall)
branchless │ flat line — always-store, data-independent
SIMD compress │ flat, lower — W elems per compress
└──────────────── selectivity →
crossover: branchy wins BELOW ~few % and ABOVE ~95%
The paper’s addition our README didn’t have: with SIMD you compute the mask for W lanes and use it to compress-store BOTH the values and their RIDs (row ids) — the output of a filter in a real engine is positions, not just values (topic 11’s selection vectors). Question: does compressing (value,rid) pairs double the cost or can one mask drive two compresses?
2. Hash probe (§5) — vertical vectorization
The naive way vectorizes ONE probe’s steps. The paper’s way runs W INDEPENDENT probes, one per lane:
keys = selective_load(input, done_mask) ← refill finished lanes
hashes = hash(keys)
slots = gather(table, hashes)
done = (slots.key == keys) | (slots == EMPTY)
output = selective_store(matches)
bucket += 1 where !done ← collided lanes probe on
Lanes finish at different times — the done-mask + refill pattern keeps all W lanes busy despite divergent probe lengths.
#![allow(unused)]
fn main() {
// vertical probing: W INDEPENDENT probes in flight, refilled as they finish
loop {
keys = selective_load(keys, input, done); // finished lanes take new keys
let slot = gather(table, hash(keys) + bucket); // ~1 cache access PER LANE
let hit = slot.key.simd_eq(keys);
let empty = slot.simd_eq(EMPTY);
selective_store(out, hit, slot.val); // compress matched lanes out
done = hit | empty;
bucket = done.select(ZERO, bucket + 1); // collided lanes probe on
}
}
This is hashbrown’s group probing turned 90°: hashbrown = SIMD within one probe, SIGMOD15 = SIMD across probes. Question: which does M11’s hash join want, given batch sizes of 1024 and a table that misses L2?
3. The gather cost model (§3)
Measured then, still true now: a gather costs ~1 cache access PER LANE — it parallelizes the instruction stream, not the memory system. Gather wins only when the computation around it vectorizes; it never fixes topic 13’s pointer-chasing tax. Corollary the paper proves: vectorized probe ≈ scalar probe when the table exceeds cache, but is 3-6× faster in-cache. Question: FalkorDB’s adjacency lookups are gathers over CSR — in-cache or out? What does that predict for SIMD-izing traversals (M24)?
4. Partition (§6) — scatter with conflict detection
Radix partition scatters each lane to out[hist[digit(k)]++]. Two
lanes with the SAME digit collide on the histogram slot. The paper
detects conflicts (AVX-512 vpconflictd; emulated before that) and
serializes only colliding lanes. Question: NEON has no conflict
detect either — sketch the scalar-fallback-inside-vector-loop shape,
and note where topic 13’s software write-combining buffers make the
scatter moot.
5. What to steal for the experiments
- The selectivity sweep axes (their Fig: cycles/tuple vs sel%) =
simd_bench’s filter output. Plot branchy/branchless/compress. - Rigged input trick: they control selectivity EXACTLY by construction — our bench does the same (threshold = quantile).
- Report cycles/tuple, not GB/s, for the probe kernel (memory-bound kernels hide instruction wins behind bandwidth).
Questions for notes.md
- Why does branchless lose to branchy at 99% selectivity in their data (hint: store traffic — branchless writes EVERY element)?
- Vertical probing needs W independent probes in flight. What does that do to the ORDER of join output, and which downstream operators care (topic 11’s sort-sensitivity)?
- Their bloom-filter kernel is probe-minus-refill — why do bloom lookups vectorize even better than hash probes (fixed iteration count)?
- The paper predates AVX-512 on servers; they emulate compress via permutation LUTs — exactly simdjson’s arm64 trick. Compare table sizes: 8-lane f32 LUT vs simdjson’s 8-byte LUT.
- For M17: rank the four operators by expected engine-level win in our Cypher pipeline (filter, probe, partition, bloom) given M11’s profile — where does Amdahl bite first?
References
Papers
- Polychroniou, Raghavan, Ross — “Rethinking SIMD Vectorization for In-Memory Databases” (SIGMOD 2015) — §3 the gather cost model, §4 selection, §5 probe, §6 partition; skim the AVX-512 forecast knowing it came true
simdjson: parsing without branches
Parsing — the most branchy code imaginable — rebuilt as branch-free
bitmask algebra over 64-byte blocks, at gigabytes per second. Read
the paper alongside include/simdjson/arm64/ (you’re on ARM — the
NEON implementation is the one your machine runs). Every trick here
transfers to a DB engine: RESP framing, CSV ingest, LIKE prefilters.
Two-stage architecture
stage 1: structural indexing (SIMD, branch-free)
64 input bytes → classify → bitmasks (one bit per byte):
quotes, backslashes, whitespace, operators {}[]:,
→ resolve strings (quote parity) → structural positions
→ flatten bit positions into an index array
stage 2: tape building (branchy, but only touches ~1/8 of bytes)
walk the structural indexes, parse numbers/strings, emit tape
Stage 1 never branches on DATA — the only branches are the loop. All data-dependence becomes bit arithmetic.
Anchor map (arm64)
| anchor | what it is |
|---|---|
| arm64/simd.h:179 | repeat_16 — build 16-byte LUTs |
| arm64/simd.h:226-229 | lookup_16 = vqtbl1q_u8 — the classification workhorse |
| arm64/simd.h:267-276 | compress via pruned vqtbl1q + LUT — NEON’s missing vpcompress, emulated |
| arm64/bitmask.h:15-22 | prefix_xor — carry-less multiply (PMULL) turns quote bits into in-string regions |
| src/generic/stage1/json_string_scanner.h:16-30 | the string-state block: escaped/quote/in_string masks |
| src/generic/stage1/json_structural_indexer.h:24-28 | bit_indexer — flatten mask bits to positions |
| src/generic/stage1/json_structural_indexer.h:194 | the stage-1 driver loop |
| src/generic/stage1/utf8_lookup4_algorithm.h | UTF-8 validation as 3 table lookups |
1. Classification by nibble LUT (lookup_16)
To classify 16 bytes at once: split each byte into hi/lo nibbles,
look each up in a 16-entry table (vqtbl1q_u8), AND the results.
Any predicate expressible as (hi-nibble class) ∧ (lo-nibble class)
costs 2 shuffles + 1 AND for 16 bytes. Question: build the two
tables that classify { } [ ] : , — why do hi and lo tables
disagree on false positives, and why does ANDing fix it?
2. Quote parity by carry-less multiply (prefix_xor)
In-string = bytes after an odd number of quotes. prefix_xor(m)
computes for each bit position the XOR of all lower bits — exactly
“odd quote count so far” — in ONE PMULL instruction (multiply by
all-ones in GF(2)). Question: why is escaped-quote handling
(backslash runs) done BEFORE this, and why does odd/even backslash
parity need its own trick (the odd_sequence_starts dance)?
3. The escaped-backslash problem
\\\" vs \\\\" — whether a quote is real depends on the PARITY of
the preceding backslash run. The scanner solves it with add-carry
propagation on masks: adding backslash_starts to the run mask
carries out at run ends where the run length is odd. Branch-free.
This is the paper’s cleverest three lines — work the example in
§3.1.1 by hand.
4. flatten_bits (bit_indexer)
Turning a 64-bit mask into an array of positions: cnt = popcnt,
then repeatedly trailing_zeros + clear lowest bit, unrolled by
8 with the count written UNCONDITIONALLY (write 8, advance by
popcount — over-write, under-advance). Same shape as our
branchless filter append.
#![allow(unused)]
fn main() {
// bit_indexer: mask → positions. Over-write, under-advance.
fn flatten(out: &mut [u32], n: usize, start: u32, mut m: u64) -> usize {
let cnt = m.count_ones() as usize;
let mut k = 0;
while k < cnt { // ceil(cnt/8) iterations, branch-free body
for j in 0..8 { // write 8 UNCONDITIONALLY —
out[n + k + j] = start + m.trailing_zeros(); // garbage lanes are fine
m &= m.wrapping_sub(1); // clear lowest set bit
}
k += 8;
}
n + cnt // advance by the REAL count only
}
}
Question: why is writing 8 always faster than writing exactly cnt?
5. What transfers to a DB engine
- RESP protocol framing (M7) = structural indexing over
\r\n$*:+- - CSV/JSON bulk ingest = the whole pipeline
- string-escape scanning = LIKE/regex prefilters
- the meta-lesson: turn per-byte branches into per-block masks, THEN branch once per block (topic 11’s vectorization, byte edition)
Questions for notes.md
- Why 64-byte blocks (one u64 mask = 64 lanes) rather than the 16-byte NEON width?
- The compress LUT at simd.h:267: how many entries, indexed by what, and why does the same trick cap at 8 lanes per shuffle?
- Stage 2 is still branchy. Why does Amdahl not kill the speedup (what fraction of bytes reach stage 2)?
- UTF-8 validation in 3 lookups: what property of UTF-8 error patterns makes nibble tables sufficient?
- For M7: sketch stage-1 masks for RESP (
*3\r\n$3\r\nSET...) — which characters are “structural”?
References
Papers
- Langdale & Lemire — “Parsing Gigabytes of JSON per Second” (VLDB Journal 2019, arXiv:1902.08318) — §3 is stage 1; work the escaped-backslash example in §3.1.1 by hand
Code
- simdjson —
include/simdjson/arm64/(simd.h, bitmask.h) plussrc/generic/stage1/— read the NEON files, they’re what your machine runs
SimSIMD: the port/latency table is the design doc
This is M14’s vector-distance layer done by someone who read the CPU
manuals: every NEON file opens with a per-instruction port/latency
table, and every kernel’s accumulator count follows from it. The
chapter’s through-line — ports × latency decides everything, and
fancy instructions lose to plain FMAs that spread across ports.
(Note: the headers live under include/numkong/, the project’s
internal rename.)
Anchor map
| anchor | what it is |
|---|---|
| numkong/spatial/neon.h:10-20 | THE table: per-instruction latency/ports on A76 vs Apple M-series |
| numkong/spatial/neon.h:123-140 | nk_sqeuclidean_f32_neon — f64 accumulation, 2 f32/iter |
| numkong/dot/neon.h:126-146 | nk_dot_f32_neon — FCVTL upcast, TWO independent FMA chains |
| numkong/dot/neon.h:37-45 | stateful streaming API: FOUR nk_dot_f32x2_state_neon_ts |
| numkong/dot/neon.h:~150 | the FCMLA comment: measured 39.7 vs 17.1 GiB/s — why they said no |
| numkong/spatial/neon.h:~100 | rsqrt by vrsqrteq + 3 Newton-Raphson rounds (no FSQRT) |
| include/numkong/*/ | one file per ISA per kernel family: neon, sve, haswell, skylake… |
1. The table is the design doc (spatial/neon.h:10-20)
Intrinsic Instruction A76 Apple M5
vfmaq_f32 FMLA 4cy @ 2p 3cy @ 4p
vaddq_f32 FADD 2cy @ 2p 2cy @ 4p
vsqrtq_f32 FSQRT 12cy @ 1p 9cy @ 1p
vrsqrteq_f32 FRSQRTE 2cy @ 2p 3cy @ 1p
Read it as: on M-series, FMA needs latency(3) × ports(4) = 12
in-flight independent FMAs to saturate; on A76 only 8. And FSQRT is
a 1-port 9-cycle disaster — hence vrsqrteq + Newton-Raphson
(3 rounds ≈ f64 precision) instead of vsqrtq. Question: the README
said “≥12 chains” but nk_dot_f32_neon uses only 2 — where do the
other 10 come from in practice (see §3)?
2. Precision is why the kernels look “slow” (dot/neon.h:126)
float64x2_t sum_low = vdupq_n_f64(0); // chain 1
float64x2_t sum_high = vdupq_n_f64(0); // chain 2
for (; i + 4 <= n; i += 4) {
a_f32x4 = vld1q_f32(a+i); b_f32x4 = vld1q_f32(b+i);
// FCVTL / FCVTL2: upcast each half to f64x2
sum_low = vfmaq_f64(sum_low, a_low_f64, b_low_f64);
sum_high = vfmaq_f64(sum_high, a_high_f64, b_high_f64);
}
f32 inputs, f64 accumulators — half the lane width, deliberately. Contrast polars: pairwise recursion (restructure the ADDITION ORDER) vs SimSIMD: wider accumulator type (restructure the PRECISION). Question: for M14’s l2 distance over 1536-dim embeddings, which error-control strategy is cheaper on M-series, and does recall@10 even care?
3. The 4-state streaming API (dot/neon.h:37-45)
The header’s doc-comment shows the intended use: ONE query against
FOUR targets, four nk_dot_f32x2_state_neon_ts updated per
iteration:
for idx: chains in flight:
q = load(query+idx) state1 += q·t1 ┐
t1..t4 = load(4 targets) state2 += q·t2 │ 4 FMA chains,
state3 += q·t3 │ shared q load
state4 += q·t4 ┘
finalize(4 states) → one f32x4 of results
The ILP comes from BATCHING CANDIDATES, not unrolling one pair — the query load is amortized 4×, and 4 states × dual-issue ≈ the 12 chains the machine wants. This is exactly M14’s HNSW inner loop shape (score one query against a neighbor list). Question: why is this better than 4 accumulators over a single pair for the short-vector case (n=128 dims: how many iterations does each scheme get to overlap)?
4. The FCMLA lesson (dot/neon.h ~:150)
ARMv8.3 has a complex-multiply instruction (FCMLA). They benchmarked
it: 17.1 GiB/s vs 39.7 GiB/s for plain deinterleave (vld2) + 4
independent FMAs on M4. The fancy instruction LOST 2.3× because it
serializes work that 4 plain FMAs spread over 4 ports. The meta-
lesson for M17: newer/specialized instruction ≠ faster; ports ×
latency decides. Question: what’s the NEON analogue in our filter
kernel (is vqtbl1q compress always better than branchless stores)?
5. Dispatch: one file per ISA, chosen at init
Directory layout is the dispatch table: dot/neon.h, dot/sve.h,
dot/haswell.h, dot/skylake.h… At init, capability detection
fills function pointers once; call sites pay an indirect call, not
a feature test. Middle binding time between hashbrown (compile) and
polars (per-call detect). Question: an indirect call can’t inline —
when does THAT cost exceed the runtime-check cost it saves (think
n=8 dims vs n=4096)?
Questions for notes.md
- From the table: peak f32 FMA throughput on M-series = 4 ports × 4 lanes × 2 flops = 32 flops/cy. What fraction does nk_dot_f32_neon reach, given f64 accumulation halves lanes?
- sqeuclidean_f32 uses ONE f64x2 chain (spatial/neon.h:123) — sloppy, or is L2-distance latency-bound elsewhere? Predict, then check with your dot.rs bench.
- Newton-Raphson: why 3 rounds for f64 (~48 bits) — how many bits does each round double from FRSQRTE’s ~8-bit estimate?
- The stateful API returns
float32x4_tof 4 results — how does this shape M14’s candidate-scoring loop signature? - For M17 dispatch: sketch the fn-pointer table for
{dot, l2sq, filter} × {neon, scalar} and where
is_aarch64_feature_detected!runs exactly once.
References
Code
- SimSIMD —
include/numkong/— one file per ISA per kernel family (dot/neon.h,spatial/neon.h, sve/haswell/skylake siblings); the port/latency tables at the top of each NEON header are the real reading assignment
Topic 17 notes — SIMD & hardware-conscious processing
Baseline (provided rungs, release, Apple Silicon, measured 2026-07-10)
N = 4M f32 (16 MB per input — out of L2, into memory), 20 reps.
dot product
| rung | GB/s | ms | vs naive |
|---|---|---|---|
| naive (1 chain) | 10.89 | 3.081 | 1.0× |
| unrolled-8 (autovec) | 42.12 | 0.797 | 3.9× |
| wide f32x4 ×4 acc | stub | ||
| neon vfmaq ×4 acc | stub |
3.9× from ZERO intrinsics — just writing 8 accumulators so LLVM may reassociate. The serial FMA chain (3cy latency) was the bottleneck, exactly as the ports×latency model predicts.
filter compact (GB/s of input)
| sel% | branchy | branchless | neon-compress |
|---|---|---|---|
| 1 | 10.95 | 12.70 | stub |
| 25 | 2.13 | 13.32 | stub |
| 50 | 1.19 | 12.73 | stub |
| 75 | 2.11 | 12.38 | stub |
| 99 | 6.65 | 11.98 | stub |
The SIGMOD ’15 curve, live: branchy collapses 9× at 50% selectivity (mispredict wall — ~symmetric around 50%, recovering toward the ends); branchless is FLAT within ±5% across the whole sweep because its control flow is data-independent. Note branchy never actually wins here even at 1%/99% — the paper’s crossover needs even more extreme selectivities (<1%) on this core.
4-bit unpack
| rung | GB/s (output) |
|---|---|
| scalar | 10.20 |
| neon shift/mask | stub |
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| dot_wide (4 × f32x4 = 16 partials) vs unrolled-8 — faster, same, slower? | ||
dot_neon vs dot_wide — does hand-written vfmaq beat wide’s codegen? | ||
| is dot at N=4M memory-bound? (32 MB / 3.081 ms ≈ 10 GB/s naive; what’s the memory ceiling — rerun at N=64K in-cache to see compute limit) | ||
| neon-compress at 50% sel vs branchless 12.73 GB/s | ||
| neon-compress at 99% — does always-store-16B beat branchless? | ||
unpack4_neon vs scalar 10.20 GB/s (is the scalar already autovectorized? check cargo asm or the 10 GB/s hint) | ||
| max f32 dot error vs naive at N=4M, 4-acc f32 (SimSIMD upcasts to f64 — do we need to?) |
Implementation log
- dot.rs: dot_wide + dot_neon — 4 tests green
- filter.rs: count_neon + compact_neon (LUT built, all 16 masks pass) — 3 tests green
- unpack.rs: unpack4_neon — 2 tests green
- full simd_bench table recorded above (replace “stub”)
- rerun dot at N=64K (in-cache) — record compute-bound ceiling
Surprises / dead ends:
Questions from the reading guides
simdjson (reading-simdjson.md)
- Why 64-byte blocks over 16-byte NEON width:
- Compress LUT size/indexing, why 8 lanes max per shuffle:
- Amdahl on branchy stage 2 (fraction of bytes reaching it):
- Why nibble tables suffice for UTF-8 validation:
- RESP stage-1 mask sketch for M7:
polars-compute (reading-polars-compute.md)
- Why STRIPE=16 (wider than one NEON vector) helps (ports×latency):
- Pairwise error bound vs left-to-right at n=10⁸:
- Why compress trailing garbage is safe (who truncates):
- Bit-level validity-bitmap filtering:
- Why compress defeats portable SIMD abstractions:
hashbrown + memchr (reading-hashbrown-simd.md)
- Why 16B NEON Group lost to u64 SWAR in hashbrown:
- vshrn nibble-mask >>2 vs byte-mask >>3 — the newtype guard:
- Why SWAR false positives are acceptable in match_tag:
- 7-bit tags + sign-bit encoding — cost/benefit for M2:
- Compile vs init vs call-time dispatch for our engine:
SimSIMD (reading-simsimd.md)
- Fraction of 32 flops/cy peak that f64-accumulating dot reaches:
- Is single-chain sqeuclidean_f32 actually a bottleneck:
- Newton-Raphson rounds → bits (8 → 16 → 32 → 48):
- 4-target stateful API → M14 scoring loop signature:
- M17 fn-pointer dispatch table sketch:
SIGMOD ’15 (reading-sigmod15-vectorization.md)
- Why branchless loses at 99% in their data (store traffic):
- Vertical probing vs join output order:
- Why bloom filters vectorize better than probes:
- Their permutation-LUT compress vs simdjson’s:
- Rank filter/probe/partition/bloom by engine-level win (Amdahl):
FastLanes (reading-fastlanes.md)
- Why 1024-value blocks:
- Interleaved planes vs prefetcher:
- Chain-length ratio for transposed delta on NEON:
- Random access cost we traded away:
- Predicted vs measured unpack4 GB/s reconciliation:
Mojo (reading-mojo-simd.md)
- w=1-as-oracle testing trick (adopt in dot.rs? already done — scalar IS the oracle):
- Remainder-loop bugs vs simdjson’s padding:
- What’s actually blocking std::simd stabilization:
- When topic 13 (tiling) out-levers topic 17 (lanes):
- One-sentence NEON-width-4 justification for M17:
Cross-topic threads
- The filter selectivity curve is topic 11’s selection-vector decision at lane level; branchless = the vectorized engine’s default for exactly this flatness.
- ports × latency ⇒ accumulator count is topic 13’s MLP argument (memory-level parallelism) transposed to FLOPs.
- unpack4 is topic 12’s decoder; FastLanes says the LAYOUT, not the intrinsics, decides whether decode rides at RAM bandwidth.
- hashbrown group matching is topic 2’s probe loop; SwissTable = SIMD as a hash-table DESIGN constraint, not an optimization.
- SimSIMD’s 4-target streaming states are M14’s candidate scoring loop, pre-shaped.
M17 log (capstone)
- NEON kernels behind M11 vectorized runtime: filter compact, hash probe, dot/l2 (M14)
- scalar fallbacks +
is_aarch64_feature_detected!dispatch (once, at init — fn-pointer table) - engine-level speedup per kernel; record where Amdahl eats the 4×
- SIMD-ize topic 12 bit-unpack; re-run compression-IS-performance
Done when
- All stub tests green; simd_bench tables above fully populated; prediction table reconciled; reading-guide questions answered.
Topic 18 — GPU Acceleration for Databases
When is the transfer tax worth paying? Topic 17 bought lanes; a GPU buys thousands of them — behind a bus. On this machine (Apple Silicon) the “bus” is unified memory, which changes the answer in ways the experiments measure directly.
1. GPU architecture for DB people
CPU core: wide OoO, ~5 GHz, caches hide latency PER THREAD
GPU SM: 32-lane warps (SIMT), latency hidden by OVERSUBSCRIPTION
— thousands of resident threads; when a warp stalls on
memory, another issues. Occupancy = how many can be
resident (limited by registers + shared memory per SM).
branch divergence: both sides of an if execute, lanes masked
→ SIMT is topic 17's predication done by hardware, per warp
memory coalescing: a warp's 32 loads become ONE transaction iff
adjacent lanes touch adjacent addresses
→ the GPU word for topic 12's columnar layout argument
shared memory: ~100 KB/SM software-managed scratchpad
→ the GPU word for topic 13's cache blocking
Every GPU-DB trick is one of: coalesce (layout), stay resident (occupancy), amortize atomics (reduce first), or avoid the bus.
2. The bus decides the architecture
discrete GPU: device HBM 400-3000 GB/s ← kernels feast
PCIe 4/5 32-64 GB/s ← queries starve
NVLink ~900 GB/s ← the expensive fix
Apple Silicon: unified LPDDR, one pool, ~150-400 GB/s shared
no copy needed IN PRINCIPLE — but wgpu still
stages through private buffers (our measured
"upload" cost is real)
Crystal (SIGMOD ’20)’s rule of thumb: a discrete GPU beats the CPU on a scan-heavy query only if the working set LIVES on the device (the “data resident” assumption); shipping data per query loses to the CPU at PCIe speeds. Corollary the paper works out: GPU as accelerator-of-operators fails; GPU as primary-store-with-CPU-fallback works. Our gpu_bench reproduces the miniature version.
3. Measured on this machine (Apple M3 Pro, wgpu/Metal)
sum of n f32 — CPU 8-acc autovec vs GPU workgroup reduction:
n=16K CPU 2 µs GPU 1619 µs ← ~1.5 ms FIXED dispatch cost
n=4M CPU 589 µs GPU 4555 µs
n=16M CPU 2258 µs GPU 14333 µs ← no crossover, ever
A memory-bound reduction never wins on the GPU here: both processors see the SAME memory, so the GPU’s only edge is FLOPs it doesn’t need, and it pays ~1.5 ms of encode/submit/poll overhead per dispatch. The lesson is NOT “GPU slow” — it’s that arithmetic intensity (FLOPs/byte) decides: sum is 0.25 FLOP/byte; l2_batch at dim=128 is ~64 FLOP/byte-of-query. The stubs exist to find where the flip happens.
4. GPU joins & aggregation (libcudf)
- Hash join: build with cuco (RAPIDS’ cuckoo/open-addressing GPU
hash tables), probe with COOPERATIVE GROUPS — a warp probes
together (cudf join/hash_join/, distinct_hash_join.cu) — then a
two-phase size/retrieve pattern (inner_join_size.cu before
inner_join_retrieve.cu): count matches first, allocate exactly,
fill second. GPU code can’t
Vec::push— every output needs its size known or an atomic cursor. - Group-by: shared-memory aggregation per block when cardinality fits (groupby/hash/compute_shared_memory_aggs.cu), spilling to global-memory atomics when it doesn’t — topic 11’s two-phase partial aggregation, forced by the memory hierarchy.
5. GPU graph processing (Gunrock) & ANN (CAGRA)
- Gunrock: BFS = frontier ADVANCE (expand neighbors) + FILTER (dedupe/validate) operators; the whole research area is load balancing ragged adjacency lists across warps (thread_mapped / block_mapped / merge_path in operators/advance/).
- CAGRA (cuVS): HNSW rebuilt for SIMT — a flat degree-regular graph (no levels), searched by MANY parallel greedy walks with a shared visited hashmap in shared memory; one CTA per query (search_single_cta_kernel.cuh). Graph traversal made regular enough for warps.
6. Programming models
| model | reach | why it matters here |
|---|---|---|
| CUDA | NVIDIA only | where all the DB literature lives |
| Metal | Apple only | what actually runs on this Mac |
| wgpu/WebGPU | everywhere | our experiments: WGSL → Metal/Vulkan/DX12 |
| Mojo/MLIR | CPU SIMD + GPU | topic 17’s parametric width, extended to the device axis |
Experiments (experiments/)
wgpu compute on Metal. PROVIDED: GpuCtx + working sum-reduction
kernel (shaders/sum.wgsl — coalesced strided loads, shared-memory
tree reduction) with per-phase timings, gpu_bench crossover sweep
(runs now, prints the table in §3). YOURS:
filter_count— WGSL skeleton provided: fold per-thread, reduce per-workgroup, ONEatomicAddper group. Test: exact vs CPU.l2_batch— one invocation per target; row-major first, then transpose and measure the coalescing gap. Test: 1e-3 relative.- Fill the crossover table in notes.md — l2_batch at dim 128 × 100K targets is where the GPU should finally win. Verify.
- Stretch (M18/M20 bridge): BFS as SpMV over a bitmap frontier in WGSL vs SuiteSparse CPU.
Reading guides
| guide | chapter |
|---|---|
| reading-crystal-sigmod20.md | GPU vs CPU for analytics: two regimes, two verdicts |
| reading-wgpu-compute.md | wgpu compute: the 1.5 ms tax before your first FLOP |
| reading-libcudf.md | libcudf: GPU kernels can’t push |
| reading-gunrock.md | Gunrock: advance, filter, and the ragged-frontier problem |
| reading-cagra.md | CAGRA: HNSW rebuilt for warps |
| reading-faiss-gpu.md | Faiss GPU: k-select that never leaves registers |
Capstone M18
- experimental GPU backend for ONE hot path (vector distance scoring is the honest candidate — M14’s rescore loop) behind a feature flag
- CPU-vs-GPU crossover bench INCLUDING transfer, per batch size — the go/no-go artifact
- document the verdict: on unified memory, which engine ops clear the arithmetic-intensity bar? (expect: almost none — record WHY, that’s the deliverable)
CAGRA: HNSW rebuilt for warps
Topic 14’s HNSW rebuilt from GPU-first principles: what does a graph-traversal index look like when the executor is 32-wide warps instead of one pointer-chasing core? The answer — flatten the levels, fix the degree, move the visited set into shared memory — is a case study in making an irregular algorithm regular enough for SIMT. The cuVS implementation is the code half of this chapter.
Anchor map
| anchor | what it is |
|---|---|
| cpp/src/neighbors/detail/cagra/cagra_build.cuh | build: NN-descent → rank-based pruning |
| detail/cagra/graph_core.cuh | graph optimization (detour counting, reverse edges) |
| detail/cagra/search_single_cta_kernel.cuh:30-34 | the search kernel params: itopk, hashmap ptr |
| detail/cagra/search_single_cta.cuh:127-143 | shared-memory budget assembly (dataset ws + topk scratch) |
| detail/cagra/hashmap.hpp | the visited set: open-addressing table IN SHARED MEMORY |
| detail/cagra/topk_by_radix.cuh + bitonic.hpp | k-select without sorting everything |
| detail/cagra/search_multi_cta.cuh | many CTAs per query for large k / low QPS |
| detail/cagra/compute_distance_vpq-impl.cuh | PQ-compressed distance (topic 14’s ADC on device) |
1. The index: HNSW minus everything SIMT hates
HNSW (topic 14) CAGRA
multi-level skip list SINGLE flat level
variable degree ≤ M FIXED degree (e.g. 32) — no ragged
adjacency, no load balancing needed
(Gunrock's whole problem, deleted
by construction)
greedy walk, 1 candidate parallel walk, itopk candidate list,
beam ef search_width parents expanded/iter
visited: hash set on heap visited: hashmap in SHARED MEMORY
Fixed degree means one warp loads a neighbor list in exactly one coalesced pass, and thread i always has lane-i work. Question: what does fixed degree cost in graph quality, and how does build compensate (rank-based pruning + detour counting in graph_core.cuh — keeping the edges that SHORTCUT most 2-hop paths)?
2. Build: NN-descent instead of insert-one-at-a-time
HNSW builds incrementally — inherently serial (topic 14’s build took minutes). CAGRA builds the whole graph at once: NN-descent (everyone’s neighbors’ neighbors are candidate neighbors — a fixpoint of local refinement, embarrassingly parallel), then prune to fixed degree. Paper’s headline: build is ~10× faster than HNSW at equal recall. Question: NN-descent is itself a graph algorithm with ragged intermediate state — how does the paper make ITS memory usage bounded (fixed-size candidate lists again)?
3. Search: one CTA per query
search_single_cta_kernel.cuh — a whole thread block cooperates on ONE query:
shared memory holds: itopk candidate list + visited hashmap
+ distance scratch (:127-143 budgets this)
loop until itopk stable:
pick search_width best unvisited parents (bitonic/radix topk)
ALL threads: load their fixed-degree neighbors, compute
distances in parallel (one lane ≈ one neighbor)
dedupe via shared hashmap, merge into itopk
The greedy walk is still SEQUENTIAL across iterations — parallelism is WITHIN each step (32-64 distance computations at once) plus ACROSS queries (one CTA each, thousands resident).
#![allow(unused)]
fn main() {
// one CTA per query; the walk is sequential, each STEP is parallel
while !itopk.stable() {
let parents = itopk.best_unvisited(SEARCH_WIDTH); // bitonic/radix topk
par_for lane in 0..(SEARCH_WIDTH * DEGREE) { // one lane ≈ one neighbor
let v = graph[parents[lane / DEGREE]][lane % DEGREE];
// FIXED degree ⇒ this load is one coalesced pass, no load balancing
if visited.insert(v) { // shared-memory hashmap
dist[lane] = l2(query, data[v]);
}
}
itopk.merge(dist); // shared-memory topk
}
}
Question: batch size 1 uses a fraction of the device; batch 10K saturates it — how does that reshape M14’s “QPS at recall” curve axes (GPU ANN is a THROUGHPUT device: latency per query barely improves, queries per second explode)?
4. The visited hashmap (hashmap.hpp)
Open addressing in shared memory, sized by hashmap_min_bitlen / max_fill_rate params (search_single_cta.cuh:57-59). Collisions → false “already visited” is acceptable (skip a node, lose a bit of recall) but the reverse isn’t tracked… check: is it lossy or exact? Question: compare topic 14’s visited-set choices (bitmap vs hash set per query) — why does shared-memory capacity (~100 KB) force the hash here, and what happens to recall when the table saturates on a long search?
5. What transfers to M18
Vector distance scoring is our engine’s most GPU-shaped op (dense, regular, high arithmetic intensity — the l2_batch stub is its kernel). CAGRA says: if you also want the TRAVERSAL on device, you must first make the graph regular. FalkorDB’s CSR adjacency is not — which is why M18’s flag gates distance scoring, not traversal.
Questions for notes.md
- Fixed degree 32 vs HNSW’s M=16-64 with levels: derive expected hops for 1M vectors (paper reports ~same recall at similar memory — where did the levels’ log-factor go?).
- itopk lives in shared memory and is maintained by bitonic/radix-topk — why is a HEAP (topic 14’s CPU choice) wrong on a warp?
- search_multi_cta splits one query across CTAs — when (large k, small batch)? What synchronizes the partial itopks (global memory + separate merge kernel — the no-device-barrier tax again)?
- compute_distance_vpq: PQ codes unpacked per lane — topic 14’s ADC table lives where (shared memory — budget collision with the hashmap: find who wins)?
- For M14+M18: our rescore pipeline is exact-f32 over PQ candidates. Which half goes to GPU first, and what’s the batch size per the crossover table you’ll measure with l2_batch?
References
Papers
- Ootomo, Naruse, Nolet, Wang, Feher, Wang — “CAGRA: Highly Parallel Graph Construction and Approximate Nearest Neighbor Search for GPUs” (ICDE 2024, arXiv:2308.15136) — §III for build (NN-descent + pruning), §IV for the single-CTA search
Code
- cuvs —
cpp/src/neighbors/detail/cagra/— the anchor map above is the reading order; start fromsearch_single_cta_kernel.cuh
GPU vs CPU for analytics: two regimes, two verdicts
Shanbhag, Madden & Yu’s Crystal paper settled a decade of “GPU databases: hype?” papers by building the fairest possible comparison: a tile-based GPU query library vs a state-of-the-art CPU baseline, on Star Schema Benchmark, with the transfer question made explicit. Its two-regime framing is the go/no-go lens for every operator M18 considers offloading.
1. The framing: two regimes, two verdicts
regime A: data ships over PCIe per query (coprocessor model)
GPU time ≈ transfer time; PCIe ~16 GB/s vs CPU membw ~100 GB/s
→ CPU WINS almost always. Full stop.
regime B: working set resident in GPU HBM (primary-store model)
HBM ~880 GB/s vs CPU ~100 GB/s
→ GPU wins by ~ the bandwidth ratio (they measure ~16× on SSB)
Everything else in the literature is confusion between A and B. Our gpu_bench’s no-crossover table is regime A in miniature — except on unified memory the “transfer” is a staging copy + ~1.5 ms dispatch overhead, and the bandwidth RATIO is ~1, so even regime B wouldn’t save a memory-bound scan on this Mac. Question: what DOES unified memory save, and which operator class exploits it (arithmetic intensity — the l2_batch stub)?
2. The tile-based execution model
Crystal’s core idea: process a query as a sequence of BLOCK-WIDE functions over tiles (a tile = items per thread × threads per block), staged through shared memory:
load tile → coalesced, all threads
BlockPred: each thread evaluates predicate on its items → flags
BlockScan: prefix-sum flags → output offsets (compaction!)
BlockShuffle / BlockAggregate / BlockProbe ...
write tile → coalesced
It’s topic 11’s vectorized execution with tiles for batches and shared memory for the L1-resident chunk — and the compaction step is topic 17’s compress, built from scan instead of vpcompress.
#![allow(unused)]
fn main() {
// tile-based filter: 100K threads share no cursor — the SCAN makes the order
par_for tile in input.tiles(ITEMS_PER_THREAD * THREADS_PER_BLOCK) {
let items = block_load(tile); // coalesced
let flags = items.map(|x| pred(x) as u32); // BlockPred
let (offsets, total) = block_exclusive_scan(flags); // BlockScan
let base = atomic_add(&global_cursor, total); // once per BLOCK
for i in 0..ITEMS_PER_THREAD {
if flags[i] == 1 { out[base + offsets[i]] = items[i]; }
}
}
}
Question: why does GPU filter output need a prefix-scan where the
CPU used a cursor k += mask? (No total order across 100K threads
— the scan MAKES one.)
3. What they measure that people forget
- Fused vs staged operators: materializing intermediates to HBM between operators wastes the bandwidth win; Crystal fuses the whole SSB query into one kernel (topic 11’s operator fusion, mandatory now).
- Selection via scan+compact beats branch-per-thread at mid selectivities — the topic 17 selectivity curve, GPU edition.
- CPU baseline honesty: their CPU code is AVX-vectorized and multi-threaded; most prior “100× GPU speedups” compared against scalar single-thread CPU code (topic 0’s fair-benchmarking paper, case study #1).
4. The cost model worth memorizing
For a scan-shaped operator: time = max(bytes/membw, flops/peak).
GPU wins iff data is resident AND the op is bandwidth-bound (ratio
~9×) or compute-bound with high intensity (ratio can be ~50×).
Neither holds for ship-per-query. Question: place these on the
roofline: sum (0.25 FLOP/byte), filter (0.25), hash probe (~1 +
random access), l2 dim=128 (~32), CAGRA search (~high + irregular).
Which two belong on a GPU at all?
Questions for notes.md
- SSB is denormalized-star scans. Which topic 22 benchmark shape would flip the verdict back to CPU even in regime B (hint: point lookups, topic 3)?
- Crystal predates Apple unified memory. Rewrite their regime table for M-series: what replaces PCIe, what replaces HBM, and why does the GPU still lose our sum bench?
- Their group-by uses atomics into a hash table when groups are few. At what group cardinality does that collapse, and what’s the fallback (cudf’s shared-mem vs global split)?
- Fusing the whole query into one kernel kills operator-at-a-time profiling. What replaces topic 0’s flamegraph on GPU (NSight / Metal capture — occupancy + achieved bandwidth per kernel)?
- For M18: our engine’s hot paths are graph expand (random), filter (streaming), distance scoring (dense). Apply §4’s roofline to each and write the one-line go/no-go.
References
Papers
- Shanbhag, Madden, Yu — “A Study of the Fundamental Performance Characteristics of GPUs and CPUs for Database Analytics” (SIGMOD 2020) — §2-3 for the tile model, §5-6 for the two-regime measurements; the CPU-baseline-honesty discussion is worth reading even if you never touch a GPU
Faiss GPU: k-select that never leaves registers
Johnson, Douze & Jégou’s 2017 paper made GPU ANN real: IVF-PQ (topic 14’s quantization ladder) at billion scale, built around one algorithmic contribution — k-selection that never leaves registers — and one systems discipline: keep the index resident, stream only queries. It’s Crystal’s regime B practiced before Crystal named it.
1. The memory-tier layout (the whole system in one table)
what where why
PQ codes (1B×8B) GPU HBM (8-32 GB) scanned every query — needs bandwidth
coarse centroids HBM tiny
original vectors CPU RAM / disk only for optional rescore
queries PCIe per batch small — the ONLY per-query transfer
Crystal’s regime B by design: the billion-scale index lives on device; a query batch ships kilobytes, not gigabytes. Question: our gpu_bench shipped the DATA per call and lost everywhere — restate Faiss’s layout rule as a rule about which side of the bus each data-lifetime class belongs on.
2. The k-select problem (their §4, the real contribution)
IVF scan produces millions of distances per query; you need the top-k WITHOUT sorting (sort is O(n log n) of HBM traffic). CPU used a heap — serial, branchy, SIMT-hostile. Faiss: WarpSelect —
each lane keeps a tiny sorted queue IN REGISTERS
insert: compare-exchange against lane's queue (predicated, no branch)
when any lane's queue overflows → odd-even merge network across
the warp (warp shuffles, no shared memory), rebuild thresholds
end: merge 32 lane-queues once → warp's top-k
One pass over the distances, k-select at register speed.
#![allow(unused)]
fn main() {
// WarpSelect, one lane's view: a tiny sorted queue in REGISTERS
let mut queue = [f32::INFINITY; Q]; // lane-local register array
let mut threshold = f32::INFINITY; // the warp's current kth-best
for d in my_stripe_of_distances {
if d < threshold { // overwhelmingly false → no work
queue.insert_sorted(d); // predicated compare-exchange
}
if ballot_any_lane_full() { // warp vote, no shared memory
odd_even_merge_across_warp(); // fixed schedule = zero divergence
threshold = kth_best(); // queues drain, threshold tightens
}
}
// end: merge the 32 lane-queues once → the warp's top-k
}
This is topic 17’s “sorting networks beat comparison sorts at small fixed n” scaled to warps — and CAGRA’s bitonic itopk is its descendant. Question: why do sorting NETWORKS (fixed compare-exchange schedule) fit SIMT while heaps don’t (data-independent schedule = no divergence — the same reason branchless filter won at 50% selectivity)?
3. IVF-PQ on device (topic 14 vocabulary check)
- coarse quantizer: query → nprobe nearest inverted lists (a small brute-force matmul — cuBLAS)
- ADC lookup tables: per query × subquantizer, built in shared memory (256 entries × m subquantizers)
- scan: each thread streams PQ codes, 8 table lookups per 8-byte code, feeds WarpSelect
- batch everything: queries × lists tiled to saturate SMs
Question: the ADC tables are per-QUERY — at what batch size does shared memory run out, and what’s the fallback (smaller tiles, or float16 tables)? Compare CAGRA’s shared-memory budget fight.
4. Numbers that set expectations (2017 hardware, still directive)
- brute-force k-NN on 1M×128d: ~20× over CPU (dense matmul — the best case; this is our l2_batch stub’s ceiling shape)
- billion-scale IVF-PQ: ~8.5× over prior GPU art; k-select was the bottleneck they removed
- multi-GPU: shard lists (data parallel) or replicate (query parallel) — topic 15’s scaling menu, verbatim
5. What transfers to M14/M18
Our M14 pipeline (PQ scan → rescore) maps 1:1: PQ scan is the GPU-shaped half (regular, bandwidth-bound, k-select), rescore is gather-heavy (CPU keeps it unless candidates batch well). M18’s distance-scoring flag should implement the brute-force tile first — it’s §4’s 20× case and needs no index redesign.
Questions for notes.md
- WarpSelect keeps k ≤ ~1024 in registers per warp. What breaks
at larger k, and what did they use before overflow (thread-queue
- warp-queue two-level — find the threshold t)?
- Faiss streams distances INTO k-select fused (no materialized distance array). Crystal made the same fusion argument — what’s the HBM traffic ratio, fused vs staged, for 1M distances/query?
- The coarse quantizer is a matmul (batch queries × centroids) — why does THIS piece hit near-peak FLOPs while the PQ scan is bandwidth-bound (arithmetic intensity of each)?
- Their multi-GPU sharding sends every query to every shard; replication doesn’t. Map to topic 15’s read-scaling vs partitioning — which does recall@k prefer (shard = exact merge, replica = independent)?
- For M18: l2_batch(1 query × 100K targets, dim 128) ≈ their brute-force case at batch 1. Predict from the roofline whether Metal wins BEFORE running your implementation — then check.
References
Papers
- Johnson, Douze, Jégou — “Billion-scale similarity search with GPUs” (arXiv:1702.08734, IEEE Trans. on Big Data 2019) — §4 (k-selection) is the real contribution; §5’s layout table is the systems lesson
Gunrock: advance, filter, and the ragged-frontier problem
The GPU graph framework that reduced every graph algorithm to two
data-parallel operators over frontiers — and then spent its research
budget on the problem hiding inside: adjacency lists are RAGGED, and
warps hate ragged. Read the modern “Essentials” codebase alongside
the paper; the load-balancing menu in operators/advance/ is the
chapter’s core.
Anchor map
| anchor | what it is |
|---|---|
| include/gunrock/algorithms/bfs.hxx:95-149 | the whole BFS loop: advance + optional filter |
| include/gunrock/framework/operators/advance/advance.hxx:94-123 | load-balance dispatch: thread/block/merge_path |
| operators/advance/thread_mapped.hxx | 1 thread : 1 vertex — dies on power laws |
| operators/advance/block_mapped.hxx | 1 block : 1 vertex’s edges — dies on leaves |
| operators/advance/merge_path.hxx | binary-search work split — even by EDGE count |
| framework/frontier/vector_frontier.hxx | sparse frontier (vertex list) |
| framework/frontier/experimental/boolmap_frontier.hxx | dense frontier (bitmap) |
| include/gunrock/framework/operators/filter/ | dedupe/compact the output frontier |
1. The programming model: two operators
while frontier not empty:
ADVANCE: frontier → all neighbors, apply user lambda
(BFS lambda: CAS parent; return "keep?" per edge)
FILTER: drop invalids/duplicates → next frontier
BFS, SSSP, PageRank, connected components = different lambdas,
SAME two operators. GraphBLAS says the same thing with matrices:
advance = SpMV/SpMSpV over the frontier vector, filter = the mask
(topic 20's push/pull duality, imperative edition).
bfs.hxx:139-145 is the whole loop: advance::execute_runtime then
optionally filter::execute_runtime to remove invalids.
#![allow(unused)]
fn main() {
// every graph algorithm = the same two operators + a different lambda
while !frontier.is_empty() {
let next = advance(csr, &frontier, |src, dst| {
// BFS lambda: a LOST race is benign — any parent is a valid tree
parent[dst].compare_exchange(INVALID, src).is_ok()
});
frontier = filter(next, |v| is_valid(v)); // dedupe/compact
}
// SSSP, PageRank, CC: same loop, different lambda + frontier policy
}
Question: BFS works WITHOUT the filter (bfs.hxx:114’s comment) — what grows unbounded if you skip it, and why is that sometimes still faster (redundant work vs a full extra pass — the “idempotent BFS” trick)?
2. Load balancing: the actual hard problem
A frontier’s vertices have degrees from 1 to 10⁷ (topic 13’s power laws). Assign work naively and one warp does a hub while thousands idle:
thread_mapped: thread i ← vertex i good: uniform degree
dies: one hub = one thread
block_mapped: block ← one vertex good: hubs
dies: 1-degree leaves waste 255/256
merge_path: binary-search the CSR offsets so every thread gets
the same number of EDGES regardless of which vertex
they belong to — perfect balance, pays a search
advance.hxx:111-123 dispatches on a runtime enum — because no single strategy wins; real frontiers mix hubs and leaves. (CAGRA sidesteps this whole problem by CONSTRUCTION: fixed-degree graph ⇒ thread_mapped is perfect. Worth noticing.) Question: merge_path is topic 11’s morsel-stealing idea done with arithmetic instead of a queue — what property of CSR (sorted prefix offsets) makes the binary search sufficient?
3. Frontiers: sparse vs dense = push vs pull
vector_frontier (list of vertex ids) vs boolmap_frontier (bit per vertex): exactly topic 20’s SpMSpV-vs-SpMV and direction-optimizing BFS. Small frontier → sparse/push; huge frontier → dense/pull (and no filter needed — the bitmap dedupes by construction). Question: the switch threshold on CPU is ~|frontier| > n/20; what changes on GPU (atomics for sparse output vs full-array scans being nearly free at 400 GB/s)?
4. What transfers to M18/M20/M24
- The advance lambda = FalkorDB’s per-edge semiring op; Gunrock is what GraphBLAS-on-GPU compiles down to.
- Each BFS level = one dispatch (no device-wide barrier — the wgpu guide’s point); the frontier size must round-trip to the host OR use indirect dispatch. Find how Gunrock decides iteration convergence.
- The stretch-goal WGSL BFS: use boolmap frontier + level array — dense SpMV shape, no atomics needed except the “changed” flag.
Questions for notes.md
- Advance produces the NEXT frontier with unknown size — cudf solved this with size/retrieve; what does Gunrock use (scan the degrees of the input frontier first — same two-phase, different name)?
- BFS’s lambda uses CAS on parent[] — why is a LOST race benign here (any parent is a valid BFS tree — idempotence again)?
- Direction-optimizing BFS needs the REVERSE graph for pull. What does that double (memory), and when is it worth it (topic 13’s CSR+CSC question resurfacing)?
- Estimate: hub vertex, degree 10⁶, thread_mapped — how many microseconds does one thread take at ~10 edges/cycle/SM… vs merge_path spreading it over the whole device?
- For M24: LDBC power-law graphs on GPU — which advance strategy per LDBC scale factor, and does the answer change with the frontier’s hub fraction per BFS level?
References
Papers
- Wang, Davidson, Pan, Wu, Riffel, Owens — “Gunrock: A High-Performance Graph Processing Library on the GPU” (PPoPP 2016, arXiv:1501.05387) — §3 the operator model, §4 load balancing
Code
- gunrock — the modern
“Essentials” rewrite under
include/gunrock/— readalgorithms/bfs.hxxfirst, then the three load-balance strategies inframework/operators/advance/
libcudf: GPU kernels can’t push
RAPIDS’ GPU DataFrame engine — Arrow-layout columns (topic 12) with every operator rewritten under GPU constraints: no resizable output, atomics that must be amortized, and a memory hierarchy you manage by hand. The two-phase size/retrieve pattern and cooperative-group probing here are the idioms every GPU-DB operator ends up using.
Anchor map
| anchor | what it is |
|---|---|
| src/join/hash_join/ | size/retrieve split: inner_join_size.cu THEN inner_join_retrieve.cu |
| src/join/distinct_hash_join.cu | cuco-based build + cooperative-groups probe |
| src/join/hash_join/kernels_common.cuh | the probe kernel shapes |
| src/groupby/hash/compute_shared_memory_aggs.cu | per-block shared-mem aggregation + spill test |
| src/groupby/hash/compute_global_memory_aggs.cu | the global-atomics fallback |
| src/groupby/hash/compute_mapping_indices.cu | key → group index pass |
| src/join/conditional_join.cu | non-equi joins: nested loop, AST predicate on device |
| src/join/jit/ | JIT’d join predicates (topic 19 preview) |
| src/bitmask/ | validity bitmaps as first-class kernels (topic 11’s null masks) |
1. The two-phase everything (size → retrieve)
GPU kernels can’t push. Every variable-output operator runs twice:
pass 1 (size): each thread COUNTS its matches → total via reduce
allocate exactly total
pass 2 (retrieve): same probe again, write via computed offsets
inner_join_size.cu and inner_join_retrieve.cu are literally the
same probe loop with different epilogues. Alternatives they could
have used and didn’t: atomic global cursor (contended), max-size
over-allocation (memory).
#![allow(unused)]
fn main() {
// pass 1: the probe loop with a COUNTING epilogue
par_for i in 0..n_probe {
count[thread_id] += table.matches(keys[i]);
}
let offsets = exclusive_scan(count); // per-thread write positions
let out = alloc_exact(offsets.total()); // GPU output must be pre-sized
// pass 2: the SAME probe loop with a WRITING epilogue
par_for i in 0..n_probe {
for m in table.probe(keys[i]) { // recompute beats remembering:
out[offsets[thread_id]] = (i, m); // HBM traffic to materialize
offsets[thread_id] += 1; // match lists costs more than
} // probing the table twice
}
}
Question: pass 2 recomputes all of pass 1’s probes — why is recompute cheaper than remembering (HBM bandwidth vs materializing per-thread match lists)? Compare simdjson’s over-write-under-advance: same problem, opposite answer — why?
2. Cooperative-groups probing (distinct_hash_join.cu)
A single thread probing a hash table = one uncoalesced load per step. cudf (via cuco) probes with a COOPERATIVE GROUP: a warp fragment (e.g. 4-8 threads) loads a whole bucket window in one coalesced transaction, ballot-votes on matches, and the group advances together.
thread-per-probe: t0→slot17, t1→slot93, t2→slot4 (3 transactions)
group-per-probe: t0..t3 → slots 17,18,19,20 (1 transaction,
ballot → who matched) hashbrown Group
at warp scale!)
This is EXACTLY topic 17’s SwissTable match_tag — 16 control
bytes per vceq — with the warp playing the vector register.
Question: hashbrown shrank its NEON group to 8B; what’s the
analogous tuning knob in cuco (window size vs probe length)?
3. Group-by: shared memory until it spills
compute_shared_memory_aggs.cu sizes per-block scratch for the
output columns and BAILS to compute_global_memory_aggs.cu (global
atomics) when they don’t fit (~few hundred groups × columns). Two
levels of the same aggregation = topic 11’s partial/final split,
imposed by the ~100 KB shared-memory budget instead of by threads.
Question: high-cardinality group-by (1M groups) — neither fits.
What’s the classical answer (partition by group hash first — topic
13’s radix partition, now for occupancy)?
4. What Arrow layout buys on GPU
Columns are dense arrays + validity bitmaps — loads coalesce by construction; nulls process as bitmask kernels (src/bitmask/) not branches. A row-store on GPU would strand 31/32 of every transaction. Topic 12’s layout argument, with a 32× multiplier. Question: strings. Arrow offsets+bytes means variable work per element — find how cudf balances it (warp-per-string vs thread-per-char kernels in src/strings/) and relate to Gunrock’s ragged-frontier problem.
Questions for notes.md
- Count kernel launches for one
inner_join: build + size + retrieve (+ mapping). At ~1.5 ms dispatch overhead each (our measured floor on Metal), what’s the minimum batch that amortizes four launches? - The size/retrieve recompute doubles probe FLOPs. On the Crystal roofline, when is that free (probe is bandwidth-bound; second pass hits the same cache lines… does HBM have a “cache” that helps — L2)?
- Why does conditional_join fall back to nested-loop + device AST instead of hashing (non-equi predicates can’t hash — same reason topic 10’s planner keeps NL join)?
- cudf JIT-compiles join predicates (src/join/jit/) at runtime. What’s the WGSL analogue for our engine (naga compiles WGSL strings at pipeline creation — shader specialization = topic 19’s query compilation)?
- For M18: our filter_count stub’s one-atomic-per-workgroup is pass-1-only of the cudf pattern. Sketch the pass-2 (compact values, not count) using a workgroup prefix scan — Crystal’s BlockScan.
References
Code
- cudf —
cpp/src/— the anchor map above:join/hash_join/for size/retrieve,join/distinct_hash_join.cufor cooperative-groups probing,groupby/hash/for the shared-vs-global aggregation split,bitmask/for validity-mask kernels
wgpu compute: the 1.5 ms tax before your first FLOP
The portable GPU-compute stack our experiments use: WGSL shaders → naga → Metal on this Mac, Vulkan/DX12 elsewhere. This chapter walks three examples in order — each fixes one naivety of the previous — and names the fixed costs (dispatch overhead, staging copies, buffer limits) that decide whether any operator is worth offloading at all.
Anchor map
| anchor | what it is |
|---|---|
| examples/standalone/01_hello_compute/ | the full plumbing, heavily commented — read FIRST |
| examples/features/src/repeated_compute/ | amortizing setup across dispatches (what our GpuCtx does) |
| examples/features/src/hello_workgroups/ | workgroup semantics + shared memory |
| examples/features/src/hello_synchronization/ | barriers + atomics |
| examples/features/src/big_compute_buffers/ | >128 MB data — chunking around limits |
| examples/standalone/01_hello_compute/src/shader.wgsl | minimal WGSL compute entry |
1. The object ladder (hello_compute)
Instance — loads Metal/Vulkan/DX12
└ Adapter — one physical GPU; limits + features live here
└ Device — the logical connection; creates ALL resources
Queue — where encoded work is submitted
Buffer(STORAGE) — GPU-side data
Buffer(MAP_READ|COPY_DST)— the ONLY way back to the host
ShaderModule (WGSL) → ComputePipeline (entry point + layout)
BindGroup — binds buffers to @group/@binding slots
CommandEncoder → ComputePass → dispatch_workgroups(x,y,z)
submit → poll → map_async → read
The hello_compute doc-comment says it outright: for trivial math “running on the gpu is slower than doing the same calculation on the cpu… transfer/submission overhead is quite a lot higher than the actual computation.” Our gpu_bench measured that sentence: ~1.5 ms per dispatch, no crossover for sum up to 16M elements. Question: break down the 1.5 ms — encode, submit, Metal command-buffer scheduling, poll — which part would a persistent command buffer (repeated_compute) remove?
2. What our GpuCtx already does (repeated_compute)
Pipeline + shader compilation happen ONCE; per-call cost is buffer create + bind + encode + submit. The example goes further: reuses buffers across iterations too. Question: rewrite GpuCtx::sum to take pre-uploaded input (upload once, dispatch many) — how does the crossover table change? This is exactly Crystal’s regime A → B move, expressible in ~15 lines.
3. WGSL vs the CUDA you read about
| CUDA | WGSL | note |
|---|---|---|
__global__ kernel | @compute @workgroup_size(N) fn | size fixed at pipeline creation |
| blockIdx/threadIdx | @builtin(workgroup_id / local_invocation_id) | |
__shared__ | var<workgroup> | our sum.wgsl scratch |
__syncthreads() | workgroupBarrier() | workgroup-scope only |
| warp shuffles | subgroup ops (feature-gated) | portable fallback: shared memory |
| atomicAdd | atomicAdd(&x, v) on atomic<u32/i32> | NO float atomics in core WGSL |
Two DB-relevant gaps: no float atomics (aggregate f32 sums via u32-bitcast CAS loops or per-workgroup partials — our sum kernel’s design is FORCED by this) and no device-wide barrier (multi-pass algorithms = multiple dispatches; BFS levels each need their own submit).
#![allow(unused)]
fn main() {
// sum.wgsl's shape: fold in registers, tree-reduce in shared memory,
// ONE partial per workgroup — because WGSL has no float atomicAdd
var<workgroup> scratch: array<f32, WG>;
@compute @workgroup_size(WG)
fn sum(gid: u32, lid: u32) {
var acc = 0.0;
for (var i = gid; i < n; i += stride) { acc += input[i]; } // coalesced
scratch[lid] = acc;
workgroupBarrier();
for (var s = WG / 2u; s > 0u; s >>= 1u) { // tree reduction
if (lid < s) { scratch[lid] += scratch[lid + s]; }
workgroupBarrier();
}
if (lid == 0u) { partials[workgroup_id] = scratch[0]; }
} // second dispatch (or CPU) folds the partials — no device barrier
}
Question: what does the no-device-barrier rule do to the stretch-goal BFS (frontier per dispatch — where does the frontier size live)?
4. Limits that bite (big_compute_buffers)
Default max_storage_buffer_binding_size = 128 MB; default max
workgroups per dimension = 65535. Our sum kernel folds 4 elements
per thread partly to stay under the dispatch limit at n=2^24.
Question: at what n does the 128 MB limit break GpuCtx::sum, and
what’s the fix (request higher limits at device creation vs chunked
dispatches)?
Questions for notes.md
- Measure: GpuCtx::sum with upload hoisted out (regime B). Does the GPU beat 2258 µs CPU at n=16M now? Predict first.
- Why does WGSL make workgroup_size a compile-time pipeline constant while CUDA takes it at launch (hint: what can the compiler do with a known size — our scratch array)?
- The readback in our sum is 3-19 µs — tiny. Why is upload so much worse (staging copy through a private buffer even on unified memory — find the wgpu buffer-mapping discussion)?
- Subgroup (warp) ops vs shared-memory reduction: rewrite sum.wgsl’s tree loop with subgroupAdd — how many barriers disappear?
- For M18: the feature flag should gate at the operator boundary.
Which signature do you expose:
sum(&[f32])(per-call upload, regime A) orupload(&[f32]) -> GpuVec+sum(&GpuVec)(regime B)? Justify from this guide’s measurements.
References
Code
- wgpu —
examples/— read in order:standalone/01_hello_compute/(the full plumbing, heavily commented — its doc-comment admits the overhead out loud),features/src/repeated_compute/(amortizing setup — what our GpuCtx does), thenhello_workgroups/hello_synchronization/big_compute_buffersas needed
Topic 18 notes — GPU acceleration
Baseline (provided sum kernel, wgpu/Metal, Apple M3 Pro, measured 2026-07-10)
CPU = 8-accumulator autovec sum; GPU = workgroup tree reduction (sum.wgsl), END-TO-END including buffer creation, upload, dispatch, readback. 5-rep averages.
| n | CPU µs | GPU µs | upload | kernel+submit | readback | winner |
|---|---|---|---|---|---|---|
| 16K | 2.3 | 1618.9 | 48.5 | 1567.1 | 3.2 | CPU |
| 64K | 9.2 | 1633.5 | 69.6 | 1560.8 | 3.1 | CPU |
| 256K | 36.8 | 1701.5 | 151.8 | 1547.1 | 2.6 | CPU |
| 1M | 154.4 | 1985.5 | 437.3 | 1544.2 | 4.0 | CPU |
| 4M | 588.6 | 4554.8 | 1654.8 | 2887.2 | 12.8 | CPU |
| 16M | 2257.7 | 14332.9 | 7384.7 | 6929.1 | 19.1 | CPU |
No crossover, ever, for a memory-bound reduction on unified memory. Two reasons, cleanly separated by the phase columns:
- ~1.5 ms FIXED encode/submit/poll cost per dispatch (flat from 16K to 1M — pure overhead, not work).
- Even amortized (16M: ~7 ms kernel for 64 MB ≈ 9 GB/s effective), the GPU reads the SAME memory the CPU reads at ~30 GB/s — there is no bandwidth ratio to win (Crystal’s regime B advantage doesn’t exist on unified memory for streaming ops).
Upload cost is real despite “unified” memory: wgpu stages through a private buffer (~9 GB/s effective at 16M).
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| filter_count GPU vs CPU branchless (~12.7 GB/s) — crossover anywhere? | ||
| l2_batch dim=128 × 100K targets (51 MB, ~26 MFLOP… intensity ~0.5 FLOP/B on targets): GPU wins end-to-end? | ||
| l2_batch with targets PRE-UPLOADED (regime B): GPU µs at 100K targets? | ||
| row-major vs column-major targets in l2_batch — coalescing gap (×?) | ||
| hoisting upload out of sum (regime B): does GPU beat 2258 µs CPU at 16M? | ||
| one atomicAdd per ELEMENT instead of per workgroup in filter_count — slowdown ×? |
Implementation log
- filter_count.wgsl + pipeline — test green
- l2_batch.wgsl + pipeline (row-major) — test green
- l2_batch column-major variant — coalescing gap recorded
- regime-B variants (pre-uploaded buffers) — crossover table redone
- stretch: BFS via dense SpMV in WGSL vs CPU
- prediction table reconciled
Surprises / dead ends:
Questions from the reading guides
Crystal SIGMOD ’20 (reading-crystal-sigmod20.md)
- Which topic 22 benchmark shape flips regime B back to CPU:
- Regime table rewritten for Apple unified memory:
- Group-by atomics collapse cardinality + fallback:
- GPU profiling replacement for flamegraphs:
- Roofline go/no-go for expand/filter/distance:
wgpu compute (reading-wgpu-compute.md)
- Regime-B sum at 16M measured vs prediction:
- Why workgroup_size is a pipeline-time constant:
- Why upload ≫ readback on unified memory:
- subgroupAdd rewrite — barriers removed:
- M18 API: per-call upload vs GpuVec handle:
libcudf (reading-libcudf.md)
- Kernel launches per inner_join × 1.5 ms — min batch to amortize:
- When size/retrieve recompute is free (roofline):
- Why conditional_join is NL + device AST:
- cudf JIT ↔ WGSL pipeline specialization (topic 19):
- filter compact pass-2 via BlockScan sketch:
Gunrock (reading-gunrock.md)
- Advance’s unknown output size — Gunrock’s two-phase:
- Why lost CAS races are benign in BFS:
- Direction-optimizing needs CSC — memory doubling worth it when:
- Hub degree 10⁶: thread_mapped vs merge_path arithmetic:
- M24: advance strategy per LDBC frontier shape:
CAGRA (reading-cagra.md)
- Where the levels’ log-factor went at fixed degree 32:
- Why bitonic topk beats a heap on a warp:
- multi_cta partial-itopk merge (no device barrier):
- ADC tables vs visited hashmap — shared memory budget fight:
- M14+M18: which rescore half goes GPU first + batch size:
Faiss GPU (reading-faiss-gpu.md)
- WarpSelect k limit + thread-queue threshold t:
- Fused vs staged distance→k-select HBM traffic ratio:
- Coarse matmul near-peak vs PQ scan bandwidth-bound — intensities:
- Shard vs replicate ↔ topic 15 read-scaling:
- l2_batch brute-force prediction vs measurement:
Cross-topic threads
- SIMT = topic 17’s predication done by hardware; branch divergence = the branchy filter’s mispredict wall, warp edition.
- Coalescing = topic 12’s columnar argument with a 32× multiplier; shared memory = topic 13’s cache blocking, made explicit.
- cudf size/retrieve = simdjson’s over-write problem with the opposite answer (recompute vs over-allocate) — forced by 10⁵ threads sharing one output.
- CAGRA deletes Gunrock’s load-balancing problem by fixing the degree — regularity is bought at BUILD time, spent at SEARCH time.
- The 1.5 ms dispatch floor is topic 7’s syscall-batching argument: amortize the boundary crossing or die by it.
M18 log (capstone)
- GPU feature flag: batch vector-distance scoring (M14 rescore)
behind
--features gpu, GpuVec-handle API (regime B) - crossover bench per batch size committed as the go/no-go doc
- verdict paragraph: which engine ops clear the arithmetic-intensity bar on unified memory (expect ~only dense distance batches; traversal and filter stay CPU — SAY WHY with the roofline numbers)
Done when
- Both stub tests green; crossover + coalescing-gap numbers in the tables above; prediction table reconciled; reading-guide questions answered; M18 verdict written.
Topic 19 — JIT & Query Compilation
The other answer to interpretation overhead. Topic 11 killed the per-tuple interpreter with batches (vectorization); this topic kills it with compilation — turn the query into machine code so there is no interpreter left to amortize. HyPer made it famous, Umbra made it fast to compile, SQLite has quietly shipped a bytecode VM since 2000, and SuiteSparse:GraphBLAS JIT-compiles its semiring kernels — which makes this FalkorDB home turf twice over (M19 JITs Cypher expressions with cranelift).
1. The spectrum (and where each system sits)
tree walker ──► bytecode VM ──► template/copy-patch ──► IR JIT ──► LLVM -O3
(eval per (SQLite VDBE, (copy-and-patch, (Umbra (HyPer,
AST node) Postgres OOPSLA'21) Tidy Postgres
ExprState) Tuples, jit=on)
cranelift)
compile: 0 ~0 ~µs ~100µs ~10-100ms
run: 1× ~2-5× ~10× ~10-30× ~10-60×
Every step right buys execution speed with compilation latency. The entire topic is that trade — and the reason Postgres’s LLVM JIT is often a regression (§5): it sits at the far right where compile cost is milliseconds, gated by a planner cost heuristic that routinely misfires.
flowchart LR
Q[query arrives] --> D{expected work?}
D -->|one row, OLTP| I[interpret / bytecode\ncompile cost 0]
D -->|millions of rows| J[JIT\namortize compile over rows]
D -->|unknown| A[adaptive: start interpreting,\ncompile in background, swap in]
style A fill:#e8f5e9
Adaptive execution (ICDE’18) is the escape hatch Umbra ships: never pay compile latency up front, never miss the JIT win on long queries.
2. SQLite’s VDBE — the bytecode VM that refuses to die
~/repos/sqlite/src/vdbe.c — one giant dispatch loop
(vdbe.c:1049 switch( pOp->opcode )), 199 case OP_ opcodes, each
op a fixed struct (vdbeInt.h:55 struct VdbeOp: opcode + p1..p5
operands). EXPLAIN SELECT ... prints the program.
SELECT a+1 FROM t WHERE b < 10;
addr opcode p1 p2 p3
0 Init 0 8
1 OpenRead 0 2 ← cursor on table t
2 Rewind 0 7
3 Column 0 1 r1 ← b into register 1
4 Ge r1 6 ← if b >= 10 skip
5 Column+Add … ← a+1 into result register
6 ResultRow
7 Next 0 3 ← loop
Why bytecode and not a tree walker? The flattened program is
resumable (a coroutine — OP_Yield at vdbe.c:1264 powers
INSERT ... SELECT without materializing), inspectable, and the
dispatch is one indirect branch per op instead of a virtual call
per AST node. Why not JIT? SQLite’s queries touch a handful of rows
— column (a) of the flowchart above, compile cost can never
amortize. Guide: reading-sqlite-vdbe.md.
3. Produce/consume (Neumann VLDB’11) — compile the PIPELINE, not the operators
The paper’s insight: iterator-model next() calls are the cost, so
don’t compile operators that call each other — fuse each pipeline
into ONE tight loop where tuples stay in registers.
σ → Γ → ⋈ plan generated code (one pipeline):
for tuple in scan: ← produce
each operator gets if pred(tuple): ← σ consume
produce()/consume(); ht.insert(tuple) ← Γ consume
codegen walks the (pipeline breaker: hash table materializes;
tree ONCE, emits next pipeline starts a new loop)
nested control flow
Data flows upward through registers, control flow is inverted (push, not pull) — exactly topic 11’s push-vs-pull, but the pushing is done by generated code with zero interpretation. Guide: reading-neumann-vldb11.md.
4. Umbra’s Tidy Tuples & copy-and-patch — attacking compile LATENCY
HyPer used LLVM and ate 10-100 ms compiles. Umbra’s answer (VLDBJ’21): a custom low-level IR designed for single-pass lowering — the query translates to IR to machine code in one linear sweep, ~100× faster compiles at ~70-80% of LLVM -O3 speed, with LLVM kept as the top adaptive tier. Copy-and-patch (OOPSLA’21) goes further: precompile a library of binary “stencils” (one per operator/type combo, holes for constants), then “compilation” is memcpy + patching holes — microseconds. Guide: reading-umbra-tidy-tuples.md.
5. Postgres’s LLVM JIT — a cautionary tale
~/repos/postgres/src/backend/jit/llvm/ — expression + tuple-deform
JIT only (NOT whole-pipeline: the executor stays interpreted;
llvmjit_expr.c:80 llvm_compile_expr compiles ExprState step
arrays, emitting one basic block per step, llvmjit_expr.c:302-307).
Two LLJIT instances at opt0/opt3 (llvmjit.c:100-101). Gated by
jit_above_cost (planner.c:699-700) — a planner cost estimate
threshold. Failure mode: estimate says expensive, query is short,
you pay 50 ms of LLVM for a 5 ms query. That’s why every Postgres
ops guide says “try jit=off”. Guide:
reading-postgres-jit.md.
6. GraphBLAS’s JIT — compile the KERNEL, cache it forever
SuiteSparse takes a third road: the JIT unit is not a query but a
kernel specialization (semiring × types × sparsity formats).
Source/jitifyer/GB_jitifyer.c — encode the problem to a hash
(GB_encodify_mxm.c:55-59), look up an in-memory hash table
(GB_jitifyer.c:2119), fall back to an on-disk cache of compiled
.so files, fall back to invoking THE C COMPILER at runtime and
dlopening the result (GB_jitifyer.c:1565,1937). Compile once per
type-combo ever, not per query — amortization across the process
lifetime, not across rows. FalkorDB inherits this whole machinery.
Guide: reading-graphblas-jit.md.
7. And DuckDB has NO JIT — on purpose
The counter-argument, worth stating precisely: vectorization already amortizes interpretation to ~nothing (topic 11’s measured ~10-40×), a JIT adds a compiler dependency + compile latency + a security surface, and VLDB’18 (“Everything You Always Wanted to Know…”) measured compiled vs vectorized within ~2× of each other on most of TPC-H — vectorized even wins on hash-join-heavy queries (better memory parallelism from batched probes). JIT’s clear wins: complex expressions (compute-heavy scalar code) and data-centric loops LLVM can keep in registers. Hence M19 JITs expressions only — the eval.rs interpreter is the FalkorDB analogue of ExprState.
8. cranelift — the build tool
~/repos/cranelift-jit-demo/src/jit.rs is the whole recipe (461
lines): JITBuilder/JITModule (:39-41), FunctionBuilder translates
AST→CLIF IR (:135, :189), then declare→define→finalize→pointer
(:69-90). Cranelift sits at Umbra’s design point: fast single-pass
compiles (~10-100× faster than LLVM), decent code, pure Rust.
Guide: reading-cranelift-jit-demo.md.
Experiments (experiments/)
Three-way expression executor over f64 columns — the PLAN §19 bench:
| file | role |
|---|---|
| src/expr.rs | PROVIDED — Expr tree (Col/Const/Add/Mul/Lt/And) + seeded random generator |
| src/interp.rs | PROVIDED — AST-walking eval(expr, row) (the strawman) |
| src/vectorized.rs | PROVIDED — column-at-a-time batch eval (topic 11’s answer) |
| src/jit.rs | STUB — cranelift: compile Expr → fn(*const f64) -> f64 |
| src/bin/jit_bench.rs | PROVIDED — interpreter vs vectorized vs JIT, rows/s + compile µs, depth × rows sweep |
cd topics/19-jit/experiments
cargo test # provided tests green; jit tests panic until implemented
cargo run --release --bin jit_bench
Predict before you run (notes.md): at which (depth, rows) does JIT beat vectorized? Where does compile time drown it?
M19 (capstone)
- cranelift JIT for Cypher expressions vs eval.rs interpreter
- fallback path (unsupported expr node → interpreter, never fail)
- compile-time budget heuristic — measured, not estimated (postgres’s lesson: gate on actual rows seen, adaptive-style, not on a planner estimate)
Reading order
- reading-neumann-vldb11.md — the model
- reading-sqlite-vdbe.md — the bytecode floor
- reading-umbra-tidy-tuples.md — compile-latency war (+ copy-and-patch)
- reading-postgres-jit.md — how it goes wrong in production
- reading-graphblas-jit.md — kernel-grain JIT (FalkorDB’s inheritance)
- reading-cranelift-jit-demo.md — then implement the stub
Cranelift in 461 lines: AST to function pointer
The implementation manual for our stub: a toy language compiled to callable machine code, and the entire cranelift JIT recipe fits in one file. This chapter walks jit.rs top to bottom — read it before touching experiments/src/jit.rs, because every ceremony the stub needs (module lifetimes, SSA plumbing, the transmute contract) appears here first.
Anchor map
| anchor | what it is |
|---|---|
| src/jit.rs:39-41 | JITBuilder::with_isa(...) → JITModule::new |
| src/jit.rs:12-25 | the four state objects (see §1) |
| src/jit.rs:55-92 | compile() — the whole ladder, annotated below |
| src/jit.rs:135 | FunctionBuilder::new(&mut ctx.func, &mut builder_context) |
| src/jit.rs:180 | builder.finalize() — seals the CLIF function |
| src/jit.rs:189-191 | FunctionTranslator — AST→CLIF recursion lives here |
| src/jit.rs:400+ | helper emitters (calls, comparisons) |
| src/frontend.rs | the toy parser (87 lines — ignore, we have Expr) |
1. The object ladder (compare wgpu’s, topic 18)
JITBuilder ──► JITModule (owns memory for code+data)
├─ ctx: codegen::Context (one function's CLIF)
├─ builder_context: FunctionBuilderContext (reused scratch)
└─ declare/define/finalize API
FunctionBuilder(&mut ctx.func) (SSA construction helper —
you emit ops, IT handles
block params/phi nodes)
Same shape as topic 18’s Instance→Device→Pipeline: expensive long-lived containers, cheap per-function contexts, and an explicit “finalize” moment after which you hold a raw pointer.
2. The compile ladder (jit.rs:55-92, memorize this)
1. translate AST → CLIF (FunctionTranslator walk)
2. module.declare_function(name, Linkage::Export, &sig) → id
3. module.define_function(id, &mut ctx) ← compilation happens
4. module.clear_context(&mut ctx) ← reuse scratch
5. module.finalize_definitions() ← relocations patched
6. module.get_finalized_function(id) → *const u8 (:90)
7. unsafe { mem::transmute::<_, fn(f64...)->f64>(ptr) }
The same ladder as our stub will run it:
#![allow(unused)]
fn main() {
// CLIF in, callable pointer out — the whole recipe
fn compile(&mut self, expr: &Expr) -> fn(*const f64) -> f64 {
let mut b = FunctionBuilder::new(&mut self.ctx.func, &mut self.b_ctx);
let block = b.create_block();
b.append_block_params_for_function_params(block);
b.switch_to_block(block);
b.seal_block(block); // one block: seal immediately
let row_ptr = b.block_params(block)[0];
let v = translate(&mut b, expr, row_ptr); // the §3 table, recursively
b.ins().return_(&[v]);
b.finalize();
let id = self.module.declare_function("f", Linkage::Export, &sig)?;
self.module.define_function(id, &mut self.ctx)?; // ← compilation happens
self.module.clear_context(&mut self.ctx);
self.module.finalize_definitions()?; // ← relocations patched
unsafe { mem::transmute(self.module.get_finalized_function(id)) }
} // sound only while the JITModule lives — CompiledExpr must own it
}
The pointer is valid as long as the JITModule lives — our
CompiledExpr must own the module (drop order = use-after-free
otherwise; postgres solves the same lifetime with per-context
resource trackers, llvmjit.c:288).
3. Translating an expression (what the stub must do)
The demo’s translator (jit.rs:189+) is statement-oriented; our
Expr is pure — simpler. Per node:
Col(i) → load: builder.ins().load(F64, MemFlags::trusted(),
row_ptr, (i*8) as i32)
Const(c) → builder.ins().f64const(c)
Add(a,b) → builder.ins().fadd(va, vb)
Mul(a,b) → builder.ins().fmul(va, vb)
Lt(a,b) → cmp = builder.ins().fcmp(FloatCC::LessThan, va, vb)
→ select(cmp, one, zero) (we keep f64 1.0/0.0)
And(a,b) → both sides as f64 0/1 → fmin or fmul (branch-free —
topic 17's predication instinct, now in codegen)
Signature: fn(*const f64) -> f64 — one pointer param
(AbiParam::new(types::I64) or a real pointer type via
module.target_config().pointer_type()), one F64 return. SSA
plumbing: one block, append_block_params_for_function_params,
switch_to_block, seal_block — see jit.rs:135-180 for the
exact ceremony.
4. Cranelift vs LLVM in one table
cranelift LLVM -O3
compile speed ~10-100× faster baseline
code quality ~ -O0..-O1 best
passes e-graph based ~100 passes
mid-end (aegraph)
written in Rust (no FFI) C++ (bindgen pain)
designed for wasmtime JIT everything
Cranelift ≈ Umbra’s Flying Start as a design point (fast, single-tier, good-enough). For straight-line f64 arithmetic the quality gap vs LLVM nearly vanishes — no loops to optimize, and OUR loop (over rows) stays in Rust and gets rustc -O.
5. Gotchas for the stub
- Version lock: cranelift crates move together — Cargo.toml pins matching versions of cranelift-{jit,module,frontend,codegen,native}.
cranelift_native::builder()detects the host ISA; enableis_picfalse default is fine for JIT.MemFlags::trusted()= aligned + notrap: we promise row_ptr is valid — the unsafe contract lives at theeval()call site.- Floats: use
fcmp+select, NOT bint/bitcast tricks — CLIF’s bool handling changed across versions; select on f64 is stable. - The module must not be dropped:
CompiledExpr { module, func }with func called through a stored raw pointer.
Questions for notes.md
- Why does define_function (:78) not yet give you a callable —
what do relocations still need (addresses of other functions/
data), and which of our Expr nodes would introduce one (none —
pure arithmetic; a
pow()call would)? - FunctionBuilder “handles SSA construction” — what does that
mean concretely for a
varassigned in two branches (block params instead of phi nodes — how do they differ)? - Time
compile()in jit_bench across expr depths 2..12. Is it linear in node count? Where does the constant term come from (ISA setup? module init? — hoist GLOBAL vs per-expr state and measure both ways)? - The demo transmutes to
fn(f64) -> f64. Spell out every precondition that makes ourfn(*const f64) -> f64transmute sound (ABI = System V default? signature match? module alive? W^X handled by JITModule?). - M19: eval.rs values aren’t all f64 (nodes, strings, nulls). Which subset of Cypher expressions compiles to this f64 scheme directly, and what’s the fallback boundary (per-node fallback vs whole-expression bailout — pick one and defend it)?
References
Code
- cranelift-jit-demo
—
src/jit.rs— read it top to bottom;src/frontend.rs(the toy parser) can be skipped, we already haveExpr
GraphBLAS JIT: compile once per semiring, cache forever
The third grain of JIT. Postgres compiles per query; Umbra per pipeline; GraphBLAS compiles per kernel specialization — a (operation × semiring × types × sparsity formats) combination — and caches it for the lifetime of the machine. FalkorDB runs on this. Home turf.
Anchor map
| anchor | what it is |
|---|---|
| Source/jitifyer/GB_jitifyer.c:21-40 | the static hash table of loaded kernels |
| GB_jitifyer.c:2119 | GB_jitifyer_lookup — hash-table probe |
| GB_jitifyer.c:1565-1576 | GB_jitifyer_load — the full ladder |
| GB_jitifyer.c:1677-1710 | load2_worker — compile path under a critical section |
| GB_jitifyer.c:1937, 2050 | GB_file_dlopen — load the compiled .so |
| Source/jitifyer/GB_encodify_mxm.c:16-59 | problem → GB_jit_encoding + hash |
| GB_jitifyer.c:48 | direct compile/link vs cmake toggle |
| Source/jit_kernels/ | the kernel templates the JIT instantiates |
| GB_control.h + “PreJIT” | ahead-of-time compiled kernel table |
1. Why a kernel JIT at all (the combinatorial explosion)
GrB_mxm(C, M, accum, semiring, A, B, desc)
semiring = (add monoid × multiply op) over any types
× A/B/C/M sparsity ∈ {sparse, hypersparse, bitmap, full}
× masked/complemented, accum present/absent, ...
⇒ pre-compiling every combination: thousands of kernels ALREADY
shipped (the "factory" kernels) and still nowhere near coverage
— user-defined types/operators make it infinite.
Without JIT, any non-factory combination falls back to a generic kernel calling function pointers per entry — a per-ELEMENT interpreter, the exact overhead this whole topic is about, at the scalar grain. Question 1 quantifies the gap.
2. The load ladder (GB_jitifyer_load, :1565)
flowchart TD
E[encodify: problem → 64-bit hash + encoding\nGB_encodify_mxm.c:55-59] --> P{PreJIT table?\ncompiled into lib}
P -->|hit| RUN[call fn pointer]
P -->|miss| H{in-memory hash table?\nGB_jitifyer_lookup :2119}
H -->|hit| RUN
H -->|miss| D{.so in cache dir?\n~/.SuiteSparse/GrB.../}
D -->|hit| DL[dlopen :1937 → insert in table] --> RUN
D -->|miss| CC[write C source from template,\ninvoke C compiler, link .so\n:1677-1710 critical section] --> DL
Amortization horizon: the first mxm with a new semiring pays a
C-compiler invocation (~100 ms - 1 s); every later call in ANY
process pays a hash probe. Compare: postgres re-pays per query,
Umbra per query (µs), copy-and-patch per query (ns). GraphBLAS can
afford a huge one-time cost because the key space is small and
stable — type combos, not query texts.
#![allow(unused)]
fn main() {
// the load ladder: four caches, each with a longer lifetime
fn get_kernel(problem: &Mxm) -> KernelFn {
let (hash, enc) = encodify(problem); // SHAPE only — no data values
if let Some(f) = PREJIT.get(hash, &enc) { return f; } // in the binary
if let Some(f) = TABLE.lookup(hash, &enc) { return f; } // this process
if let Some(so) = cache_dir_probe(hash) { return dlopen_insert(so); }
critical_section(|| { // first time EVER: pay the compiler
write_c_from_template(&enc); // #defines into jit_kernels/
invoke_cc_and_link(); // ~100 ms - 1 s, once per combo
dlopen_insert(so_path(hash))
})
}
}
3. The encoding (GB_encodify_mxm.c)
The cache key: a packed bit-field struct (GB_jit_encoding) —
kernel code, then GB_enumify_mxm packs semiring ops, types,
sparsity formats, mask/accum flags into encoding->code
(:55-59). User-defined ops add a name suffix (:16-18) since
their semantics aren’t enumerable. Hash = the lookup key; the
suffix disambiguates. This answers postgres-guide Q5: the cache
key is the SHAPE with all data-dependent values excluded.
4. Compilation is literally cc (+ dlopen)
No LLVM, no cranelift: write a .c file instantiating a template
from Source/jit_kernels/ with #defines, shell out to the same
compiler that built the library (GB_jitifyer.c:59-71 stores
compiler+flags), dlopen the result (:1937). Crude and perfect
for the amortization horizon: the C optimizer gives factory-equal
code, and the cache makes latency irrelevant. There’s also PreJIT:
ship the accumulated cache compiled into the next binary release
(GB_jitifyer.c:299) — JIT as a build-time kernel harvester.
5. What transfers to M19/FalkorDB
- FalkorDB’s Delta matrices + custom semirings ride exactly this machinery — a cold start on a new semiring stalls the first query; consider warming the JIT cache at startup.
- M19’s Cypher-expression JIT should copy the two-level cache (in-memory hash + persist compiled artifacts keyed by expression shape) rather than postgres’s compile-every-time.
- The generic-kernel fallback is M19’s interpreter fallback: same contract — never fail, only be slower.
Questions for notes.md
- Find the generic mxm path (function-pointer per multiply-add,
Source/generic/). Estimate its per-entry cost vs a JITed
z += a*bon f64 (call + load fn ptr vs 1 FMA) — does the ratio match this topic’s interpreter/compiled gaps (~10×)? - Why is the critical section (:1677-1710) around compile+insert only, with lookup lock-free-ish before it — and what duplicate work can two threads still do (both compile; one insert wins — benign, same as Gunrock’s lost CAS)?
- The hash table is process-global and never evicts (GB_jitifyer.c:24-40). Why is unbounded growth fine here but would not be for a query-text-keyed cache (bounded key space — count it for FalkorDB’s actual semiring usage)?
- PreJIT (:299): kernels harvested from the JIT cache get compiled into the library. What’s the copy-and-patch analogy (stencils = AOT-compiled parametrized kernels), and where do the two differ (holes patched at runtime vs full specialization)?
- For M19: design the Cypher expression cache key. Which parts of
WHERE n.age > $p AND n.name = 'x'are shape vs parameter, and what does getting this wrong cost (constant folded in → cache miss per literal value → compile storm)?
References
Code
- GraphBLAS —
Source/jitifyer/(GB_jitifyer.c is the machine, GB_encodify_mxm.c the cache key) andSource/jit_kernels/(the templates the JIT instantiates);GB_control.hfor the PreJIT table
Produce/consume: compile the pipeline, not the operators
THE query-compilation paper (Neumann, VLDB ’11). One claim: the
iterator model’s next()-per-tuple is dead weight on modern CPUs
(virtual calls, cache-hostile hopping between operators), and the
fix is to compile each pipeline into one loop where the tuple
never leaves registers. Everything else in this topic is a reaction
to what this paper made possible — and to what it cost.
1. Why iterators lose (the paper’s §2, topic 11 recap)
Volcano: each next() = virtual call + branch mispredicts
+ tuple pointer chased through memory
per-tuple cost: ~dozens of instructions of pure bookkeeping
vectorized fix: amortize over 1024-row batches (topic 11)
compiled fix: eliminate — there is no interpreter at runtime
The paper’s Figure 1 point: operator boundaries in Volcano are also data boundaries (tuple goes to memory between operators). Compiled pipelines keep the current tuple in CPU registers across all operators of the pipeline.
2. Pipelines and pipeline breakers (the core vocabulary)
⋈ (hash)
/ \ P1: scan S → filter → build ht (breaker!)
Γ scan R P2: scan R → probe ht → Γ build (breaker!)
| P3: read Γ table → output
scan S
A pipeline breaker is any operator that must materialize (hash build, sort, group-by table). Everything between breakers becomes one generated loop. Question 1 below asks you to do this for a Cypher plan.
3. Produce/consume — codegen by tree walk
produce(op): "generate code that produces op's rows"
consume(op, source): "generate code receiving one row from source"
scan.produce() → emit: for row in table { filter.consume() }
filter.consume() → emit: if p(row) { join.consume() }
join.consume(build) → emit: ht.insert(row)
The generator recurses; the generated code is a flat nested loop. Control flow is inverted vs Volcano: the scan is on the OUTSIDE (push), consumers are inlined inside.
#![allow(unused)]
fn main() {
// the codegen walk: each operator knows how to PRODUCE rows and how to
// CONSUME one row from its child — the emitted code is one flat loop
fn produce(op: &Op, g: &mut Codegen) {
match op {
Scan(t) => { g.emit("for row in {t} {"); consume(parent(op), g); g.emit("}"); }
Filter(_, c) => produce(c, g), // filters produce via their child
HashJoin(b, p) => { produce(b, g); produce(p, g); } // two pipelines
}
}
fn consume(op: &Op, g: &mut Codegen) {
match op {
Filter(pred, _) => { g.emit("if {pred} {"); consume(parent(op), g); g.emit("}"); }
HashJoinBuild(_) => g.emit("ht.insert(row);"), // breaker: the loop ends here
Output => g.emit("emit(row);"),
}
}
}
Mermaid of the inversion:
flowchart LR
subgraph Volcano pull
out1[output] -->|next| j1[join] -->|next| s1[scan]
end
subgraph Compiled push
s2[scan loop] -->|inlined code| j2[join] -->|inlined| out2[output]
end
4. What they compile WITH — and the latency seed
HyPer emits LLVM IR (not C — they measure C compiler latency as seconds), mixing generated IR with precompiled C++ for complex operators (“cocktail”). Even so, LLVM -O3 on big queries costs 10-100 ms — the number that spawns Umbra’s Tidy Tuples (reading-umbra-tidy-tuples.md). Key engineering rule from the paper: generated code should be branch-predictable and keep attributes in registers; complex logic goes in precompiled C++ called from IR.
5. Numbers (2011 hardware, directionally durable)
- TPC-H vs Volcano-style: ~2-10× faster per query
- vs vectorized (VectorWise): usually faster but same ballpark — the honest comparison arrives in VLDB ’18 (README §7)
- compile time: tens of ms with LLVM even then
Questions for notes.md
- Draw the pipelines for a FalkorDB-ish plan:
MATCH (a)-[:R]->(b) WHERE a.x < 10 RETURN b.y, count(*). Which operators break the pipeline, and what does M19’s expression-only JIT compile vs what produce/consume would? - Why does push-based codegen produce ONE loop where pull-based codegen can’t — what forces materialization of control state in pull (the resumability the VDBE gets from bytecode, coroutines)?
- The “cocktail” rule: which parts of our jit_bench expression executor belong in precompiled Rust vs generated CLIF, and why is the boundary a function call in both HyPer and our stub?
- Registers vs L1: the paper claims tuple-in-registers across a pipeline. With 16 GP + 32 vector registers, how wide can a tuple get before this claim quietly dies (spills)?
- VLDB ’18’s result — vectorized wins hash-probe-heavy queries via memory parallelism. Explain with topic 13’s MLP argument: why does one-tuple-at-a-time compiled code serialize cache misses, and what did HyPer add to fix it (group prefetching / SIMD probe batching)?
References
Papers
- Neumann — “Efficiently Compiling Efficient Query Plans for Modern Hardware” (VLDB 2011) — read whole; §2 the argument, §3 produce/consume, §4 the LLVM “cocktail”
- Kersten et al. — “Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask” (VLDB 2018) — the honest compiled-vs-vectorized comparison Q5 leans on (also cited in README §7)
Postgres’s LLVM JIT: why everyone sets jit=off
The production cautionary tale. Postgres 11+ ships an LLVM JIT for expressions and tuple deforming only — the executor loop stays interpreted — and it is famous mostly for the advice “set jit=off”. Read it to learn exactly where the compile-latency spectrum bites, and which half of the JIT (deforming) actually pays.
Anchor map
| anchor | what it is |
|---|---|
| llvmjit.c:156 | provider hook: cb->compile_expr = llvm_compile_expr |
| llvmjit.c:85-101 | session state: two LLJITs — llvm_opt0_orc / llvm_opt3_orc |
| llvmjit.c:363 | llvm_get_function — lookup + (lazy) emission |
| llvmjit.c:716-781 | module → ThreadSafeModule → LLJIT dylib + resource tracker |
| llvmjit_expr.c:80 | llvm_compile_expr(ExprState*) — the entry point |
| llvmjit_expr.c:302-307 | one LLVM basic block per ExprState step (opblocks) |
| llvmjit_expr.c:326+ | the giant case EEOP_* switch — mirror of the interpreter |
| llvmjit_expr.c:354+ | EEOP_*_FETCHSOME → JIT tuple deforming (llvmjit_deform.c) |
| planner.c:699-700 | the gate: top_plan->total_cost > jit_above_cost |
1. What is actually compiled
NOT compiled: executor nodes (SeqScan, HashJoin...) — still the
interpreted node->ExecProcNode indirection
compiled: ExprState step arrays (WHERE clauses, projections,
aggregates' transition expressions)
+ tuple DEFORMING (attribute extraction — schema-
specialized: known offsets, nullability)
ExprState is postgres’s bytecode: a flat array of steps
(EEOP_FUNCEXPR, EEOP_QUAL, …) run by a threaded-dispatch
interpreter (execExprInterp.c — computed goto). The JIT translates
each step to a basic block (opblocks, llvmjit_expr.c:302-307) and
lets LLVM fold the dispatch away. Structurally the SAME translation
our stub does for Expr → CLIF — postgres just starts from
bytecode instead of an AST.
#![allow(unused)]
fn main() {
// llvm_compile_expr's shape: one basic block per interpreter step —
// the dispatch the interpreter pays per step becomes a fallthrough
let opblocks: Vec<Block> = state.steps.iter().map(|_| new_block()).collect();
for (i, step) in state.steps.iter().enumerate() {
position_at(opblocks[i]);
match step.opcode {
EEOP_QUAL => emit_cmp_and_branch(step, opblocks[step.jumpdone]),
EEOP_FUNCEXPR => emit_direct_call(step.fn_addr, step.args),
EEOP_SCAN_FETCHSOME => emit_deform(tupledesc, step.last_attr),
// ... the giant switch mirrors execExprInterp.c case by case
}
emit_branch(opblocks[i + 1]); // then LLVM folds blocks together
}
}
2. The cost model failure (the actual lesson)
planner.c:699: use JIT iff estimated total_cost > jit_above_cost
(default 100000)
failure 1: estimate high, reality short → pay ~10-100ms LLVM
for a fast query (the classic complaint)
failure 2: cost is in COST UNITS not ms — jit_above_cost has no
unit relationship with compile time on this machine
failure 3: decision is per-QUERY, all-or-nothing, made BEFORE
any row is seen — no adaptivity (contrast Umbra)
failure 4: opt3 is gated by ANOTHER estimate (jit_optimize_above_
cost) — two thresholds to mistune
There’s a partial mitigation: two LLJIT tiers (opt0/opt3, llvmjit.c:100-101) — but tier choice is still estimate-driven.
3. Tuple deforming — the underrated half
llvmjit_deform.c generates a schema-specialized decoder: attribute offsets constant-folded, null-bitmap checks skipped for NOT NULL columns, alignment known. This routinely beats the expression JIT in profit because deforming is per-ROW-per-ATTRIBUTE and pure branchy pointer math — the same reason topic 12’s PAX/columnar layouts win, arrived at from the compiler side.
4. Lifecycle plumbing worth stealing
llvmjit.c:716+ — modules are compiled into a dylib with a
resource tracker per compilation; llvmjit.c:288-299 shows teardown
(remove tracker, clear dead symbol-pool entries). Memory for JITed
code is owned per-query-context: when the query dies, the code
dies. M19 note: cranelift’s JITModule has the same
free_memory obligation — our stub keeps the module alive inside
CompiledExpr so the fn pointer can’t dangle.
5. What transfers to M19
- Compile the expression, keep the executor: exactly M19’s scope.
- Gate on MEASURED cost (rows already processed × measured ns/row vs measured compile µs), not an estimate.
- Deforming lesson: FalkorDB’s property access (attribute fetch from the property store) is the deform-analogue — likely more profit than arithmetic JIT.
Questions for notes.md
- Trace one EEOP through both executors: find EEOP_QUAL in execExprInterp.c and in llvmjit_expr.c. What does LLVM get to do that the interpreter can’t (cross-step constant prop, dead null-check elimination)?
- Why does the JIT emit ONE function per ExprState with a block per step, rather than one function per step (call overhead + register state across steps — the copy-and-patch contrast)?
- jit_above_cost is in planner cost units. Propose the fix postgres upstream keeps debating: what would a time-based gate need to know (compile-time model per step count + rows estimate — and which half is still an estimate)?
- Deform JIT: for a 20-column table where the query touches
column 19, what does the generated decoder skip vs the generic
slot_deform_heap_tuple, and which topic 12 layout makes the whole problem vanish? - For M19: postgres compiles per-query with no cache. GraphBLAS caches per type-combo forever (reading-graphblas-jit.md). Which is right for Cypher expressions, and what’s the cache key (expression shape with constants as parameters — count how many distinct shapes a workload of 1000 queries has)?
References
Code
- postgres —
src/backend/jit/llvm/— llvmjit.c (lifecycle), llvmjit_expr.c (the EEOP switch), llvmjit_deform.c (the underrated half); pair withsrc/backend/executor/execExprInterp.cto see what each EEOP block replaces, andplanner.c:699for the gate
SQLite’s VDBE: the bytecode floor
The oldest shipping answer to interpretation overhead: don’t walk the AST, flatten it to bytecode once at prepare time, then run a register machine. 25 years in production, zero JIT, and for SQLite’s workload (few rows per query, embedded) it is the RIGHT point on the spectrum — the floor every JIT must beat before its compile time counts.
Anchor map
| anchor | what it is |
|---|---|
| src/vdbe.c:1049 | THE loop: switch( pOp->opcode ) |
| src/vdbe.c:1062 | comment: file is ordered by case OP_ convention |
| src/vdbeInt.h:55 | struct VdbeOp — opcode, p1,p2,p3 ints, p4 union, p5 flags |
| src/vdbe.c:1098 | OP_Goto — jump = set pOp, break re-enters switch |
| src/vdbe.c:1154 / :1187 | OP_Gosub / OP_Return — subroutines via a register |
| src/vdbe.c:1209 / :1264 | OP_InitCoroutine / OP_Yield — coroutines! |
| src/vdbe.c:1284 | OP_HaltIfNull — constraint checks as opcodes |
199 case OP_ total | the entire ISA |
1. The machine model
prepare: SQL ──parse──► AST ──codegen──► VdbeOp[] program
execute: pc = 0
for(;;){ pOp = &aOp[pc];
switch(pOp->opcode){ ... } ← vdbe.c:1049
pc++ or jump }
state: array of Mem registers (typed values), array of cursors
(open B-tree positions). A register machine, NOT a stack machine
— p1/p2/p3 name registers directly, no push/pop traffic.
Run EXPLAIN SELECT ... in any sqlite3 shell to see programs.
Question 1 asks you to read one.
2. Dispatch cost — what bytecode buys and what it doesn’t
One switch = one indirect branch per opcode. The branch predictor
sees ONE hot indirect jump with 199 targets — mispredict-prone
(topic 17’s branchy filter, interpreter edition). Threaded dispatch
(computed goto per-op) gives the predictor per-op history; SQLite
gains ~limited benefit and keeps the portable switch by default
(look for SQLITE_THREADSAFE-adjacent perf notes and the
OP_-macros). Either way you pay ~5-20 cycles of dispatch per op —
fine when each op does real work (B-tree step), brutal when ops are
Add r1 r2 r3 on millions of rows. That’s the JIT’s opening, and
SQLite’s workload simply doesn’t have it.
3. Coroutines — the feature bytecode gets for free
OP_InitCoroutine/OP_Yield (vdbe.c:1209, :1264): a subquery becomes
a coroutine — its program counter lives in a register, Yield swaps
pc values. INSERT INTO t SELECT ... streams without materializing
the SELECT. A tree-walking interpreter would need real coroutines
or callbacks; flattened bytecode makes suspension trivial (save
one integer). This is the same resumability argument as topic 7’s
io_uring state machines and Neumann’s §Q2 pull-model pain.
4. The ISA design (vdbeInt.h:55)
struct VdbeOp {
u8 opcode; /* one byte, 199 used */
signed char p4type; /* what the union holds */
u16 p5; /* flags */
int p1, p2, p3; /* register/cursor/jump operands */
union p4 { int i; char *z; ... KeyInfo*, FuncDef* ... };
}
Fixed 24-ish-byte ops, arrays not linked lists — the program is cache-linear. p2 is always the jump target by convention, so the code generator can fix up forward jumps in one pass. Compare Umbra’s IR (also fixed-width, also single-pass-friendly): same instinct, different target (interpretation vs fast native lowering).
5. What transfers to M19
FalkorDB’s eval.rs walks an expression tree per row — it sits LEFT of SQLite on the spectrum. M19’s cranelift JIT jumps two steps right. The VDBE lesson: there is a defensible middle (flatten to a register program, interpret that) that costs zero compile time and already kills tree-walk overhead — worth benching as a fourth lane in jit_bench if the JIT crossover disappoints (question 5).
Questions for notes.md
- Run
EXPLAIN SELECT a+1 FROM t WHERE b<10(any SQLite). Paste the program; identify the loop (Rewind/Next), the filter (Ge/Lt with p2 jump), the expression ops. How many dispatched ops per row? - Register machine vs stack machine: count the ops
a*b + c*dneeds on each. Why did SQLite pick registers (fewer dispatches, at the cost of codegen doing register allocation)? - OP_Yield: trace pc swapping between coroutine and caller. What exactly is saved/restored (ONE register holding pc — why is that sufficient, i.e. where do the coroutine’s locals live)?
- Why is
case OP_Column(the B-tree record decoder) enormous whilecase OP_Addis ~10 lines — and what does that say about where VDBE dispatch overhead actually matters? - Sketch the fourth lane: a bytecode compiler for our
Exprenum (flatten toVec<Op>with register slots, interpret with one match). Predict where it lands between interp and JIT in rows/s, then (stretch) build it and check.
References
Code
- sqlite
src/vdbe.c— start at the dispatch loop (:1049) and read opcodes in file order; thecase OP_comment convention makes it navigable - sqlite
src/vdbeInt.h—struct VdbeOpand the register/cursor state EXPLAINin any sqlite3 shell — the fastest way to see programs
Umbra & copy-and-patch: the war on compile latency
Two attacks on the same enemy: compile LATENCY. HyPer proved compiled queries run fast; production taught that 100 ms of LLVM before a 10 ms query is a loss. Umbra’s answer is a bespoke IR and a tiered backend; copy-and-patch’s answer is to do the compiling at BUILD time and only memcpy at runtime.
1. The latency budget (why LLVM had to go)
HyPer, TPC-H Q1 scale: LLVM -O3 compile ≈ tens of ms
short OLTP query: execution ≈ sub-ms
⇒ compile:run ratio can exceed 100:1
Umbra target: compile in ~100 µs — "Flying Start"
LLVM’s cost is structural, not a flag: SSA construction, many IR passes, ISel/RegAlloc are all multi-pass over graphs. Umbra’s observation: query code is generated, regular, and short-lived — it doesn’t need a general optimizer.
2. Tidy Tuples — the codegen layer
The name is the data-centric value tracking in the code generator: attributes are tracked through codegen with their types and locations (register/memory), so the generator emits loads lazily and never re-materializes. The layer stack:
relational algebra
└─ Tidy Tuples codegen (produce/consume, tracks values)
└─ Umbra IR (SSA-ish, fixed-width ops, ONE pass
per lowering — designed so every
lowering step is linear scan)
├─ Flying Start: direct x86 emit (~µs, ~LLVM -O0+)
└─ LLVM -O3 (background, hot queries only)
Design rules that make it fast (compare vdbe’s fixed 24-byte ops): IR ops fixed-size in one contiguous array; no pointer graphs; types are simple scalars; control flow is basic blocks with fall-through bias. Everything single-pass.
3. Adaptive execution — never choose wrong
flowchart LR
Q[query] --> B[compile Flying Start ~µs]
B --> R[start running]
R --> H{still running after\nbudget? }
H -->|no| DONE[done — never paid LLVM]
H -->|yes| L[LLVM -O3 in background thread]
L --> S[swap function pointer at\nnext morsel boundary]
S --> DONE2[rest of query at full speed]
The swap granularity is topic 11’s morsel: execution is already chunked, so “replace the function between morsels” is natural. This kills the postgres failure mode (reading-postgres-jit.md) — the decision uses measured runtime, not a planner estimate.
4. Copy-and-patch (OOPSLA ’21) — compile time ≈ memcpy
build time: compile a library of STENCILS with clang —
object code for each (operator × type) with HOLES
(relocations) for constants/offsets/branch targets
run time: for each IR op: memcpy stencil, patch holes
→ machine code in ~100s of ns per op
The runtime “compiler” is barely a loop:
#![allow(unused)]
fn main() {
fn compile(ops: &[IrOp], stencils: &Stencils, out: &mut Code) {
for op in ops {
let s = &stencils[op.kind()]; // object code built at BUILD time
let base = out.append(&s.bytes); // "compilation" is a memcpy
for hole in &s.holes { // relocations left unresolved
out.patch(base + hole.offset, op.operand(hole.which));
}
} // no IR, no passes, no regalloc
}
}
The trick making stencils composable: continuation-passing style +
tail calls (musttail) so each stencil ends by jumping to the next
— no prologue/epilogue, registers stay live across stencils
(GHC-ish calling convention). Result: compiles ~2 orders faster
than LLVM -O0 with better code than -O0. This is the natural
floor of the spectrum between bytecode and real JIT — and
PostgreSQL people have prototyped it for ExprState.
5. What transfers to M19
M19’s budget heuristic should be Umbra-shaped, not postgres-shaped: interpret first, count rows/time actually spent, JIT when the measured cost clears the (measured) cranelift compile cost from jit_bench. Cranelift itself sits near Flying Start on the ladder: single-tier, fast compile, decent code — a sane single choice when you don’t want two backends.
Questions for notes.md
- Umbra IR vs LLVM IR: name three concrete representation choices that make single-pass lowering possible (fixed-width ops, contiguous arrays, restricted types/CFG) and what each gives up.
- Flying Start does register allocation in one linear pass — what property of generated query code (short straight-line blocks, few live values — the Tidy Tuples tracking) makes that acceptable where a C compiler couldn’t?
- Copy-and-patch: why does continuation-passing + musttail let stencils compose without spilling registers at boundaries, and what does that share with WGSL/wgpu’s “pipeline fixed at creation” specialization from topic 18?
- The adaptive swap happens at morsel boundaries. What state must the compiled and interpreted versions AGREE on for the swap to be sound (hash tables, cursors, partial aggregates — the pipeline-breaker state, exactly)?
- For M19: cranelift compile of a depth-8 expression costs X µs
(measure in jit_bench). Using the measured interp rows/s, write
the break-even row count formula and compute it. Does a
FalkorDB
WHEREclause over a 1M-node scan clear it?
References
Papers
- Kersten, Leis, Neumann — “Tidy Tuples and Flying Start: Fast Compilation and Fast Execution of Relational Queries in Umbra” (VLDB Journal 2021)
- Xu & Kjolstad — “Copy-and-Patch Compilation” (OOPSLA 2021, arXiv:2011.13127)
Topic 19 notes — JIT & query compilation
Baseline (provided interpreter + vectorized, Apple M3 Pro, measured 2026-07-10)
jit_bench, N_COLS=4, best-of-3, Mrows/s. Full binary trees so node count = 2^(depth+1)-1.
| depth | nodes | interp M/s | vector M/s | vector/interp |
|---|---|---|---|---|
| 2 | 7 | 60-94 | 380-518 | ~6× |
| 4 | 31 | ~19.5 | 150-186 | ~8× |
| 6 | 127 | ~4.1 | 39-54 | ~11× |
| 8 | 511 | ~0.95 | 10-13 | ~12× |
| 10 | 2047 | ~0.24 | 2.6-3.2 | ~12× |
Both lanes scale ~linearly in node count (interp: ~0.95 M/s × 511 nodes ≈ 485 Mnodes/s ≈ 2.1 ns/node — a match dispatch + 2 calls; vector: ~12 M/s × 511 ≈ 6 Gnode-rows/s ≈ node cost dominated by materializing temporaries, ~16 B/row/node of memory traffic). Vectorized flat at ~10-12× from depth 6 up: the topic 11 number, reproduced. Row count barely matters — no lane has a fixed cost yet. Compile time will change that: the JIT lane is the only one with a y-intercept.
Predictions (fill BEFORE implementing jit.rs)
| question | prediction | actual |
|---|---|---|
| cranelift compile µs for depth 8 (511 nodes)? linear in nodes? | ||
| JIT run-only M/s at depth 8 (vs vector ~12, interp ~0.95)? | ||
| break-even rows vs INTERP at depth 8 (compile/(µs_i−µs_j)) | ||
| break-even rows vs VECTORIZED at depth 8 — does 2M rows clear it? | ||
| depth 2 tiny expr: does JIT ever win e2e at ≤2M rows? | ||
| does JIT beat vectorized per-ROW at all (no temporaries, but scalar vs autovec SIMD)? |
Reasoning to check later: JIT straight-line f64 code ≈ 1 FMA-ish op per node with ILP ⇒ maybe 3-6 Gnode/s single lane ⇒ depth 8 ≈ 6-12 M/s — comparable to vectorized, NOT clearly faster, because vectorized gets SIMD from autovec and JIT emits scalar. The honest VLDB’18 conclusion, predicted before measuring.
Implementation log
- jit.rs compile() — both tests green
- jit_bench full three-way table + crossover rows
- compile-time-vs-nodes linearity measured (hoist ISA/module setup, measure both ways)
- stretch: 4th lane — bytecode VM for Expr (sqlite-vdbe guide Q5)
- prediction table reconciled
Surprises / dead ends:
Questions from the reading guides
Neumann VLDB’11 (reading-neumann-vldb11.md)
- Pipelines for the Cypher plan / what M19 compiles vs produce-consume:
- Why push gives ONE loop, pull needs suspendable state:
- Cocktail rule applied to our executor:
- Tuple-in-registers dies at what width:
- VLDB’18 hash-probe MLP argument:
SQLite VDBE (reading-sqlite-vdbe.md)
- EXPLAIN program for a+1 WHERE b<10, ops/row:
- Register vs stack machine op counts for ab+cd:
- OP_Yield pc-swap — where do locals live:
- Why OP_Column is huge and OP_Add tiny:
- Bytecode 4th lane prediction:
Umbra / copy-and-patch (reading-umbra-tidy-tuples.md)
- Three Umbra-IR choices enabling single-pass:
- Why linear-scan regalloc is fine for query code:
- musttail stencil composition ↔ wgpu pipeline specialization:
- What state interp/compiled must agree on at swap:
- Break-even formula with measured numbers; 1M-node WHERE verdict:
Postgres JIT (reading-postgres-jit.md)
- EEOP_QUAL in both executors — what LLVM folds:
- One function per ExprState vs per step:
- Time-based gate — which half stays an estimate:
- Deform JIT vs generic for col 19 of 20:
- Per-query vs per-shape cache for Cypher:
GraphBLAS JIT (reading-graphblas-jit.md)
- Generic mxm fn-pointer cost vs JITed FMA:
- Critical-section scope / benign duplicate compiles:
- Why unbounded cache is fine (key space size for FalkorDB):
- PreJIT ↔ copy-and-patch stencils:
- Cypher expression cache key (shape vs parameter):
cranelift-jit-demo (reading-cranelift-jit-demo.md)
- Why define ≠ callable (relocations); which Expr nodes need them:
- FunctionBuilder SSA — block params vs phis:
- compile() linearity + constant term:
- Transmute soundness preconditions:
- Cypher f64-subset + fallback boundary choice:
Cross-topic threads
- The whole topic is topic 11’s dispatch-amortization argument with a third strategy: vectorize amortizes per-batch, JIT eliminates. VLDB’18 says they tie ~2×; our bench should reproduce that.
- Vectorized’s per-node temporary vector = topic 18’s cudf over-allocate answer; JIT keeps values in registers = Neumann’s claim = Tidy Tuples’ value tracking.
- GraphBLAS’s hash→dlopen ladder = topic 6’s buffer-pool lookup-then-fault pattern, for code instead of pages.
- postgres jit_above_cost misfiring = topic 10’s cardinality- estimate fragility, exported into the executor.
- copy-and-patch stencils with musttail = topic 17’s fixed compare-exchange networks: precommit the schedule, kill dispatch.
M19 log (capstone)
- cranelift JIT for Cypher expressions vs eval.rs interpreter
- fallback: unsupported node → interpreter (GraphBLAS generic- kernel contract: never fail, only slower)
- budget heuristic from MEASURED numbers (rows seen × ns/row vs measured compile µs) — postgres’s estimate-gate is the anti-pattern; cache compiled exprs by shape, params excluded (GraphBLAS encodify lesson)
Done when
- Both jit.rs tests green; three-way table + crossovers in notes; prediction table reconciled; reading-guide questions answered; M19 verdict written.
Topic 20 — Sparse Linear Algebra & GraphBLAS Internals
Deep home turf. FalkorDB calls the GraphBLAS API daily; this topic
owns what’s underneath: the formats SuiteSparse switches between,
the four SpGEMM engines behind one GrB_mxm, masks as the
execution model, and why push-vs-pull BFS is just SpMSpV-vs-SpMV.
M20 is the capstone’s heart: our own kernels + delta matrices
replace the M13 adjacency core.
1. The format lattice (what one GrB_Matrix really is)
density →
hypersparse ──► sparse (CSR/CSC) ──► bitmap ──► full
(rows list (rowptr[n+1] + (byte per (no structure,
only nonempty colidx per edge) cell + just values)
rows: h[] + values)
their ptrs)
nvals ≪ nrows nvals ~ O(nrows) nvals > every cell
(10M×10M with the graph default ~4-8% of present
100K edges) n×m
Switch heuristics are numbers in the code, not magic:
sparse→bitmap when nnz > bitmap_switch * nrows*ncols
(GB_convert_sparse_to_bitmap_test.c:32-38, default per-op table);
hyper↔sparse via hyper_switch on the count of non-empty vectors
(GB_conform_hyper.c:52); all applied by GB_conform
(GB_conform.c:33-89) after every operation. Why hypersparse matters
to FalkorDB: node IDs are a namespace, most rows of a relation
matrix are empty — CSR’s rowptr alone for 10M nodes = 80 MB per
relation type without it.
2. One mxm, four engines (Source/mxm/)
flowchart TD
MXM[GrB_mxm C<M>=A*B] --> META[GB_AxB_meta.c — pick engine]
META -->|"mask present, C sparse: C<M>=A'*B"| DOT3["dot3 (pull):
one dot product PER ENTRY OF M
work ∝ nnz(M) — the mask PRUNES"]
META -->|no mask, general| SAXPY3["saxpy3 (push/Gustavson):
C(:,j) += A(:,k)*B(k,j)
work ∝ flops — mask only FILTERS"]
META -->|C bitmap/full| SAXBIT[saxbit / dot2 / dot4 variants]
GB_AxB_saxpy3.c:22-60 is the scheduling essay: B split into
coarse tasks (own whole vectors) and fine tasks (teams share one
vector); each task independently picks Gustavson (dense
workspace of size m — the SPA) or hash (table sized 2×next-pow2
of estimated flops) — hash wins when the workspace would be cold,
Gustavson when the hash would exceed m/16 (:57). A flopcount pass
(GB_AxB_saxpy3_flopcount.c) sizes everything first — cudf’s
size/retrieve two-phase (topic 18), five years earlier.
GB_AxB_dot3.c computes C<M>=A'*B only where M has entries — the
reason FalkorDB masks are free performance, and the exact semantics
our stub reproduces.
3. Push vs pull = vxm vs mxv (the Beamer SC’12 story)
LAGraph’s production BFS (template/LG_BreadthFirstSearch_SSGrB _template.c) is direction-optimizing BFS written in linear algebra:
push (frontier small): q'<!visited> = q' * A (GrB_vxm :307)
work ∝ edges OUT of frontier — SpMSpV, saxpy engine
pull (frontier huge): q<!visited> = AT * q (GrB_mxv :313)
work ∝ rows still unvisited × early-exit — SpMV dot engine,
each unvisited vertex scans ITS in-edges, stops at first hit
switch push→pull: frontier growing AND (nq > n/β1 OR
pushwork > unexplored/α) α=8, β1=8 (:184-187, :261)
switch pull→push: frontier shrinking below n/β2, β2=512
The semiring is ANY_SECONDI (:140-143): ANY = “any parent will do”
(Gunrock’s benign-race, done algebraically — topic 18), SECONDI =
“the value is the edge’s source index” — the parent vector computed
with zero comparisons. Guide: reading-lagraph.md
4. Masks, semirings, ANY — the execution model
The GraphBLAS trinity, as an executor design:
- semiring (⊕,⊗): the inner loop’s two ops. Swapping (+,×) for (min,+) turns SpMV into SSSP relaxation; (ANY,PAIR) turns it into reachability with early exit.
- mask
C<M>=...: not post-filtering — dot3 iterates the mask, so structural masks change complexity class (triangle counting: compute (L*U’)∘L touches only wedges that close — LAGr_ TriangleCount.c:31-46 lists all six masked formulations). - accum + non-blocking mode: GrB_wait boundaries let SuiteSparse defer/fuse — the API-level hook FalkorDB’s delta matrices exploit.
5. Delta matrices — FalkorDB’s own layer (fresh eyes)
~/repos/FalkorDB/src/graph/delta_matrix/ — the answer to “GrB
matrices are fast to read, slow to mutate one edge at a time”:
Delta_Matrix = M (settled GrB_Matrix, hypersparse CSR)
+ delta-plus DP (pending additions)
+ delta-minus DM (pending deletions)
+ the same trio TRANSPOSED (delta_matrix.h:110-113)
read: A ≡ (M + DP) minus DM
write: O(1)-ish into DP/DM (bitmap/hash-friendly, tiny)
sync: Delta_Matrix_wait — M ←(M ∪ DP) \ DM, clear deltas
(delta_wait.c:13-46: deletions via GrB_transpose-as-copy
with GrB_DESC_RSCT0 mask trick, additions via assign)
mxm: (A*(M+DP))<!A*DM> — delta_mxm.c:44-86 folds pending state
into ONE masked multiply instead of forcing a sync
This is topic 3’s LSM memtable+tombstones, rebuilt over matrices — same read-merge, same deferred compaction, same “don’t block the writer” motive. Guide: reading-falkordb-delta-matrix.md.
6. Parallelism: OpenMP inside SuiteSparse, rayon in Rust
SuiteSparse parallelizes with OpenMP, and the scheduling decisions
are explicit code, not #pragma omp parallel for sprinkled around:
saxpy3 (GB_AxB_saxpy3.c:22-48):
B's vectors ──► coarse tasks (one thread OWNS whole columns)
└──► fine tasks (a TEAM shares one fat column,
atomics on the workspace)
how many threads? not "all of them":
flopcount pre-pass ──► nthreads = GB_nthreads(total_flops,
chunk, nthreads_max)
(GB_AxB_saxpy3_slice_balanced.c:418)
tiny multiply ⇒ 1 thread — the parallelism is COSTED like a
query plan, using the same flopcount that sizes hash tables
Even the flopcount pass itself is parallel
(#pragma omp parallel for schedule(dynamic,1) —
GB_AxB_saxpy3_flopcount.c:219). The philosophy: static
work-partitioning from a cost estimate, balanced up front
(target task size, slice_balanced.c:456), because the cost model
is cheap and accurate (flops are countable for SpGEMM).
The Rust translation is rayon, and it inverts the philosophy:
| OpenMP (SuiteSparse) | rayon | |
|---|---|---|
| unit | task list built up front | join(a, b) recursive split |
| balance | pre-computed from flopcount | work-stealing (crossbeam_deque) |
| thread count | costed per-operation | pool-global, steal if idle |
| skew handling | fine tasks + atomics | idle threads steal halves |
| code | ~500 lines of slicing | par_iter().map(...) |
rayon’s join (rayon-core/src/join/mod.rs:93) pushes the second
closure onto the calling thread’s deque and runs the first inline;
an idle thread steals the pushed half (registry.rs:248 — a
crossbeam_deque::Stealer per worker). Work-stealing makes the
flopcount pre-pass optional: skewed rows (RMAT’s heavy tail) get
split and stolen dynamically. The price: stealing has per-task
overhead, so you still chunk (with_min_len) — a cost decision
OpenMP-SuiteSparse made statically.
No native-Rust GraphBLAS exists: rustgraphblas and
graphblas_sparse_linear_algebra are FFI bindings over SuiteSparse
(you inherit its OpenMP). A pure-Rust kernel core — M20 — parallelizes
with rayon and must answer saxpy3’s questions itself: when is one
thread right, and who owns the workspace?
→ guide: reading-openmp-vs-rayon.md
7. Where the other topics plug in
- SpGEMM hash-vs-Gustavson = topic 8’s hash-vs-sort aggregation choice, per task.
- dot3’s mask-driven iteration = topic 10’s semi-join pushdown.
- saxpy3 flopcount pre-pass = topic 18’s cudf size/retrieve.
- JIT’d semiring kernels = topic 19’s jitifyer ladder — every measured number here runs through it.
- GPU GraphBLAS (GraphBLAST/Gunrock) = topic 18’s regime question: frontiers ship, matrices stay resident.
Experiments (experiments/)
| file | role |
|---|---|
| src/csr.rs | PROVIDED — CSR type, COO→CSR build, transpose, RMAT + uniform generators |
| src/spmv.rs | PROVIDED — row-parallel SpMV (f64) + (ANY,PAIR) bool variant |
| src/spgemm.rs | PROVIDED hash-Gustavson (HashMap SPA, slow-but-obvious); STUB dense-SPA Gustavson (scatter/gather workspace, the saxpy3 coarse task) |
| src/bfs.rs | PROVIDED scalar queue BFS oracle; STUB push (SpMSpV), pull (masked SpMV w/ early exit), and direction-optimizing switch |
| src/hyper.rs | PROVIDED — hypersparse row index; bench shows when 80 MB of rowptr disappears |
| src/bin/gb_bench.rs | PROVIDED — RMAT scale sweep: SpMV GB/s, SpGEMM variants, BFS push/pull/adaptive with per-level frontier trace |
cd topics/20-graphblas/experiments
cargo test # oracle + provided green; stubs panic
cargo run --release --bin gb_bench
M20 (capstone)
- sparse kernel core: CSR + hypersparse, SpMV/SpMSpV, masked dot-SpGEMM subset, (ANY,PAIR)/(PLUS,TIMES)/(MIN,PLUS) semirings
- delta-matrix layer over it (DP/DM + transposed pair, wait, the delta_mxm fold) replacing the M13 adjacency core
- LDBC bench vs reference
graph/src/graph/graphblaslayer; direction-optimizing BFS parity with LAGraph’s α/β thresholds - parallelize the kernels with rayon; document each OpenMP→rayon mapping decision (task slicing vs work-stealing, workspace ownership, when one thread wins) in notes.md
Reading order
- reading-davis-toms19.md — the system paper (+ v2 update)
- reading-suitesparse-internals.md — formats, conform, saxpy3/dot3
- reading-gustavson-spgemm.md — the ’78 paper + Buluç-Gilbert survey
- reading-beamer-sc12.md — direction-optimizing BFS
- reading-lagraph.md — the algorithms as executable linear algebra
- reading-falkordb-delta-matrix.md — then implement the stubs
- reading-openmp-vs-rayon.md — saxpy3’s OpenMP scheduling vs rayon work-stealing, before parallelizing the M20 kernels
Direction-optimizing BFS: push until pull is cheaper
Beamer’s SC ’12 paper made BFS a two-algorithm problem: push (frontier scans its out-edges) wins early, pull (unvisited vertices scan their in-edges) wins at the peak, and a two-threshold switch picks per level. Read with LAGraph’s template open (reading-lagraph.md) — the 2012 idea ships verbatim in the 2025 library, thresholds and all.
1. Top-down’s waste (why push dies mid-search)
Top-down (push): each frontier vertex scans its out-edges, tries to claim unvisited neighbors. On small-world graphs the frontier explodes — level 3-4 of an RMAT graph holds most of the graph:
level: 0 1 2 3 4 5
|frontier|: 1 d̄ d̄² ~n/2 ~n/4 tail
push work: d̄ d̄² d̄³ HUGE … …
↑ most edge checks FAIL: neighbor already visited
At the peak, nearly every edge inspection hits an already-visited vertex — wasted claims (and wasted CAS on parallel hardware).
2. Bottom-up’s bet (pull)
Invert: each UNVISITED vertex scans its in-edges asking “is any parent in the frontier?” — and stops at the FIRST hit (early exit). When the frontier is most of the graph, an unvisited vertex finds a frontier parent in O(1) expected probes:
push work ≈ Σ_{v ∈ frontier} out_degree(v) (all of it)
pull work ≈ Σ_{v unvisited} (probes until first frontier hit)
≈ nnz-touched shrinks as frontier grows
Pull needs: the REVERSE graph (CSC / AT — the memory-doubling question from topic 13 and Gunrock), and a dense frontier representation (bitmap — O(1) membership).
#![allow(unused)]
fn main() {
// pull: each UNVISITED vertex asks "is any of MY parents in the frontier?"
fn bfs_pull_level(at: &Csr, frontier: &Bitmap, visited: &Bitmap) -> Bitmap {
let mut next = Bitmap::new(at.nrows);
for v in 0..at.nrows {
if visited.get(v) { continue; }
for &u in at.row(v) { // v's in-edges (a row of AT)
if frontier.get(u) {
next.set(v);
break; // ANY monoid: first hit suffices —
} // the early exit IS the speedup
}
}
next
}
}
3. The switch heuristic (the shipped numbers)
push → pull: m_frontier_out > m_unexplored / α (α = 14 paper,
or |frontier| > n/β1 8 in LAGraph)
pull → push: |frontier| < n / β2 (β = 24 paper,
512 in LAGraph)
Asymmetric thresholds = hysteresis (same instinct as SuiteSparse’s
format switches). LAGraph adds a refinement: track
edges_unexplored incrementally by subtracting frontier degrees
(template :196, :261-277) — the heuristic input is maintained, not
recomputed. Result on scale-free graphs: 3-8× total edge
inspections saved; on high-diameter graphs (road networks) pull
never triggers and the machinery must cost ~nothing.
4. The linear-algebra translation (Yang/Buluç/Owens ICPP ’18)
push = q' * A sparse vector × CSR = SpMSpV (saxpy engine)
pull = AT * q CSR(AT) × vector w/ mask = masked SpMV (dot
engine, ANY monoid ⇒ early exit is LEGAL)
visited mask = the complemented structural mask (GrB_DESC_RSC)
direction switch = engine dispatch on frontier density
The profound part: SuiteSparse’s format switch (sparse↔bitmap vector) and engine switch (saxpy↔dot) mirror the push↔pull switch — the same decision at three abstraction levels. Our stub implements all three explicitly in ~100 lines.
Questions for notes.md
- Reproduce Beamer’s waste argument from gb_bench’s per-level trace: at the peak level, what fraction of push’s edge checks found an already-visited target (count them — add a counter to the stub)?
- Why does pull’s early exit require the ANY (or OR) monoid algebraically — what property (idempotent, any-witness-suffices) makes stopping sound, and which semirings BREAK it (PLUS: you need every contribution — BFS parent vs PageRank)?
- Road network vs RMAT: predict which levels (if any) go pull on each, from diameter and degree distribution alone. Then check with gb_bench –trace.
- The reverse graph doubles memory. FalkorDB keeps BOTH (the
transposed delta trio, delta_matrix.h:20-22) — for which query
shapes besides BFS pull is AT load-bearing (incoming-edge
traversals
<-[]-)? - LAGraph’s β2=512 (vs paper’s 24) makes pull→push switch-back very late. Hypothesize why (switch-back cost includes rebuilding a SPARSE frontier from a bitmap — O(n) scan), and design the experiment that would confirm it.
References
Papers
- Beamer, Asanović, Patterson — “Direction-Optimizing Breadth-First Search” (SC 2012) — §3-4 are the two algorithms and the waste argument; §5’s α/β tuning is the part LAGraph copied
- Yang, Buluç, Owens — “Implementing Push-Pull Efficiently in GraphBLAS” (ICPP 2018, arXiv:1804.03327) — the push=vxm / pull=mxv translation in §3
Code
- LAGraph
src/algorithm/template/LG_BreadthFirstSearch_SSGrB_template.c— the shipped thresholds (:184-187) and switch logic (:243-292); walked in reading-lagraph.md
SuiteSparse:GraphBLAS: a sparse-matrix executor in disguise
Davis’s TOMS ’19 system paper (plus the ’23 v2 update) describes the library under FalkorDB. Read it as an executor-design paper, not a math paper: it’s about lazy evaluation, format polymorphism, and kernel dispatch — the same problems as topics 8-11, in matrix clothing.
1. The object model (paper §3, code Source/matrix/GB_matrix.h)
GrB_Matrix = opaque header
├─ format: hypersparse | sparse | bitmap | full (×2: by row/col)
├─ pending tuples + zombies (lazy mutation!)
├─ hyper_switch / bitmap_switch (per-matrix knobs)
└─ iso flag (all values equal — store ONE value)
Zombies (deleted-but-present entries) and pending tuples
(inserted-but-unsorted) are the library’s OWN delta mechanism —
FalkorDB’s delta matrices exist because these are flushed at
GrB_wait boundaries the engine can’t always control. Question 2.
2. Non-blocking mode (the executor story)
The spec allows every operation to return before doing work. SuiteSparse uses it for mutation batching (pending tuples get sorted+merged once), not full lazy fusion (v2 paper discusses the JIT changing this calculus). Compare topic 27’s incremental view maintenance: same “amortize small updates” shape.
The whole mechanism, distilled:
#![allow(unused)]
fn main() {
fn set_element(a: &mut Matrix, i: u64, j: u64, v: f64) {
a.pending.push((i, j, v)); // O(1): append, don't restructure CSR
}
fn delete_element(a: &mut Matrix, i: u64, j: u64) {
if let Some(e) = a.find_mut(i, j) {
e.mark_zombie(); // flag in place — no O(nnz) splice
}
}
fn wait(a: &mut Matrix) { // the GrB_wait boundary
a.prune_zombies(); // one sweep drops ALL zombies
a.pending.sort_unstable(); // n inserts → one sort + one merge,
a.merge_pending_into_csr(); // not n binary-searched splices
conform(a); // then maybe switch format
}
}
3. The v2 update (TOMS ’23) — what changed
- the CPU JIT (topic 19’s jitifyer) — user-defined types/semirings now run at factory speed
- 32/64-bit integer indices chosen per matrix (v10) — halves index memory for graphs under 4B edges, i.e. all of ours
- iso-valued matrices — unweighted graphs store ZERO bytes of values (A(i,j)=true for all: pattern-only + one scalar)
Iso + (ANY,PAIR) semiring is why BFS over an unweighted FalkorDB relation matrix moves no value data at all — pattern in, pattern out. Question 4.
4. Numbers to retain
- format switch defaults: bitmap when nnz > ~4-8% (op-dependent), hyper when non-empty vectors < hyper_switch × nrows (~1/16)
- saxpy3 hash→Gustavson threshold: hash table > m/16 ⇒ Gustavson
- mxm engines: dot3 work ∝ nnz(M); saxpy3 work ∝ flops — the mask changes the complexity CLASS, not a constant
Questions for notes.md
- Map GrB objects to executor concepts: semiring ↔ ?, mask ↔ ?, accum ↔ ?, descriptor ↔ ? (operator, semi-join filter, UPDATE expression, query hints — defend each).
- Zombies+pending vs FalkorDB’s DP/DM: why does FalkorDB need its OWN deltas when the library already has them (control over WHEN wait happens; transposed pair kept in lockstep; readers must see pre-wait state — which reason dominates)?
- The iso optimization: which FalkorDB matrices are iso (adjacency bool — yes; relation with edge IDs as values — no). What does losing iso cost on mxm bandwidth (values move again — 8×?)?
- Trace one BFS step through the v2 machinery: iso bool matrix, ANY_PAIR semiring, sparse frontier — which engine runs (saxpy3/SpMSpV), and what does the JIT specialize away?
- 32-bit indices (v10): for a 10M-node 100M-edge graph, compute the CSR memory in v9 (64-bit) vs v10 — and where the same 2× shows up in our Rust CSR if we switch usize→u32.
References
Papers
- Davis — “Algorithm 1000: SuiteSparse:GraphBLAS: Graph Algorithms in the Language of Sparse Linear Algebra” (ACM TOMS 2019) — the system paper; read §3 (object model) and the non-blocking-mode discussion closely
- Davis — “Algorithm 1037: SuiteSparse:GraphBLAS: Parallel Graph Algorithms in the Language of Sparse Linear Algebra” (ACM TOMS 2023) — the v2 update: JIT, 32/64-bit indices, iso matrices
Code
- SuiteSparse:GraphBLAS
Source/matrix/GB_matrix.h— the object model in one header; the internals walk is reading-suitesparse-internals.md
Delta matrices: an LSM memtable over GraphBLAS
FalkorDB’s answer to “GrB matrices are fast to read, slow to mutate one edge at a time” — your own code, with this curriculum’s eyes. The delta matrix is topic 3’s LSM memtable+tombstone pattern rebuilt over GrB matrices — read it against topics 3 (LSM), 6 (buffer mgmt), and this topic’s zombies/pending-tuples machinery, and ask at each step “why not just let SuiteSparse’s own deltas do this?”
Anchor map
| anchor | what it is |
|---|---|
| delta_matrix.h:34-108 | the state-transition comment table (A/DP/DM invariants per op) — the spec |
| delta_matrix.h:110-116 | the struct: M + delta_plus + delta_minus + transposed twin |
| delta_matrix.h:17-22 | accessor macros incl. the T* transposed trio |
| delta_wait.c:13-33 | sync_deletions: GrB_transpose(m, dm, NULL, m, GrB_DESC_RSCT0) — transpose-as-masked-copy |
| delta_wait.c:36-46+ | sync_additions: fold DP into M, clear DP |
| delta_mxm.c:44-99 | (A*(M+DP))<!A*DM> — multiply WITHOUT forcing a sync |
| delta_get_set.c / delta_isStored.c | the 3-way read path (check DM, DP, M) |
| delta_will_wait.c | “would GrB_wait do work?” — the flush-decision probe |
1. The core invariant (the header comment IS the design doc)
delta_matrix.h:34-108 walks every operation through a worked example. Distilled:
logical A ≡ (M ∪ DP) \ DM
invariants: DP ∩ M = ∅ (additions are NEW entries)
DM ⊆ M (you can only pending-delete settled entries)
delete of a DP entry clears DP directly (:99) — never
passes through DM
the transposed twin maintains the same trio, updated in lockstep
Same read algebra as an LSM point-read (memtable ∪ sstables minus
tombstones), and wait is minor compaction. The read and write
paths, distilled:
#![allow(unused)]
fn main() {
// logical A ≡ (M ∪ DP) \ DM — an LSM point-read over matrices
fn contains(&self, i: u64, j: u64) -> bool {
if self.dm.contains(i, j) { return false; } // tombstone wins
self.dp.contains(i, j) || self.m.contains(i, j)
}
fn set(&mut self, i: u64, j: u64) {
if self.m.contains(i, j) {
self.dm.remove(i, j); // resurrect a pending-deleted entry
} else {
self.dp.set(i, j); // NEW entry → DP (keeps DP ∩ M = ∅)
}
self.transposed.set(j, i); // the twin trio, in lockstep
}
}
2. Why not SuiteSparse’s own pending tuples? (the load-bearing question)
SuiteSparse already defers mutations (zombies + pending tuples, reading-davis-toms19.md §1). The delta layer exists because:
- flush control: ANY GrB read op can force internal wait; FalkorDB needs reads that DON’T flush (readers under a write lock, MVCC-ish semantics) — DP/DM are ordinary matrices the library never touches implicitly.
- the transposed twin: SuiteSparse maintains ONE matrix;
FalkorDB needs M and Mᵀ synced under the same deltas
(delta_matrix.h:20-22) — pull traversals are always available
(
<-[]-patterns). - bounded sync cost: wait folds a SMALL DP/DM (bounded by write-batch size) — library pending tuples can degrade into a full rebuild inside an unrelated query.
3. delta_mxm — algebra instead of a flush
delta_mxm.c:44-86: to compute C = A*B where B has pending state,
accum = A * DP (:86 — the additions' contribution)
mask = A * DM (ANY_PAIR bool, :74 — rows poisoned by deletions)
C = (A * M) + accum, masked by !mask — "(A*(M+DP))<!A*DM>"
Two extra small multiplies instead of one big compaction — the LSM read-amplification-vs-compaction trade, chosen per multiply. Note the mask is coarse: A*DM marks any output touched by a deleted edge, potentially over-masking; check how the caller compensates (question 3).
4. wait — the two-sided compaction
delta_wait.c: deletions first (GrB_transpose(m, dm, NULL, m, GrB_DESC_RSCT0) — a transpose of m into itself, masked by the
COMPLEMENT of dm, T0 transposing the transpose away: a masked
copy that drops deleted entries in one library call), then
additions (assign/eWiseAdd DP into M), then clear both. The
Delta_Matrix_wait policy decision — sync now vs stay lazy —
consults nvals thresholds (delta_will_wait.c): compaction
triggering by size, topic 3 again.
5. What transfers to M20
M20 rebuilds this over OUR kernels: the trio + transposed twin,
the read algebra in get/extract, the mxm fold, threshold-driven
wait. The reference is the spec; the interesting freedom is
choosing DP/DM’s format (hash-of-pairs? small COO? bitmap?) now
that we own the representation — measure against GrB_Matrix DP
via the LDBC update workloads.
Questions for notes.md
- Verify the invariants against delta_set_element_bool.c and delta_remove_element.c: enumerate the 4 cases (entry in M, in DP, in DM, absent) × (set, remove) — which transitions does the header table at :34-108 show, and are any missing?
- The transposed twin doubles write work on every mutation. Cost it: per set_element, how many GrB calls hit each trio — and what would break if the transpose were rebuilt lazily at wait instead (pull traversals see stale AT between waits)?
- delta_mxm’s mask A*DM over-masks (kills a full output entry if ANY contributing edge is deleted — but other, live edges might also produce it). Find how correctness is restored (recompute masked region against (M+DP)\DM? restrict when delta_mxm is used at all — check callers in graph/graph.c) — and write the counterexample matrix that exposes it.
- delta_will_wait / the sync thresholds: what nvals bounds trigger a flush, and how do they map to LSM L0 file-count triggers (write-visible latency vs read amplification)?
- For M20: pick DP/DM’s representation in Rust. COO Vec<(u32,u32)>
- sort at wait (LSM-flavored) vs HashMap (point-read-flavored) — which do the LDBC interactive update+read mixes prefer? Predict, then bench both under gb_bench’s update workload.
References
Code
- FalkorDB
src/graph/delta_matrix/— start with the state-transition comment table indelta_matrix.h:34-108(it IS the design doc), thendelta_wait.c,delta_mxm.c,delta_get_set.c,delta_will_wait.c
Gustavson SpGEMM: one output row at a time
Every modern sparse-times-sparse multiply — saxpy3, cuSPARSE, our M20 stub — is still Gustavson’s 1978 row-wise algorithm with a different accumulator. This chapter derives why row-wise is work-optimal, then uses Buluç & Gilbert’s survey to map the whole design space onto one question: what data structure is the SPA?
1. The problem: C = A*B when everything is sparse
Dense matmul is three nested loops; sparse kills two of them. The inner-product view (C(i,j) = A(i,:)·B(:,j)) does nnz(A)·nnz(B) intersection work mostly producing zeros. Gustavson’s row-wise view:
for i in rows(A): # one output row at a time
for k where A(i,k) ≠ 0: # A's row pattern
for j where B(k,j) ≠ 0: # B's row k
SPA[j] += A(i,k) * B(k,j) # scatter-accumulate
C(i,:) = gather nonzeros of SPA # then reset SPA
Work = flops = Σᵢₖ nnz(B(k,:)) over A’s entries — optimal: you touch exactly the multiplications that exist. The entire algorithm design space since is “what data structure is SPA”:
SPA = dense array + occupied list (Gustavson '78)
O(1) scatter, O(m) alloc, gather via occupied list
SPA = hash table (saxpy3 hash task)
O(1)-ish scatter, O(flops) alloc — wins for huge m
SPA = heap / sorted-list merge (merge k sorted rows of B)
output comes out SORTED — no gather/sort pass
2. The two-pointer / two-phase trick (the “two” in the title)
Output size nnz(C) is unknown before you compute it. Gustavson: symbolic phase (pattern only — compute row counts, allocate exact) then numeric phase (fill). Every system in this curriculum that meets sparse output rediscovers this: saxpy3’s flopcount pre-pass, cudf’s size/retrieve, Gunrock’s degree scan. Alternative: guess + grow (topic 17’s simdjson over-allocate answer). Our stub does symbolic+numeric; the HashMap reference does guess-free accumulation and pays for it in allocator traffic.
3. Buluç & Gilbert’s axes (the survey’s map)
- formulation: row-wise (Gustavson) / outer-product (column of A × row of B → rank-1 updates, needs merging) / inner-product
- accumulator: SPA / hash / heap / merge — pick by density of the output row and size of m
- parallelism: rows are independent (row-wise ⇒ embarrassingly parallel over i) BUT power-law graphs make row costs wildly unequal ⇒ saxpy3’s coarse/fine split, Gunrock’s merge_path — the same load-balance problem at every layer of this curriculum
- compression: masked SpGEMM (
C<M>=A*B) can skip work only in dot formulation; Gustavson’s mask only prunes writes
4. Cost intuition to carry
For RMAT/power-law A²: flops concentrate in hub rows (row i’s cost ∝ Σ degrees of i’s neighbors — degree-squared weighting). A few rows are 1000× the median: static row partitioning dies, which is why every real implementation has the fine-task path.
Questions for notes.md
- Derive: why is Gustavson’s total work exactly Σ_{(i,k)∈A} nnz(B(k,:)) and why can no SpGEMM do fewer multiplications (each is a necessary term — unless the SEMIRING short-circuits: ANY_PAIR reachability can stop early — where?).
- The dense SPA costs m bytes×2 (value + mark) per thread. For m = 10M that’s cold DRAM per row. Compute the crossover row density where hash beats SPA using topic 13’s cache numbers (SPA touches nnz_out random cells of an 80 MB array; hash touches nnz_out cells of a 2×flops table that fits L2).
- Symbolic+numeric does the pattern walk TWICE. When is guess-and-grow cheaper (flops/row small and uniform — the variance argument; connect to topic 16’s ddmin determinism requirement… no wait, to cudf’s retrieve-skip answer)?
- Outer-product SpGEMM produces k rank-1 updates that must be merged — which topic 3 structure is that (LSM: sorted runs + merge), and why does it win out-of-core / distributed (sequential I/O, no random SPA)?
- Masked Gustavson can’t skip work; masked dot can. Show it on
triangle counting
C<L>=L*L: what does each formulation compute per wedge, and reconcile with LAGraph shipping BOTH Sandia_LL (saxpy) and Sandia_LUT (dot) as the fastest per-graph choices.
References
Papers
- Gustavson — “Two Fast Algorithms for Sparse Matrices: Multiplication and Permuted Transposition” (ACM TOMS 1978) — the row-wise algorithm + the symbolic/numeric two-phase; short and readable
- Buluç, Gilbert — “Parallel Sparse Matrix-Matrix Multiplication and Indexing: Implementation and Experiments” (SIAM J. Sci. Comput. 2012, arXiv:1109.3739) — the design-space framing: formulation × accumulator × parallelism
LAGraph: graph algorithms as executable linear algebra
LAGraph is the “standard library” of GraphBLAS — and each of the three algorithms read here (BFS, triangle counting, PageRank) is a few GrB calls whose entire performance story lives in which engine/format/mask they trigger underneath. This is where the previous chapters’ machinery gets exercised end to end, and where M20’s parity targets come from.
Anchor map
| anchor | what it is |
|---|---|
| template/LG_BreadthFirstSearch_SSGrB_template.c:184-187 | α=8, β1=8, β2=512 — the Beamer thresholds |
| …template.c:243-292 | the push↔pull switch logic (growing/shrinking + thresholds) |
| …template.c:307 | push: GrB_vxm(q, mask, …, q, A, GrB_DESC_RSC) |
| …template.c:313 | pull: GrB_mxv(q, mask, …, AT, q, GrB_DESC_RSC) |
| …template.c:140-143 | GxB_ANY_SECONDI_INT{32,64} — parent BFS with zero comparisons |
| …template.c:196, 261-277 | edges_unexplored maintained incrementally |
| LAGr_TriangleCount.c:31-46 | all SIX masked-mxm triangle formulations + which wins where |
| LAGr_PageRankGAP.c:99-135 | GAP-style PR: prescaled degrees, mxv + PLUS_SECOND at :135 |
| LG_CC_FastSV7.c | connected components via hooking/shortcutting (min-semiring) |
1. BFS: the whole Beamer paper in 40 lines
The loop (template :243-313) reads as a decision procedure:
if push: switching to pull if frontier growing AND
(nq > n/8 OR push-work estimate > unexplored/8)
if pull: switching back if frontier < n/512
then ONE line does the level: vxm (push) or mxv-on-AT (pull),
mask = complemented visited (DESC_RSC: replace + structural
complement), assign frontier into parent/level vectors (:335-340)
Everything we said in reading-beamer-sc12.md is these ~70 lines. The out_degree vector and AT are optional inputs — without them it silently degrades to push-only (:18-22): the caller decides whether pull’s memory doubling is worth it, not the library.
The whole loop, transcribed:
#![allow(unused)]
fn main() {
loop {
// the direction switch wraps ONE line of algebra per level
if push && growing && (nq > n / 8 || push_work > unexplored / 8) {
push = false; // frontier huge → pull
} else if !push && nq < n / 512 {
push = true; // tail → back to push
}
q = if push {
vxm(&q, &visited, AnySecondi, a) // q'<!visited> = q' * A
} else {
mxv(at, &q, &visited, AnySecondi) // q<!visited> = AT * q
};
parent.assign_where(&q); // ANY: any parent will do
nq = q.nvals();
if nq == 0 { break; }
}
}
2. The semiring trick: ANY_SECONDI
ANY_SECONDI (:140-143): multiply op SECONDI returns the index of
the second operand’s entry — i.e. the parent’s id; monoid ANY
keeps whichever arrives. No min, no compare, no tie-break — any
parent is a valid BFS tree (Gunrock’s benign CAS race, expressed
as algebra). 32- vs 64-bit variant chosen by n > INT32_MAX: the
v10 index-size story at the algorithm level.
3. Triangle counting: six spellings of one mask
LAGr_TriangleCount.c:31-46 — Burkhardt sum((A²).*A)/6, Cohen
sum((L*U).*A)/2, Sandia_LL sum((L*L).*L), … Sandia_LUT
sum((L*U').*L). All the same count; they differ ONLY in which
mxm engine and how much the mask prunes:
.*Lmasks the OUTPUT to the lower triangle — dot3 iterates only candidate wedges- LL vs LU’: saxpy vs dot formulation — the comment (:43-46) says LUT (dot) usually wins, but LL (saxpy) wins on GAP-urand: uniform-random degrees flatten the hub problem, exactly the Gustavson-vs-hash tradeoff
- there’s also a presort by degree (relabeling!) that bounds wedge work — topic 13’s “renumber for locality,” used for algorithmic pruning
4. PageRank (GAP variant): no mask, all bandwidth
LAGr_PageRankGAP.c:99-135 — prescale out-degrees by damping once
(:112), then each iteration is r = teleport; r += AT'*(t/d) via
one mxv with PLUS_SECOND (:135) + eWise ops. Dense vectors, full
sweep, no early exit: PageRank is the SpMV bandwidth benchmark
(gb_bench’s spmv lane IS this). Note what’s absent: GAP PR skips
proper dangling-node handling for speed (:comment near top) — a
benchmark-vs-correctness tension to remember for topic 22.
5. What transfers to M20/M24
- M20’s BFS parity target: match the template’s switch behavior with our α/β on LDBC graphs; the per-level trace in gb_bench is the debugging tool.
- FastSV (LG_CC_FastSV7.c) is M24 material: components via min-semiring hooking — read after this topic settles.
- The “optional AT” API design transfers directly: FalkorDB always HAS the transpose (delta trio) — so pull is always on the menu, unlike LAGraph’s caller-supplied AT.
Questions for notes.md
- Read the switch block (:243-292) and list every input the heuristic consumes. Which are O(1) to maintain and which need a reduction over the frontier (degree sum — GrB_reduce on a masked degree vector)?
- Why does the template keep BOTH
qsparse and the visitedmaskas a full vector — what format does q take at the peak level (SuiteSparse auto-switches it to bitmap — verify via GxB_print in a scratch C program, or reason from the conform rules)? - Sandia_LUT uses LU’ with U’=L — so it’s LL with the SECOND
operand transposed, turning saxpy into dot. Spell out why dot3
- lower-triangular mask visits each wedge exactly once.
- PageRankGAP vs textbook PR: what does prescaling d/damping save per iteration (one eWise divide over n), and why is the important-teleport handled as scalar assign not vector add?
- For M20: our engine’s BFS needs parent AND level variants. Which semiring per variant (ANY_SECONDI vs ANY_PAIR + level assign), and what does each move per level (indices vs nothing — iso!)?
References
Code
- LAGraph
src/algorithm/—template/LG_BreadthFirstSearch_SSGrB_template.c(the whole Beamer paper in ~70 lines),LAGr_TriangleCount.c(:31-46 lists all six masked formulations),LAGr_PageRankGAP.c,LG_CC_FastSV7.c(M24 material — read later)
Plan the work or steal it: SuiteSparse’s OpenMP vs rayon
Two philosophies of parallelizing the same sparse multiply.
SuiteSparse costs the work up front (a flopcount pre-pass) and
slices it statically into OpenMP tasks; rayon skips the cost model
and lets idle threads steal halves at runtime. M20’s kernels must
pick a side per kernel, so this chapter reads both schedulers —
saxpy3’s slicing code and rayon’s join — as answers to the same
skewed-row load-balance problem.
Two answers to “who does which slice of the multiply?”
SuiteSparse (plan first, execute statically):
flopcount pass ──► total_flops, per-column flops
│ (GB_AxB_saxpy3_flopcount.c:80; itself
│ parallel: omp schedule(dynamic,1) at :219)
▼
nthreads = GB_nthreads(total_flops, chunk, nthreads_max)
│ (slice_balanced.c:418 — tiny job ⇒ 1 thread)
▼
slice B into tasks, balanced by flops (:434, :456)
coarse task: one thread owns whole columns of B
fine task: a TEAM splits one fat column; Gustavson
workspace shared, atomics coordinate
▼
#pragma omp parallel — every thread grabs its task list
rayon (split lazily, steal dynamically):
par_iter over rows ──► join(left, right) (join/mod.rs:93)
caller runs left inline, pushes right onto ITS deque (:115)
idle worker steals right (registry.rs:248, Stealer)
each stolen half splits again — recursion IS the scheduler
Same problem — power-law column weights mean equal-column-count slices are wildly unbalanced — solved with a cost model in one world and with theft in the other.
rayon’s entire scheduler contract fits in one function:
#![allow(unused)]
fn main() {
// join: run `left` inline, PUBLISH `right` for theft — recursion is the scheduler
fn join<A, B>(left: A, right: B) {
let pending = my_deque.push(right); // ~free if no thread is idle
left(); // the caller does real work NOW
match my_deque.pop(pending) {
Some(right) => right(), // nobody stole it — run it inline
None => {
// an idle worker took `right`; don't block — steal OTHER
// work until it finishes (skewed halves rebalance themselves)
steal_until_done(pending);
}
}
}
// vs saxpy3: nthreads = f(total_flops, chunk); tasks pre-sliced by flops —
// the schedule is COSTED like a query plan, then frozen
}
What to read, in order
GB_AxB_saxpy3.c:22-48— the header comment is a scheduling essay: coarse/fine taxonomy, Gustavson-vs-hash per task.GB_AxB_saxpy3_slice_balanced.c:309(entry), :418 (nthreads from flops), :456 (target task size). Note what is not here: no dynamic load balancing at execution time.GB_AxB_saxpy3_flopcount.c:80— exact flops per column of B, cheap because it only walks pattern, not values.- rayon
join/mod.rs:93-140—join_context: inline + push + steal-back. The “potential parallelism” framing:joincosts ~nothing when no thread is idle. registry.rs:10-60, :248— oneWorkerdeque per thread,Stealerhandles crossed between them; the sleep/wake protocol is why idle rayon threads don’t spin.
The trade in one table
| axis | static (SuiteSparse) | stealing (rayon) |
|---|---|---|
| needs a cost model | yes (flopcount) | no |
| skew response | pre-balanced or fine-task atomics | automatic |
| per-task overhead | ~zero at runtime | deque push + potential steal |
| determinism of schedule | high | none |
| lines of scheduler code you own | many | zero (but tune min_len) |
Questions
- saxpy3’s flopcount pass costs O(nnz(B) + flops-pattern-walk) before any multiply happens. For which matrix shapes is that pre-pass a bad deal, and what does rayon do instead of paying it?
- Fine tasks share one Gustavson workspace with atomics. What is the rayon-idiomatic equivalent for one fat row — and why does “split the row, each half gets its own SPA, merge after” change the memory bill?
GB_nthreads(work, chunk, nthreads_max)returns 1 for small work. Write the rayon equivalent — where doeswith_min_lengo, and what happens if you omit it on a 1000×1000 multiply with 5K nonzeros?- Work-stealing is nondeterministic: two runs assign rows to threads differently. Which GraphBLAS semirings make that visible in the OUTPUT (hint: floating-point ⊕), and how does SuiteSparse’s static schedule sidestep the question?
- rustgraphblas-style FFI bindings inherit SuiteSparse’s OpenMP pool; your Rust process also has a rayon pool. What goes wrong when both are sized to num_cpus and a rayon task calls GrB_mxm?
- M20 mapping: pick the M20 kernel list (SpMV, SpMSpV, masked dot-SpGEMM, delta_mxm fold). For each, decide: par_iter over what axis, does it need a flopcount-style pre-pass, and who owns the workspace? Write the four decisions in notes.md — that’s the checklist item.
References
Code
- SuiteSparse:GraphBLAS
Source/mxm/GB_AxB_saxpy3.c(the header comment is a scheduling essay),GB_AxB_saxpy3_slice_balanced.c,GB_AxB_saxpy3_flopcount.c - rayon
rayon-core/src/join/mod.rs(:93join_context),rayon-core/src/registry.rs(:248 — one deque +Stealerper worker)
Inside SuiteSparse: format switching and the saxpy3 scheduler
The code walk behind the TOMS papers
(reading-davis-toms19.md): where format
switches are decided, and the saxpy3 scheduler — the most
database-executor-like piece of code in the library. Everything
below is anchored into Source/ of the SuiteSparse:GraphBLAS repo.
Anchor map
| anchor | what it is |
|---|---|
| Source/convert/GB_convert_sparse_to_bitmap_test.c:32-38 | THE bitmap heuristic: nnz > bitmap_switch * nnz_dense |
| Source/convert/GB_conform_hyper.c:52 | hyper→sparse test via hyper_switch |
| Source/convert/GB_conform.c:33-89 | conform runs after every op; sparsity_control bitmask |
| Source/mxm/GB_AxB_meta.c | engine dispatch (dot vs saxpy vs saxbit) |
| Source/mxm/GB_AxB_saxpy3.c:22-60 | coarse/fine tasks × Gustavson/hash — read this header comment twice |
| Source/mxm/GB_AxB_saxpy3.c:57 | hash > m/16 ⇒ fall back to Gustavson |
| Source/mxm/GB_AxB_saxpy3_flopcount.c | the sizing pre-pass |
| Source/mxm/GB_AxB_dot3.c:2-10 | C<M>=A'*B — mask REQUIRED, work ∝ nnz(M) |
| Source/mxm/GB_AxB_dot2.c / dot4.c | unmasked / C+=A’*B dense-output variants |
1. Format switching is a bitmask + two floats
Every matrix carries sparsity_control (which formats are ALLOWED)
plus hyper_switch/bitmap_switch (when to move). GB_conform
(GB_conform.c) runs at the end of operations:
allowed? test go to
bitmap nnz > bitmap_switch × n×m (:32-38) bitmap
sparse bitmap_to_sparse_test (reverse, with sparse
hysteresis — thresholds differ so it
doesn't ping-pong)
hyper #non-empty vectors vs hyper_switch hyper/sparse
(GB_conform_hyper.c:52)
Hysteresis is the lesson: switch-up and switch-down thresholds differ, like topic 3’s LSM compaction triggers. FalkorDB pins relation matrices hypersparse+sparse via GxB_set — find where (question 1).
2. saxpy3 — a query scheduler in one file
The header comment (GB_AxB_saxpy3.c:22-60) describes a two-level work division that IS morsel-driven parallelism (topic 11):
B's vectors (columns) → tasks:
coarse task: owns ≥1 whole vectors, private workspace
fine task: teams up on ONE big vector (a hub column),
shares workspace, needs atomics
each task independently picks its accumulator:
Gustavson: dense f64[m] + pattern marker ("SPA") — O(1) scatter,
wins when the column's flops fill enough of m
hash: open-addressing table 2×pow2(flops-estimate) — wins
when m is huge and the column is sparse
rule: hash size would exceed m/16 ⇒ just use Gustavson (:57)
The flopcount pre-pass (saxpy3_flopcount.c) computes per-column flops BEFORE allocating — exact same two-phase as cudf’s size/retrieve (topic 18) and Gunrock’s degree-scan. Sparse output size is the recurring villain of this whole curriculum.
3. dot3 — the mask as the driver
dot3 (GB_AxB_dot3.c) requires the mask and iterates over M’s entries: for each (i,j) ∈ M, compute A(:,i)’·B(:,j). Work is nnz(M) dot products — if M is a triangle-counting L, that’s one dot per candidate wedge. The mask isn’t a filter; it’s the OUTER LOOP. Contrast saxpy3, where the mask only prunes writes. Dispatch between them (GB_AxB_meta.c) weighs nnz(M) vs predicted saxpy flops — a cost-based optimizer decision (topic 10) made per multiply.
#![allow(unused)]
fn main() {
// dot3: the mask M is the outer loop — work ∝ nnz(M), a complexity
// CLASS below computing A'*B and filtering afterward
fn dot3(m: &Pattern, a_t: &Csr, b: &Csc, semiring: &Semiring) -> Coo {
let mut c = Coo::new();
for (i, j) in m.entries() { // one dot per MASK entry
// sparse dot = two-pointer intersect of the two patterns
if let Some(v) = sparse_dot(a_t.row(i), b.col(j), semiring) {
c.push(i, j, v); // (ANY monoid ⇒ sparse_dot
} // may stop at first hit)
}
c
}
}
4. What transfers to M20
- Our stub SpGEMM = one coarse Gustavson task (dense SPA). The HashMap reference = the hash task. gb_bench measures the m/16 intuition directly.
- Masked-SpMV pull BFS = dot3’s idea specialized: iterate the UNVISITED set (the mask), early-exit each dot at first frontier hit (ANY monoid ⇒ short-circuit legal).
- M20’s kernel core needs only: saxpy-SpMSpV (push), masked dot-SpMV (pull), one SPA SpGEMM, conform-lite (hyper↔sparse).
Questions for notes.md
- Find FalkorDB’s GxB_set calls pinning formats (grep GxB_SPARSITY in ~/repos/FalkorDB/src). Which matrices allow bitmap and why not the adjacency ones?
- Why does a fine Gustavson task need atomics on the SPA but a coarse one doesn’t — and what’s the topic 11 analogue (shared hash aggregation vs per-thread pre-aggregation)?
- The hash task’s table is sized 2× next-pow2(estimated flops). What happens on underestimate (collision pile-up — degrade, or rebuild? find it in GB_AxB_saxpy3.c) — compare SwissTable’s resize story (topic 8).
- dot3 vs saxpy3 crossover: for
C<L>=L*U'triangle counting on an RMAT graph, estimate both costs (nnz(L) dots of avg length d̄ vs Σ flops) — which wins and why does LAGraph still offer both (LAGr_TriangleCount.c:31-46)? - Run gb_bench: at what RMAT scale does our dense-SPA Gustavson lose to the HashMap version (SPA = m×8B cold bytes per row team — when m outgrows L2, topic 13’s blocking argument bites)?
References
Papers
- Davis — “Algorithm 1000: SuiteSparse:GraphBLAS” (ACM TOMS 2019) — the companion paper; see reading-davis-toms19.md
Code
- SuiteSparse:GraphBLAS
Source/convert/GB_conform.c,GB_conform_hyper.c,GB_convert_sparse_to_bitmap_test.c;Source/mxm/GB_AxB_meta.c,GB_AxB_saxpy3.c,GB_AxB_saxpy3_flopcount.c,GB_AxB_dot3.c— read the saxpy3 header comment (:22-60) twice; it’s the scheduler spec
Topic 20 notes — sparse linear algebra & GraphBLAS internals
Baseline (provided kernels, Apple M3 Pro, measured 2026-07-10)
gb_bench, RMAT edge_factor 8, best-of-N.
SpMV (PLUS,TIMES) — the bandwidth story
| scale | n | nnz | µs | GB/s |
|---|---|---|---|---|
| 14 | 16K | 120K | 146 | 19.1 |
| 16 | 65K | 495K | 617 | 18.6 |
| 18 | 262K | 2.0M | 2547 | 18.3 |
| 20 | 1.05M | 8.2M | 11958 | 15.8 |
~16-19 GB/s single-thread — below the ~30 GB/s streaming baseline (topic 0/13) because the x-gathers are random: RMAT colidx sprays across the vector. Slight decay with scale = x outgrowing L2/LLC.
SpGEMM C=A*A — hash reference (SPA stub pending)
| scale | nnz(A) | flops | nnz(C) | hash ms | Mflop/s |
|---|---|---|---|---|---|
| 10 | 6.7K | 298K | 142K | 3.9 | 76 |
| 12 | 28.7K | 2.27M | 1.14M | 33.0 | 69 |
| 14 | 120K | 17.1M | 8.9M | 279.4 | 61 |
~60-75 Mflops/s — HashMap entry ≈ 15 ns/flop: hashing + probe + per-row alloc/sort dominate. Note compression ratio flops/nnz(C) ≈ 2: RMAT A² produces mostly-distinct pairs, so the accumulator rarely accumulates (hash’s worst case, SPA’s too).
BFS scalar oracle
rmat18 3308 µs (2M edges ⇒ ~1.6 ns/edge), uniform-256K 6446 µs, path-100K 2041 µs (~20 ns/hop — pure dependent-load latency, no parallelism available: topic 13’s pointer chase).
Hypersparse — the headline
10M-node id space, 100K edges: index bytes 80.4 MB CSR vs 1.59 MB hyper (50×); full sweep 11312 µs vs 66 µs (171×) — iterating the id space vs iterating what exists. This is why every FalkorDB relation matrix is hypersparse.
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| SPA vs hash at scale 14 (SPA array = 16K×12B, fits L2) — speedup ×? | ||
| SPA at scale 20 (1M×12B = 12 MB SPA, out of L2) — still wins? | ||
| push-BFS edge checks on rmat18 vs scalar’s nnz — ratio | ||
| diropt on rmat18: which levels flip to pull, checks saved ×? | ||
| diropt on uniform graph — does pull trigger at all? | ||
| pull-only on path-100K — catastrophic by ×? (n probes per level) |
Implementation log
- spgemm_spa (symbolic+numeric, stamp-marked SPA) — test green
- bfs_push / bfs_pull / bfs_diropt — tests green incl. path-stays-push
- per-level trace analyzed vs LAGraph α/β thresholds
- stretch: masked SpGEMM (triangle count
C<L>=L*L) — dot vs saxpy - prediction table reconciled
Surprises / dead ends:
Questions from the reading guides
Davis TOMS ’19/’23 (reading-davis-toms19.md)
- GrB objects ↔ executor concepts mapping:
- Why FalkorDB needs own deltas over zombies/pending:
- Which FalkorDB matrices are iso; cost of losing iso:
- BFS step through v2 machinery (engine + JIT specialization):
- 32-bit index memory math v9 vs v10:
SuiteSparse internals (reading-suitesparse-internals.md)
- FalkorDB’s GxB_set sparsity pins:
- Fine-task atomics vs coarse — topic 11 analogue:
- Hash-task underestimate handling vs SwissTable resize:
- dot3 vs saxpy3 cost estimate for triangle counting:
- SPA-vs-hash crossover scale measured:
Gustavson + survey (reading-gustavson-spgemm.md)
- Why flops = Σ nnz(B(k,:)) is the lower bound; ANY short-circuit:
- SPA-vs-hash crossover from cache numbers:
- When guess-and-grow beats symbolic+numeric:
- Outer-product = LSM runs + merge:
- Masked saxpy vs masked dot on
C<L>=L*L:
Beamer SC ’12 (reading-beamer-sc12.md)
- Measured wasted-check fraction at peak level:
- Why early exit needs ANY/OR; which semirings break:
- Road vs RMAT pull prediction + trace check:
- Which FalkorDB query shapes need AT besides pull-BFS:
- Why LAGraph β2=512 vs paper’s 24:
LAGraph (reading-lagraph.md)
- Switch-heuristic inputs and their maintenance cost:
- Frontier format at peak level (conform reasoning):
- dot3 + tril mask visits each wedge once — spell out:
- PageRankGAP prescaling savings:
- M20 semiring per BFS variant (parent vs level):
FalkorDB delta matrices (reading-falkordb-delta-matrix.md)
- 4×2 case table verified against set/remove code:
- Transposed-twin write cost; lazy-rebuild breakage:
- delta_mxm over-masking counterexample + caller compensation:
- Sync thresholds ↔ LSM L0 triggers:
- DP/DM representation for M20 (COO+sort vs HashMap) — bench:
Cross-topic threads
- SpMV’s 16-19 GB/s vs sum’s 30+ GB/s (topic 0): the gather tax — same lesson as hash probing (topic 8) and pointer chasing (13).
- saxpy3 flopcount pre-pass = cudf size/retrieve (18) = Gustavson symbolic phase (1978): sparse output size, the eternal villain.
- Delta matrices = LSM (3): memtable=DP, tombstones=DM, wait=minor compaction, delta_mxm=read-path merge.
- Direction switch = three-level dispatch: algorithm (push/pull), engine (saxpy/dot), format (sparse/bitmap vector) — SuiteSparse makes the same call at each layer.
- ANY_SECONDI’s benign nondeterminism = Gunrock’s lost-CAS (18) = the ANY monoid making races algebra.
M20 log (capstone)
- kernel core: CSR+hypersparse, SpMV/SpMSpV, masked dot-SpGEMM subset, semirings (ANY,PAIR)/(PLUS,TIMES)/(MIN,PLUS)
- delta trio + transposed twin + wait + delta_mxm fold over it
- LDBC bench vs reference graphblas layer; BFS switch parity
Done when
- All 4 stub tests green; SPA-vs-hash + BFS traces + wasted-check numbers in tables; prediction table reconciled; guide questions answered; M20 kernel-core design sketched from the measurements.
Topic 21 — Formal Methods & Verification
Testing (topic 16) finds bugs you imagined; formal methods find the ones you didn’t. This topic covers the three tools that actually get used in databases — TLA+ (model checking protocols), e-graphs (equality saturation for optimizers), SMT (Z3) — plus Lean 4 for the proof end of the spectrum.
what it checks effort used by
fuzz/PBT (16) behaviors you generate hours everyone
TLA+ / TLC ALL behaviors of a days AWS, MongoDB,
finite model CockroachDB
SMT (Z3) one logical formula mins query verifiers
(validity/satisfiab.) (Cosette), symex
Lean 4 proof the actual theorem, weeks seL4-style kernels,
unbounded mathlib
graph LR
SAT["SAT solver<br/>(CDCL)"] --> SMT["SMT = SAT + theories<br/>DPLL(T), Z3"]
SMT --> EM["e-matching over an<br/>e-graph (congruence closure)"]
EM -.same data structure.-> EGG["egg: equality saturation<br/>(rewrite ALL ways, then pick)"]
EGG --> OPT["query optimizers<br/>M21 rewrite stage"]
TLA["TLA+ spec"] --> TLC["TLC model checker<br/>(BFS over states)"]
TLC --> PROTO["replication / MVCC<br/>protocol checking"]
LEAN["Lean 4"] --> PROOF["machine-checked proof<br/>(unbounded, forever)"]
1. E-graphs: the data structure
An e-graph = union-find over e-classes + hashcons (memo) + congruence
closure. It stores a set of terms closed under equivalence,
compactly: 2*x and x<<1 live in the same e-class, and every
parent of that class automatically has both forms.
e-class {a*2, a<<1} union-find: id → canonical id
/ \ hashcons: node → e-class id
e-class{a} e-class{2,1<<0?} (topic 8's hash table, again)
Three ops (egg src/egraph.rs):
add(:970) — hashcons hit or new singleton classunion(:1147) — union-find merge; repair is DEFERREDrebuild(:1416) — restore congruence invariant in one batched pass (process_unions:1346 re-canonicalizes and re-unions until fixpoint). Deferring this is egg’s headline contribution — the same amortize-the-repair move as delta matrices’wait(topic 20) and LSM compaction (topic 4).
2. Equality saturation vs hand-ordered rules
Topic 10’s optimizer applies rules in a fixed order, destructively. Order is a silent correctness-of-outcome bug:
(a*2)/2
hand (ordered): strength-reduce FIRST → (a<<1)/2 … stuck, cost 5
egg (saturate): keep BOTH forms; (x*y)/z→x*(y/z) still matches
→ a*(2/2) → a*1 → a, cost 1
graph TD
A["with_expr: seed e-graph"] --> B["match ALL rules<br/>(machine.rs VM)"]
B --> C["apply: add + union<br/>(no destruction)"]
C --> D["rebuild (deferred<br/>congruence repair)"]
D --> E{"saturated? limits?<br/>run.rs StopReason"}
E -->|no| B
E -->|yes| F["Extractor + CostFunction<br/>pick cheapest term"]
The catch: the e-graph can blow up (assoc+comm rules alone are
exponential), so Runner has node/iter/time limits — saturation is
best-effort, extraction is greedy per-class. This is a search
budget, the same shape as topic 10’s join-order DP cutoff.
3. TLA+ — spec the scary parts
specs/WalReplication.tla models topic 15’s WAL shipping: sequential
entries, prefix logs (so a log is just a length), quorum commit,
crash, longest-log failover. TLC exhaustively checks every
interleaving of a 3-replica, 3-entry model:
| config | states (distinct) | result |
|---|---|---|
SyncCommit = TRUE | 2583 (1080), depth 14 | Durability holds |
SyncCommit = FALSE | 123 checked | violated at depth 5 |
The counterexample TLC prints is the exact PostgreSQL
synchronous_commit = off data-loss story: Append → Commit (no
quorum) → Crash(primary) → Failover(empty log) — committed=1, new
primary has nothing. Five states. No test generator finds this
guaranteed; TLC does, in under a second.
Run it: java -cp ~/repos/tla2tools.jar tlc2.TLC -deadlock WalReplication.tla (flip SyncCommit in the .cfg to see the trace).
4. Z3 — SMT in one paragraph
CDCL SAT core + theory solvers (linear arithmetic, arrays,
uninterpreted functions) cooperating via DPLL(T); quantifiers via
e-matching over a congruence-closure e-graph — the same structure
as egg, built for search instead of rewriting. Z3’s modern e-graph
(src/ast/euf/euf_egraph.h:23) literally cites egg’s deferred
congruence repair. Databases meet Z3 in query equivalence checking
(Cosette, topic 16) and symbolic execution of UDFs.
5. Lean 4 — proofs, and a runtime worth reading
Proofs are unbounded (no MaxLog=3), but cost weeks not days. Lean’s
own runtime is a systems story: Perceus reference counting with
reuse tokens gives functional-but-in-place updates — an RC design
directly relevant to any Rust engine tempted by Arc everywhere.
M21 taste: prove one delta-matrix invariant (DP ∩ M = ∅ preserved
by set/remove) in Lean, and compare with the same property as a
proptest (topic 16).
Reading guides
- reading-aws-cacm15.md — Why AWS writes TLA+: exhaustively testable pseudo-code
- reading-egg-popl21.md — egg: equality saturation with deferred rebuilding
- reading-z3-tacas08.md — Z3: SAT plus theories, with an e-graph at the core
- reading-tlaplus-raft.md — A spec is a state machine: TLA+ through raft.tla
- reading-lean-perceus.md — Perceus: reference counting precise enough to reuse memory
Experiments
| file | status | what it shows |
|---|---|---|
expr.rs | provided | tiny expression IR + AstSize cost + random gen |
hand.rs | provided | ordered fixpoint rewriter with the R2-before-R4 trap |
eqsat.rs | stub | egg_optimize — saturate, extract, beat the trap |
bin/eqsat_bench.rs | provided | trap case + depth sweep, hand vs egg lanes |
specs/WalReplication.tla | provided | quorum-commit WAL replication, TLC-checked |
M21 checklist
- TLA+ spec of capstone MVCC visibility (or reuse WalReplication
for the replication layer) checked by TLC in CI (a
java -cp tla2tools.jarstep — seconds at model scale) - Lean proof of one delta-matrix invariant
- optional: egg-based rewrite stage in the planner, budgeted (node limit) and gated like topic 19’s JIT threshold
Why AWS writes TLA+: exhaustively testable pseudo-code
The CACM 2015 experience report that moved TLA+ from academia to industrial default for distributed protocols. Read it for the economics, not the math: what class of bug justifies days of spec-writing — and what a spec still can’t do for you. It frames every other chapter in this topic.
The core claims
- Human intuition fails at ~35 steps. S3’s replication bug needed a 35-step interleaving to trigger; design reviews, code review, and testing all missed it. TLC found it because exhaustive breadth-first search doesn’t get bored.
- Specs are cheap relative to the bug. 2-3 weeks to first useful spec; DynamoDB’s spec was ~1000 lines and found 3 design bugs pre-implementation, one requiring a fundamental change.
- “Exhaustively testable pseudo-code” — the internal pitch that worked. Not “formal verification”: engineers write the spec as the design doc, then get model checking for free.
- Model small, learn big. Checking 3 replicas × 3 entries (like our WalReplication) is not a proof — but protocol bugs are almost never “only at N=7”; small-scope hypothesis.
spec size vs payoff (paper's table, paraphrased)
S3 repl. ~800 lines 2 design bugs, one 35-step
DynamoDB ~1000 lines 3 design bugs pre-impl
EBS ~450 lines design confirmed (also a win)
What TLA+ did NOT do for them
- No liveness in practice (they check safety; liveness is expensive and fairness assumptions are subtle).
- No code conformance — the spec and the C++ can drift. (MongoDB later attacked this with spec-driven test generation.)
- No performance modeling.
Questions (answer in notes.md)
- Which capstone protocol clears the paper’s cost/benefit bar for a
spec — MVCC visibility, delta-matrix
waitconcurrency, or WAL replication — and which is fine with proptest alone (topic 16)? - The 35-step bug: what makes an interleaving reachable-but-rare? Relate to why our SyncCommit=FALSE trace is only 5 steps (the model has no noise to wade through).
- “Exhaustively testable pseudo-code”: how is a TLA+
Nextaction different from a proptest state-machine transition (topic 16)? What does TLC explore that proptest samples? - Why does the small-scope hypothesis hold for protocols but NOT for, say, B+tree split bugs (topic 3) that need page-full edge cases?
- Spec-code drift: sketch how the capstone’s CI could keep WalReplication.tla honest against the real replication code.
References
Papers
- Newcombe, Rath, Zhang, Munteanu, Brooker, Deroche — “How Amazon Web Services Uses Formal Methods” (CACM 2015) — short; read all of it, the sidebar tables carry the economics
egg: equality saturation with deferred rebuilding
egg is the e-graph library behind a wave of optimizer research —
and behind our eqsat.rs stub. Its POPL 2021 paper makes two
contributions worth reading the source for: deferred rebuilding
(batch congruence repair instead of fixing invariants after every
union) and e-class analyses (attach lattice facts like constant
values to classes). The src/ tree is ~10K lines and half of that
is explain.rs/tests — you can read the core tonight.
The data structure, bottom-up
| file:line | what |
|---|---|
unionfind.rs:30/:37/:47 | find (path-compressing in find_mut), union — 60 lines, the whole thing |
egraph.rs:66 | memo: HashMap<L, Id> — the hashcons; canonical only after rebuild |
egraph.rs:970 | EGraph::add — canonicalize children, memo lookup-or-insert |
egraph.rs:1147 | EGraph::union — merge classes, push parents onto pending (:69) |
egraph.rs:1346 | process_unions — drain pending: re-canonicalize node, re-insert into memo; a collision is a discovered congruence → recursive union |
egraph.rs:1416 | rebuild — the public batched-repair entry point |
machine.rs:8/:24 | pattern matching compiled to a tiny VM: Bind/Scan/Compare instructions over the e-graph |
run.rs:138/:161/:237 | Runner, RunnerLimits (iter/node/time), StopReason |
extract.rs:41/:116/:157/:225 | Extractor, CostFunction, AstSize, find_best (fixpoint of per-class best-cost) |
Deferred rebuilding — the headline
Classic congruence closure (and old eqsat engines) restores the
invariant after EVERY union. egg lets the e-graph go stale during a
batch of rule applications, then rebuild() repairs once:
per-union repair: union → fix parents → fix grandparents → …
egg: union, union, union, … → rebuild (dedup work:
a class touched 10× is repaired once)
#![allow(unused)]
fn main() {
fn union(&mut self, a: Id, b: Id) {
let root = self.unionfind.union(a, b); // O(α) — and STOP:
self.pending.extend(self.classes[&root].parents()); // repair deferred
}
fn rebuild(&mut self) {
while let Some((node, class)) = self.pending.pop() {
let node = node.canonicalize(&self.unionfind); // re-canon children
if let Some(old) = self.memo.insert(node, class) {
// hashcons collision = two nodes became equal children-wise:
// a DISCOVERED congruence — union them, which refills pending
self.union(old, class); // hence: loop to fixpoint
}
}
}
}
Paper reports up to 88× from this alone. It is exactly the
delta-matrix wait (topic 20) / LSM memtable flush (topic 4) move:
make mutation O(1) by batching the expensive invariant restoration.
Z3’s new e-graph adopted it (euf_egraph.h:23 cites egg).
E-class analyses
A lattice value per e-class, maintained through merges
(analysis_pending, egraph.rs:70; N::remake/merge in
process_unions). The canonical one: constant folding — class
carries Option<i64>; when it becomes Some, modify adds the
literal node, and extraction gets it for free. Our stub sidesteps
this ((/ 2 2) folds via the div-same rule), but M21’s planner
stage would carry cardinality estimates as the analysis — topic
10’s estimate() as a lattice.
Extraction is the weak spot
find_best is greedy per e-class — optimal for tree cost like
AstSize, NOT optimal with sharing (DAG cost). lp_extract.rs does
ILP extraction for that. Planner analogy: greedy extraction ≈
picking the cheapest subplan per group in a memo — which is exactly
what a Cascades optimizer does, and e-graph ≈ Cascades memo
discovered independently.
Questions (answer in notes.md)
- Trace
(a*2)/2by hand: which unions happen in iteration 1, and in which e-class do(/ 2 2)and1meet? - Why must
memore-canonicalization happen in a loop (a repair can create a new collision)? Find the fixpoint inprocess_unions(:1346). machine.rs: what doesScancost when a pattern’s root op has thousands of e-nodes? Relate toclasses_by_op(:81).- Assoc+comm on
+alone: estimate e-graph growth per iteration on a depth-8 sum. WhichRunnerLimittrips first (predict, then measure in the stub)? - Cascades memo vs e-graph: what does Cascades have that egg lacks (physical properties, promises), and vice versa (congruence)?
References
Papers
- Willsey, Nandi, Wang, Flatt, Tatlock, Panchekha — “egg: Fast and Extensible Equality Saturation” (POPL 2021, arXiv:2004.03082) — §2 is the best e-graph intro in print; §3 deferred rebuilding, §4 analyses
Code
- egg
src/unionfind.rs,src/egraph.rs(add :970, union :1147, process_unions :1346, rebuild :1416),src/machine.rs,src/run.rs,src/extract.rs— read fully; skipexplain.rson the first pass
Perceus: reference counting precise enough to reuse memory
How does a pure functional language (Lean 4, Koka) get in-place
update performance? Two compiler passes — borrow inference and
reuse tokens — make reference counting precise enough that copying
mostly disappears. Read the two runtime papers as a systems
story: they explain why Lean 4 is fast enough to be the M21 proof
target, and what Arc-everywhere Rust engines leave on the table.
The problem
Pure FP = every update copies. Naive RC = inc/dec traffic on every
pointer move (the Arc<T> tax, topic 2/9: contended atomics).
GC = throughput but latency + no destructive reuse. Lean’s compiler
(Immutable Beans) and Koka’s (Perceus) make RC precise enough that
copying mostly disappears.
The two ideas
1. Borrowed vs owned parameters (Beans). The compiler infers
which parameters a function merely inspects (borrowed — no RC ops)
vs consumes (owned — caller transfers the reference). Exactly
Rust’s &T vs T, inferred instead of written. Result: most
inc/dec pairs vanish.
2. Reuse tokens / functional-but-in-place (both papers). When a value’s count is 1 at its last use, its memory is handed to the constructor about to be allocated:
match xs with
| Cons x rest => Cons (f x) (map f rest)
│ │
└─ if RC(xs)==1 ─┘ reuse xs's cell in place: map becomes
an in-place loop, zero allocation
Perceus refines this to garbage-free RC: a reference is dropped at the exact last use (precise liveness), so peak memory equals live data — no GC headroom.
naive RC: inc on copy, dec on scope exit (chatty, atomic)
Beans: borrow inference kills most pairs
Perceus: drop-at-last-use + reuse ⇒ uniqueness typing effect
without the type system
What the compiler actually emits for map, in Rust-ish form:
#![allow(unused)]
fn main() {
fn map(f: &Closure, xs: Ptr<Cons>) -> Ptr<Cons> {
if rc(xs) == 1 {
// reuse token: we are the only owner — xs's cell is handed
// to the Cons about to be built. map becomes an in-place loop.
xs.head = f.call(xs.head);
xs.tail = map(f, xs.tail);
xs // zero allocation
} else {
let out = alloc(Cons { head: f.call(xs.head), tail: map(f, xs.tail) });
dec(xs); // dropped at exact last use —
out // peak memory = live data
}
}
}
Why this is in a database curriculum
- The RC(1) fast path is delta-matrix thinking: mutate in place
when you’re the only owner, copy-on-write otherwise — it’s Redis’s
shared objects, FalkorDB’s tensor sharing, and
Arc::make_mutas a compiler pass. - Borrowed params = zero-cost read path: an executor passing
&Valuedown a pipeline (topic 11) is doing manual Beans. - Proof relevance: Lean’s kernel checks proofs by running terms; a fast runtime is why mathlib-scale proof search is viable, which is why Lean 4 (not Coq) is the M21 proof target.
M21 taste: the proof-vs-test trade-off
Property (topic 20): delta-matrix invariant DP ∩ M = ∅ ∧ DM ⊆ M
preserved by set/remove/wait.
- proptest (topic 16): minutes to write, samples the space.
- TLC: model M/DP/DM as small sets, exhaustive at n=4.
- Lean:
theorem set_preserves_inv : inv m → inv (set m i j)— unbounded, but you’ll spend a day on set-theory lemmas. Do it once to calibrate which properties deserve which tool.
Questions (answer in notes.md)
- Where exactly does
Arc<T>in a Rust engine pay costs that Beans-style borrow inference eliminates? (Think: clone in a hot loop vs&reborrow — topic 9’s contended counter.) - Reuse tokens require RC==1 checks at runtime. When does that branch cost more than it saves (small cells? shared-by-design structures like interned strings)?
- Perceus “garbage-free” claim: what does peak-memory = live-data buy a memory-budgeted buffer pool (topic 6) design?
- Lean proof vs TLC vs proptest for
DP ∩ M = ∅: rank by (cost to write, strength of guarantee, maintenance under refactor). - Koka’s effect types let Perceus assume no hidden aliasing. What’s
the moral equivalent in Rust that makes
Arc::make_mutsound?
References
Papers
- Ullrich, de Moura — “Counting Immutable Beans: Reference Counting Optimized for Purely Functional Programming” (IFL 2019, arXiv:1908.05647) — borrow inference + the first reuse story; this is Lean 4’s runtime
- Reinking, Xie, de Moura, Leijen — “Perceus: Garbage Free Reference Counting with Reuse” (PLDI 2021) — drop-at-last-use, the garbage-free claim, and the sharper reuse analysis
A spec is a state machine: TLA+ through raft.tla
TLA+ has one idea — describe your protocol as “which next-states
are allowed” and let TLC enumerate every interleaving. Lamport’s
Specifying Systems part I (chapters 1-7) teaches the language;
Ongaro’s published Raft spec (471 lines) shows what a real protocol
spec looks like. Read both against our specs/WalReplication.tla
(94 lines) — same genre, toy scale.
TLA+ mental model
A spec is a state machine described in math:
Spec == Init /\ [][Next]_vars
│ │
│ └─ every step satisfies Next (or stutters)
└─ initial-state predicate
Next == A1 \/ A2 \/ ∃ r ∈ S : A3(r) ← actions, primed vars
Invariant: a state predicate TLC checks on EVERY reachable state
No control flow, no processes — just “which next-states are allowed”. Concurrency falls out of the disjunction: TLC explores every interleaving of enabled actions. That’s the whole trick.
A real action, from our WalReplication.tla — an action is a predicate relating the current state to the primed next state:
\* WAL shipping: backup r pulls the next entry it is missing.
Ship(r) ==
/\ r # primary /\ r \notin crashed /\ primary \notin crashed
/\ wal[r] < wal[primary] \* enabled only when behind
/\ wal' = [wal EXCEPT ![r] = @ + 1] \* ONE entry per action —
/\ UNCHANGED <<primary, crashed, committed>> \* atomicity IS the model
Next ==
\/ Append
\/ Commit
\/ \E r \in Replicas : Ship(r) \/ Crash(r) \/ Failover(r)
\* THE invariant TLC checks on every reachable state:
Durability == primary \notin crashed => committed <= wal[primary]
raft.tla anchors
| line | what |
|---|---|
| :24 | message types incl. AppendEntriesRequest/Response |
| :155 | Init — everything empty, all followers |
| :204 | AppendEntries(i, j) — leader ships up to 1 entry per action (model-size discipline; same reason our Ship moves one entry) |
| :229 | BecomeLeader(i) — quorum of votes ⇒ leader |
| :327 | HandleAppendEntriesRequest — the consistency check: term + prevLogIndex/prevLogTerm match, else reject |
Notice what Raft needs that WalReplication doesn’t: terms and the log-matching check. Our model gets away without them because (a) entries are sequential integers shipped in order, so logs are prefixes by construction, and (b) crashes are permanent, so there is never a stale ex-primary that can come back and diverge the log. Un-model either assumption and you re-derive Raft piece by piece — a great exercise: allow crashed replicas to rejoin and watch TLC show you why terms exist.
Model-size discipline (why TLC finishes)
- Logs-as-lengths: our
wal ∈ [Replicas → 0..MaxLog]gives 4³ log states; raft.tla with real sequences and terms explodes — Ongaro notes it’s checked only for tiny bounds. - One entry per Ship/AppendEntries action: granularity of atomicity IS the model — batching would hide interleavings.
- Our measured runs: SyncCommit=TRUE → 1080 distinct states, depth 14, <1 s. SyncCommit=FALSE → violation at depth 5 after 123 states. Small models, real bugs.
Safety vs liveness
Everything above is safety (“nothing bad”). Liveness (“something
good eventually”) needs fairness: WF_vars(Ship(r)) — otherwise TLC
accepts the behavior where shipping just never runs. Raft’s spec
famously checks safety only; so does ours. Start there; liveness
doubles the conceptual load for a different class of bug (stuck
protocols, not corrupt ones).
Questions (answer in notes.md)
- Add
Rejoin(r)(crashed → alive, keeping its stale wal) to WalReplication. What new invariant is needed, and what trace does TLC find without it? (This re-derives Raft’s term check.) - Why does
Failoverneed “longest log among survivors” — exhibit the quorum-intersection argument for Quorum=2, |Replicas|=3, and the trace when failover picks an arbitrary survivor instead. - raft.tla:204 ships ≤1 entry per action. What bug class would a “ship everything atomically” model hide in OUR spec?
- Express topic 8’s MVCC snapshot-visibility as a TLA+ invariant sketch (what are the variables? what’s an action?) — this is the M21 deliverable’s outline.
[][Next]_varsallows stuttering. Why is that essential for refinement (mapping a detailed spec onto an abstract one)?
References
Papers
- Lamport — Specifying Systems (Addison-Wesley 2002) — part I, chapters 1-7; free PDF from Lamport’s site — the rest of the book is reference material
Code
- raft.tla
raft.tla— Ongaro’s published spec, 471 lines; anchors above specs/WalReplication.tla(this topic’s experiments) — the 94-line toy to read first
Z3: SAT plus theories, with an e-graph at the core
SMT is what turns “is this rewrite rule sound?” into a solver
query. This chapter reads de Moura & Bjørner’s 4-page TACAS 2008
tool paper — the architecture is the point — alongside Z3’s modern
e-graph in src/ast/euf/, which turns out to be egg’s data
structure (reading-egg-popl21.md) built
for search instead of rewriting.
SMT in one diagram
formula (QF or quantified)
│ simplify / tactics
▼
┌──────── CDCL SAT core ────────┐ boolean skeleton:
│ decide / propagate / learn │ p ∨ ¬q, p ≡ "x+y ≤ 3" …
└──────┬─────────────▲──────────┘
│ partial │ theory lemma
│ assignment │ (conflict clause)
▼ │
theory solvers: EUF (congruence closure e-graph),
linear arith (simplex), arrays, bit-vectors …
DPLL(T): SAT core proposes a boolean assignment; theory solvers check its conjunction of atoms; on conflict they hand back a lemma that prunes the SAT search. Theories cooperate by exchanging equalities over shared terms (Nelson-Oppen).
#![allow(unused)]
fn main() {
// DPLL(T): the SAT core proposes, the theory solvers dispose
fn smt_solve(mut clauses: Vec<Clause>, theories: &Theories) -> Result {
loop {
match sat_cdcl(&clauses) {
Unsat => return Unsat, // even the skeleton is out
Sat(assignment) => {
// the boolean skeleton says: these theory atoms hold
match theories.check(assignment.atoms()) {
Consistent(model) => return Sat(model),
Conflict(lemma) => clauses.push(lemma),
// the lemma ("¬(x≤3) ∨ ¬(x≥7)") prunes the SAT
// search — theory knowledge flows back as clauses
}
}
}
}
}
}
The e-graph, again — src/ast/euf/
| anchor | what |
|---|---|
euf_egraph.h:23 | comment: “same effect as delayed congruence table reconstruction from egg” — the 2021 paper flowing back into the 2008 solver |
euf_egraph.h:85 | class egraph |
euf_egraph.h:91-96 | to_merge queue (plain / commutativity / justified) — the pending-unions worklist, egg’s pending |
euf_enode.h | e-node: term + parents + root pointer |
euf_etable.h | the congruence table (hashcons keyed on canonicalized children) |
euf_justification.h | proof-producing unions — egg’s explain.rs counterpart; Z3 needs it for conflict lemmas |
Key difference from egg: Z3’s e-graph must support backtracking (SAT core undoes decisions ⇒ undo unions via a trail) and justifications (every merge must be explainable to build conflict clauses). egg only needs monotone growth + optional explanations. Same structure, different contract.
E-matching (quantifiers)
∀x. f(g(x)) = x becomes a trigger f(g(x)); e-matching finds
instantiations by matching the trigger against the e-graph modulo
equivalence (euf_mam.h — matching abstract machine ≈ egg’s
machine.rs, industrial strength). This is why quantified SMT is
incomplete-but-useful: instantiation is heuristic.
Where a database meets Z3
- Query equivalence (Cosette, topic 16): compile two SQL plans to formulas, ask Z3 if outputs can differ. UNSAT = equivalent.
- Constraint-based test generation: “give me a row that makes this WHERE clause true” is a SAT query.
- Optimizer rule soundness: our
x/x → 1caveat is checkable —assert x=0 ∧ rewrite-changes-result, SAT means unsound rule.
Questions (answer in notes.md)
- Why must Z3’s e-graph carry justifications while egg’s can skip them? What would proof-producing unions cost egg’s rebuild?
- The trail/backtracking requirement: why does deferred rebuilding
interact badly with undo, and how does
to_merge_t(:91) hint at the resolution? - Encode the
x/x → 1soundness check as an SMT query (ints, then reals). Which theory answers each? - Nelson-Oppen needs theories to agree on equalities of shared terms — spot the analogy to exchanging join keys between operators (topic 11).
- E-matching triggers: why is trigger selection the “index choice” problem of SMT (too general = blowup, too specific = incomplete)?
References
Papers
- de Moura, Bjørner — “Z3: An Efficient SMT Solver” (TACAS 2008) — 4 pages; read all of it for the architecture diagram
Code
- z3
src/ast/euf/—euf_egraph.h(:23 cites egg’s deferred repair, :91-96 theto_mergeworklist),euf_enode.h,euf_etable.h,euf_justification.h,euf_mam.h(e-matching abstract machine)
Topic 21 notes — formal methods & verification
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
Hand-ordered rewriter (eqsat_bench, 20 seeds/depth)
| depth | in cost | hand cost | µs/expr | firings |
|---|---|---|---|---|
| 4 | 31 | 21 | 5.4 | 4 |
| 6 | 127 | 90 | 25.2 | 15 |
| 8 | 511 | 355 | 129.7 | 61 |
| 10 | 2047 | 1390 | 534.8 | 248 |
~30% cost reduction, ~2 µs per rule firing (clone-heavy rebuild — each pass reallocates the whole tree). Linear in size, no search: this is the baseline egg must beat on quality while inevitably losing on time.
The ordering trap
(a*2)/2: hand rewriter → (a<<1)/2, cost 5 (1 firing,
18.8 µs). R2 (strength reduction) destroys the Mul before R4
(div-reassoc) can see it. egg keeps both forms → should reach a,
cost 1.
TLC on WalReplication.tla (tla2tools.jar, java 17)
| config | states generated | distinct | depth | result | time |
|---|---|---|---|---|---|
| SyncCommit=TRUE | 2583 | 1080 | 14 | Durability holds | <1 s |
| SyncCommit=FALSE | 183 | 123 | 5 | VIOLATED | <1 s |
The counterexample is 5 states: Append → Commit(no quorum) →
Crash(r1) → Failover(r2, empty log) ⇒ committed=1 > wal[r2]=0.
The postgres synchronous_commit=off data-loss story, found
exhaustively in under a second on a 3-replica/3-entry model.
Removing one conjunct (the quorum gate) flips 1080-states-safe to
counterexample-at-depth-5 — invariants are load-bearing.
Predictions (fill BEFORE implementing the stub)
| question | prediction | actual |
|---|---|---|
egg on trap: cost, iterations to find a | ||
| egg µs/expr at depth 6 vs hand’s 25 µs — slowdown ×? | ||
| e-graph enodes at depth 8 input (511 nodes) with comm rules | ||
| which StopReason at depth 10 with node_limit 10k | ||
| egg cost vs hand cost at depth 10 — how much better? | ||
| add assoc rules for + and *: still terminates under limits? |
Implementation log
- eqsat.rs egg_optimize — all 3 tests green (trap → cost 1)
- prediction table reconciled
- stretch: ConstantFold as an e-class Analysis (egg tutorial pattern) — replaces the div-same-folds-constants trick
- stretch: Rejoin(r) in WalReplication → find the stale-primary trace → re-derive terms (reading-tlaplus-raft.md Q1)
- stretch: DAG-cost extraction (lp_extract) vs greedy on a shared subexpression
Surprises / dead ends:
- TLC found the SyncCommit=FALSE violation after only 123 states — BFS means the shortest counterexample comes out first, which is why TLC traces are readable.
Questions from the reading guides
AWS CACM’15 (reading-aws-cacm15.md)
- Which capstone protocol clears the spec cost/benefit bar:
- 35-step vs our 5-step trace — reachable-but-rare:
- TLA+ Next action vs proptest state-machine transition:
- Small-scope hypothesis: protocols vs B+tree edge cases:
- Keeping spec and code honest in CI:
egg POPL’21 (reading-egg-popl21.md)
- Hand-trace (a*2)/2 unions; where (/ 2 2) meets 1:
- Why memo re-canonicalization loops to fixpoint:
- machine.rs Scan cost; classes_by_op index:
- Assoc+comm growth per iteration; which limit trips:
- Cascades memo vs e-graph — what each has the other lacks:
Z3 TACAS’08 (reading-z3-tacas08.md)
- Why Z3’s e-graph needs justifications, egg doesn’t:
- Deferred rebuild vs backtracking trail:
- x/x→1 soundness as SMT query (ints vs reals):
- Nelson-Oppen equality exchange ↔ join-key exchange:
- Trigger selection = index choice of SMT:
Specifying Systems + raft.tla (reading-tlaplus-raft.md)
- Rejoin(r) → what invariant breaks → why terms exist:
- Longest-log failover: quorum-intersection argument + bad trace:
- What “ship everything atomically” would hide:
- MVCC visibility as TLA+ sketch (M21 outline):
- Why stuttering is essential for refinement:
Beans + Perceus (reading-lean-perceus.md)
- Arc costs that borrow inference eliminates:
- When the RC==1 reuse check costs more than it saves:
- Garbage-free peak memory ↔ buffer pool budgets:
- Proof vs TLC vs proptest ranking for DP∩M=∅:
- Rust’s equivalent of Koka’s no-hidden-aliasing:
Cross-topic threads
- Deferred rebuild (egg) = delta-matrix wait (20) = LSM compaction (4): batch the invariant repair, amortize the fixup.
- E-graph = Cascades memo (10) + congruence; hand.rs IS topic 10’s push_down/reorder pipeline, now with a measured miss.
- e-graph hashcons = topic 8’s hash table; machine.rs pattern VM = topic 19’s bytecode interpreter (same Bind/Scan/Compare shape).
- Runner limits = topic 10’s DP cutoff = topic 19’s jit_above_cost: every search needs a budget gate.
- TLC exhaustive interleavings vs proptest sampling (16): same state machine, different quantifier (∀ vs ∃-sampled).
- Z3 backtracking trail = topic 8’s undo log; justifications = WAL for unions.
M21 log (capstone)
- TLA+ spec of MVCC visibility (or adapt WalReplication) + TLC in CI (java -cp tla2tools.jar, seconds at model scale)
- Lean 4 proof: delta-matrix invariant DP∩M=∅ preserved by set/remove — calibrate proof-vs-test cost
- optional egg rewrite stage in planner: node-limited Runner, cardinality estimates as e-class analysis
Done when
- eqsat stub green (trap → cost 1); prediction table reconciled;
- TLC both configs re-run and understood line-by-line; one stretch (Rejoin or ConstantFold analysis) attempted;
- guide questions answered; M21 spec outline drafted.
Topic 22 — Standard Benchmarks: TPC-H, TPC-C, YCSB, LDBC & Friends
Benchmarks are engineering tools only if you know what each query actually stresses. This topic maps the standard suites to their choke points, builds the two most-copied pieces of benchmark machinery (the YCSB Zipfian generator, TPC-H Q1/Q6), and sets up M22’s standing regression suite.
The map
OLTP ◄──────────────────────────► OLAP
TPC-C YCSB TATP SmallBank │ SSB TPC-H TPC-DS ClickBench
(txn (KV │ (star (choke (100+q, (one wide
contention) mixes×skew) │ schema) points) skew) table)
│
graph: LDBC SNB interactive (OLTP-ish) / BI (OLAP) / Graphalytics
vector: ann-benchmarks (recall vs QPS — topic 14)
real cardinalities: JOB (topic 10 — built because TPC-H is uniform)
graph TD
Q["a benchmark number"] --> W["workload: mix + distribution<br/>(YCSB: A-F × zipfian/uniform)"]
Q --> D["data: scale factor + skew +<br/>correlation (dbgen: none!)"]
Q --> H["harness: open vs closed loop,<br/>think times, warmup, driver cost"]
Q --> M["metric: tpmC? geomean?<br/>p999? GB/s? recall@10?"]
W & D & H & M --> V{"change ANY one<br/>⇒ different number"}
Choke points, one line each
- TPC-H Q1: tiny group domain ⇒ hash table free ⇒ pure
expression eval + fused agg (our
q1_flatmakes it explicit). - TPC-H Q6: 2%-selective scan ⇒ SIMD predicates, the “GB/s”
headline query (our
q6_branchless, topic 17’s filter shapes). - TPC-H Q9: 6-way join order + LIKE ‘%green%’ + skew — the optimizer punisher (topic 10).
- TPC-C: the D_NEXT_O_ID hot counter + 1% remote warehouses + think times nobody runs — contention by design, not throughput.
- YCSB: mix × distribution factoring; θ=0.99 zipfian is the whole personality (see the generator math in the reading guide).
- LDBC SNB: correlated power-law datagen + choke points for graphs (topic 13’s guide) — M22’s centerpiece.
Measured baselines (bench_suite, M3 Pro, single thread)
| lane | result |
|---|---|
| Q1 oracle (row-at-a-time HashMap) | SF 0.25: 10.2 ms, 5.6 GB/s effective |
| Q6 oracle (branchy scalar) | SF 0.25: 2.7 ms, 15.7 GB/s |
| YCSB uniform A/B/C/D/E/F | 2.88 / 4.15 / 3.72 / 4.40 / 1.11 / 2.85 Mops/s |
| YCSB E p50 | 917 ns — scans are 4× a point read, visible instantly |
Q6’s oracle at 15.7 GB/s is already half of memory bandwidth with branches — predict what branchless + autovec adds at this 2% selectivity before implementing (topic 17 says: maybe nothing!).
Benchmarking sins checklist (see topic 0’s reading-fair-benchmarking.md)
- closed-loop tails quoted as latency (coordinated omission)
- warm cache vs cold unstated; SF that fits in LLC
- “TPC-C” without think times ⇒ you measured a latch
- geomean over arithmetic mean (or vice versa) chosen post hoc
- comparing your tuned build vs their defaults (Fair Benchmarking’s core sin)
- uniform data standing in for skewed reality (dbgen’s lie; JOB’s reason to exist)
Reading guides
- reading-boncz-tpch.md — TPC-H decoded: 22 queries, 28 choke points
- reading-ycsb.md — YCSB: six mixes, five distributions, one Zipfian generator
- reading-oltpbench-tpcc.md — TPC-C: contention by design (and the harness that runs it honestly)
- reading-duckdb-tpch.md — dbgen as a table function: shipping a benchmark inside the engine
- topic 0:
reading-fair-benchmarking.md(DBTest ’18) — methodology - topic 13:
reading-ldbc-snb.md— SNB datagen + interactive driver - topic 10:
reading-leis-vldb15.mdanalogue — JOB, real cardinalities
Experiments
| file | status | what it shows |
|---|---|---|
lineitem.rs | provided | dbgen-lite columnar lineitem (SF×6M rows) |
tpch.rs oracles | provided | Q1 HashMap row-at-a-time, Q6 branchy scalar |
tpch.rs q1_flat/q6_branchless | stub | flat group array; mask-multiply scan |
zipf.rs Zipfian/Scrambled | stub | THE YCSB generator, statistical contract tests |
ycsb.rs | provided | A-F mixes, BTreeMap store, ns-percentile driver |
bin/bench_suite.rs | provided | both sections, stub lanes catch_unwind |
M22 checklist
- standing suite: LDBC SNB interactive + graph micro-benches + ann-benchmarks recall/QPS, one command, results as data files
- regression tracking across milestones (M0 baselines are the floor; every M* run appends, plots trend)
- three-way shootout: falkordb-scratch vs falkordb-rs-next-gen vs FalkorDB — same driver, same data, same machine, fair- benchmarking checklist applied
TPC-H decoded: 22 queries, 28 choke points
TPC-H’s 22 queries are not arbitrary — each stresses a named set of engine capabilities (“choke points”), and Boncz, Neumann, and Erling’s TPCTC 2013 paper catalogs all 28 of them. Internalize the map and a benchmark number stops being a score and becomes a diagnosis. Read it WITH the queries open (DuckDB vendors them — see References and reading-duckdb-tpch.md).
The choke-point taxonomy (condensed)
CP1 aggregation dominated by GROUP BY machinery
CP1.1 ordered agg / CP1.2 small group-by keys (Q1!) /
CP1.4 dependent group-by (Q18)
CP2 joins order (Q5,Q7-Q9), semijoin (Q4,Q21,Q22),
large vs selective probes
CP3 locality materialized views would help (Q14/Q15),
physical column order
CP4 expressions arithmetic-heavy (Q1 again), string match
LIKE '%green%' (Q9), date logic everywhere
CP5 correlated subq Q2, Q11, Q17, Q20-Q22
CP6 parallelism all of them, but skew hits Q9/Q18 hardest
The three queries everyone profiles (and why)
| query | choke points | what it really measures |
|---|---|---|
| Q1 | CP1.2 + CP4 | expression evaluation + tiny-domain aggregation: ~4 groups, so the hash table is FREE and fused arithmetic dominates — our q1_flat stub makes this explicit |
| Q6 | CP4 + scan | pure selection: ~2% selectivity, SIMD-able predicates — DBMS “GB/s scanned” headline numbers are usually Q6 |
| Q9 | CP2 + CP4 (LIKE) + CP6 skew | 6-way join order + %green% string matching + per-nation skew — the query that punishes optimizers |
Q1’s whole trick, made explicit (this is what our q1_flat stub
implements):
#![allow(unused)]
fn main() {
// Q1: "GROUP BY returnflag, linestatus" has ~6 groups TOTAL, so the
// hash table degenerates into a flat array — all that's left to
// measure is expression evaluation and fused accumulation.
fn q1_flat(c: &LineItemColumns) -> [Agg; 6] {
let mut g = [Agg::default(); 6];
for i in 0..c.len {
if c.shipdate[i] > CUTOFF { continue; }
let k = group_code(c.returnflag[i], c.linestatus[i]); // 0..5
let disc_price = c.extendedprice[i] * (1.0 - c.discount[i]);
g[k].sum_qty += c.quantity[i];
g[k].sum_disc_price += disc_price;
g[k].sum_charge += disc_price * (1.0 + c.tax[i]);
g[k].count += 1;
}
g
}
}
Hidden messages worth knowing
- Uniformity is a lie you can exploit: dbgen data is uniform and independent — cardinality estimation is EASY on TPC-H (contrast JOB, topic 10, built precisely because of this).
- Q1’s group count (4-6) makes hash-agg invisible — a benchmark win on Q1 says nothing about high-cardinality GROUP BY; that’s why ClickBench and TPC-DS exist.
- Refresh functions (RF1/RF2) are always skipped in informal runs — published “TPC-H” numbers are usually just the power test’s 22 SELECTs, i.e. read-only. Say “TPC-H-derived” (the spec police are real, and so is the Fair Benchmarking paper — topic 0 guide).
- Scale factor changes the winner: SF1 fits in cache, SF100 doesn’t — engine rankings flip between them (topic 0’s ladder).
Questions (answer in notes.md)
- Map Q1/Q6/Q9 onto FalkorDB-relevant analogues: which Cypher query shapes hit the same choke points (small-domain agg, scan+filter, join-order + skew)?
- Our dbgen-lite is uniform AND independent like the real dbgen.
Which columns would need correlation to break
q1_flat’s perfect-group-code trick? - Why does Q6’s ~2% selectivity favor branchy evaluation while 50%
would favor branchless (topic 17’s crater)? Predict the measured
crossover for
q6_branchless. - Choke point CP3 (materialization): which of the 22 queries would an incremental-view engine (topic 27 preview) answer in O(1)?
- TPC-H says nothing about updates. What does TPC-C’s NewOrder mix test that no TPC-H query can (see reading-oltpbench-tpcc.md)?
References
Papers
- Boncz, Neumann, Erling — “TPC-H Analyzed: Hidden Messages and Lessons Learned from an Influential Benchmark” (TPCTC 2013) — the choke-point catalog; read with the queries open
Code
- duckdb
extension/tpch/dbgen/queries/q01.sql … q22.sql— the 22 queries; reference answers indbgen/answers/
dbgen as a table function: shipping a benchmark inside the engine
How a modern engine ships TPC-H as a built-in: DuckDB vendors the
official dbgen, wraps it in a table function, and stores the
reference answers next to the queries — so every benchmark run is
also a correctness test. It’s also the fastest way to get real
TPC-H numbers on this machine (no CLI install needed;
pip install duckdb or the Rust crate both carry the extension),
which is what the questions below ask you to do.
Layout (~/repos/duckdb/extension/tpch/)
| path | what |
|---|---|
tpch_extension.cpp:17-30 | DBGenFunctionData — dbgen exposed as a table function: CALL dbgen(sf=1) |
tpch_extension.cpp:49-95 | bind (parse sf, :63) → init → DbgenFunction (:99) streaming chunks — the generator IS an operator, so SF100 generation parallelizes and never materializes a .tbl file |
dbgen/ | the actual TPC-official dbgen C code, vendored (bm_utils.cpp, build.cpp, permute.cpp — 1990s C, seeded, spec-exact) |
dbgen/queries/q01.sql…q22.sql | the 22 queries, parameter-substituted |
dbgen/answers/ | reference results per SF — correctness oracle, not just speed |
tpch_config.py | generates the header embedding queries/answers |
The lesson for M22: benchmark data generators belong inside the engine as table functions — deterministic, parallel, no file-format drift, and answers ship next to queries so every run is also a correctness test (topic 16’s oracle habit).
Run it (record numbers in notes.md)
-- python: import duckdb; con = duckdb.connect()
INSTALL tpch; LOAD tpch;
CALL dbgen(sf=1);
PRAGMA tpch(1); -- Q1
PRAGMA tpch(6); -- Q6
PRAGMA tpch(9); -- Q9
-- .timer on / %timeit around them; compare against our
-- dbgen-lite oracle numbers (bench_suite) at matched row counts
Expected shape (verify): Q6 saturates memory bandwidth (topic 0’s
30 GB/s baseline), Q1 is compute-bound in expression eval + fused
aggregation, Q9 is join-order sensitive (try
SET disabled_optimizers='join_order' for the horror version).
Why our dbgen-lite is NOT dbgen
Real dbgen: correlated text fields (comment with pattern-planted
%green% for Q9), spec-exact value distributions, refresh streams,
and — crucially — the SAME seeds everyone else uses, so results are
comparable across papers. Ours: uniform, independent, three columns’
worth of fidelity — enough for Q1/Q6 choke-point work, useless for
Q9 or optimizer studies. Scope your generator to your question.
Questions (answer in notes.md)
- Measure DuckDB Q1 and Q6 at SF1 on this machine; compute effective
GB/s and compare with our oracle lanes AND topic 0’s streaming
baseline. Where does the gap come from (vectorization? fewer
passes? parallelism — check with
SET threads=1)? - Why does shipping
answers/matter more than shippingqueries/? Relate to topic 16’s oracle taxonomy. DbgenFunctionstreams chunks instead of writing .tbl files — which topic-11 concept is that (operator vs materialization)?- Q9 with join order disabled: how much slower, and which topic-10 lesson does the number reproduce?
- Sketch M22’s
CALL ldbc_datagen(sf=1)equivalent for the capstone: what determinism/answer-shipping properties must it keep?
References
Code
- duckdb
extension/tpch/—tpch_extension.cpp(the table-function plumbing),dbgen/(the vendored TPC-official C code),dbgen/queries/,dbgen/answers/,tpch_config.py
TPC-C: contention by design (and the harness that runs it honestly)
TPC-C doesn’t measure throughput — it measures how an engine behaves when the workload deliberately funnels transactions through hot rows. This chapter reads the OLTP-Bench paper (VLDB 2013) for what a fair OLTP harness must do — rate control above all — and then walks TPC-C’s designed contention in the maintained fork, CMU’s BenchBase: one harness, ~20 benchmarks, one config format, per-phase rate control.
The harness’s three contributions
- Rate control as a first-class knob — closed-loop (max speed), open-loop (fixed rate, honest tails), and phases that change the mix mid-run (diurnal patterns). Most homegrown harnesses have only closed-loop, which hides queueing (see the coordinated- omission note in reading-ycsb.md).
- Benchmark = workload descriptor, not code fork — transaction
weights in XML (
config/postgres/sample_tpcc_config.xml), so “TPC-C but 100% NewOrder” is a config edit. - Everything is measured the same way — one histogram, one sampling story across 20 benchmarks; comparisons are apples to apples.
TPC-C in 60 seconds (via benchmarks/tpcc/)
Five transactions, weighted 45/43/4/4/4: NewOrder, Payment, OrderStatus, Delivery, StockLevel. Contention is BY DESIGN:
warehouse (W of them) ← every NewOrder updates its W_YTD row-ish
└─ district (10/W) ← D_NEXT_O_ID: THE hot counter, serializes
└─ orders NewOrders within a district
~1% NewOrders touch a REMOTE warehouse ⇒ cross-shard txns exist
~1% NewOrders ABORT by spec (rollback path must be exercised)
| anchor | what |
|---|---|
TPCCWorker.java:85-100 | keying + think times: -log(c)·mean, capped at 10× — the spec’s human simulator |
TPCCUtil.java:94-116 | NURand non-uniform randoms; note :94’s constraint on C_LAST_LOAD_C vs C_LAST_RUN_C (157/223) — load-time and run-time skew must DIFFER by spec |
TPCCConfig.java | the 45/43/4/4/4 weights |
The spec’s two human-proofing devices, in code:
#![allow(unused)]
fn main() {
// NURand: TPC-C's non-uniform random — OR of two uniforms biases bits
// toward 1, concentrating hits in a hot region you can't cheat away
fn nurand(a: u64, x: u64, y: u64, c: u64, rng: &mut Rng) -> u64 {
// c MUST differ between load time and run time (TPCCUtil:94) —
// otherwise the loader could pre-sort the hot region into cache
(((rng.range(0, a) | rng.range(x, y)) + c) % (y - x + 1)) + x
}
// keying + think time: the simulated human nobody runs — capped
// exponential wait between transactions (TPCCWorker:85-100)
fn think_time(mean: f64, rng: &mut Rng) -> f64 {
(-rng.f64().ln() * mean).min(10.0 * mean) // spec caps at 10× mean
}
}
Why nobody runs it honestly: with think times, one warehouse supports ~12.86 tpmC max — spec-compliant runs need thousands of warehouses (= huge data) to post big numbers. Everyone strips think times and runs 4 warehouses ⇒ they’re benchmarking the D_NEXT_O_ID latch, not the engine. “tpmC” without an audit is a vibe.
TPC-C vs YCSB-A — what each contention is
- YCSB-A zipfian: skewed READS+UPDATES on independent keys — no transaction spans keys; MVCC barely matters.
- TPC-C NewOrder: multi-statement transaction, read-modify-write on a hot counter + ~10 item updates — THIS is what write-skew, 2PL queues, and MVCC abort rates (topic 8) are about.
Questions (answer in notes.md)
- D_NEXT_O_ID: under MVCC-OCC (topic 8’s stub), what abort rate do you expect at 4 warehouses × 16 threads, closed loop? What changes with per-district queues (topic 9)?
- Why must load-time and run-time C_LAST constants differ (TPCCUtil:94)? What cheat does the constraint block?
- Design “TPC-C for graphs”: what’s the hot-counter analogue in a social-network write workload (hint: supernode edge appends, topic 13)?
- Open vs closed loop on workload E (our scan-heavy mix): which reports the higher p999, and why is that the honest one?
- OLTP-Bench’s phased rates: sketch the config that reproduces a cache-warmup-then-spike incident (topic 6’s eviction storm).
References
Papers
- Difallah, Pavlo, Curino, Cudré-Mauroux — “OLTP-Bench: An Extensible Testbed for Benchmarking Relational Databases” (VLDB 2013) — §3 (harness architecture, rate control) is the part that aged well
Code
- benchbase — the maintained
fork;
src/main/java/com/oltpbenchmark/benchmarks/tpcc/(TPCCWorker.java,TPCCUtil.java,TPCCConfig.java) andconfig/postgres/sample_tpcc_config.xml
YCSB: six mixes, five distributions, one Zipfian generator
Cooper et al.’s SoCC 2010 paper standardized KV benchmarking by
factoring a workload into an operation mix times a key
distribution — and its θ=0.99 Zipfian generator is the skew behind
nearly every KV paper since (our zipf.rs stub reimplements it
from the go-ycsb port). This chapter covers both the factoring and
the generator’s math, plus the traps to know before citing a YCSB
number.
The design: workloads = mix × distribution
op mix (what) key distribution (where)
A 50r/50u update-heavy uniform — every key equal
B 95r/5u read-mostly zipfian .99 — hot head, θ=0.99
C 100r read-only latest — zipf over newest
D 95r/5i read-latest scrambled — zipf rank, fnv-
E 95scan/5i short ranges hashed into space
F 50r/50rmw read-modify-write hotspot — x% ops on y% keys
Property files: workloads/workloada:31-36 (proportions +
requestdistribution). The genius is the factoring — 6 mixes × 5
distributions covers most serving systems’ realities.
The Zipfian generator (pkg/generator/zipfian.go)
The most-copied benchmark code in existence — our zipf.rs stub:
| anchor | what |
|---|---|
| :43 | ZipfianConstant = 0.99 — why every paper says θ=0.99 |
| :92-118 | constructor: zetan (harmonic-ish sum, O(n)!), eta, alpha = 1/(1-θ) |
| :125-132 | zetaStatic — the O(n) sum; incremental recompute when item count GROWS (:135-147), full recompute (slow, warned) when it shrinks |
| :150-163 | the sampler: two fast paths (uz < 1 → rank 0, < 1+0.5^θ → rank 1), else n·(ηu − η + 1)^α |
scrambled_zipfian.go | fnv64(rank) % n — same skew, scattered hot keys |
The sampler, transcribed (zipfian.go:150-163):
#![allow(unused)]
fn main() {
fn next(&mut self, rng: &mut Rng) -> u64 {
let u = rng.f64();
let uz = u * self.zetan; // zetan = Σ 1/i^θ — O(n), computed ONCE
if uz < 1.0 { return 0; } // fast path: THE hottest key
if uz < 1.0 + 0.5f64.powf(self.theta) { return 1; }
// general case: inverse-CDF approximation, rank from one pow()
let rank = (self.n as f64
* (self.eta * u - self.eta + 1.0).powf(self.alpha)) as u64;
rank // alpha = 1/(1-θ)
}
fn next_scrambled(&mut self, rng: &mut Rng) -> u64 {
fnv64(self.next(rng)) % self.n // same skew, hot keys NOT ids 0,1,2…
}
}
Why scrambling matters: plain zipfian’s hot keys are ids 0,1,2,… — adjacent, so they share cache lines/pages/shards, and you accidentally benchmark spatial locality instead of skew. Scrambled spreads them. (Our test pins this: hottest key must not be id 0.)
What YCSB gets wrong (know before citing)
- Coordinated omission (Tene): the closed-loop driver stops sending while an op stalls, so recorded latencies MISS the queueing a real open-loop client would see. p999 under load is fiction unless you use a target rate + intended-start-time correction.
- Zeta is O(n) at startup — at 1B keys the constructor takes minutes; ports cache zetan constants for common sizes.
- No transactions, no scans-with-filter, values are blobs — it benchmarks the KV layer only (fine for M22’s graph micro-benches, wrong for MVCC claims).
Questions (answer in notes.md)
- Derive why P(rank 0) = 1/ζ(n,θ). Then: at n=1M, θ=0.99, what fraction of ops hit the top 100 keys? (Compute, then verify with the stub.)
- Why do the two fast paths in
next()exist — what fraction of draws do they absorb at θ=0.99? - Predict uniform → zipfian effect per workload on OUR BTreeMap store: A-F, which speeds UP (cache-hot head) and which barely moves (E’s scans)? Fill the prediction table before implementing.
- Coordinated omission: our driver records service time. Sketch the fix (intended arrival times at a target rate) and what p999 would show for workload E.
- Workload D’s “latest” distribution: why is passing a plain zipfian to a growing keyspace subtly wrong (hint: zetan staleness, go-ycsb :135)?
References
Papers
- Cooper, Silberstein, Tam, Ramakrishnan, Sears — “Benchmarking Cloud Serving Systems with YCSB” (SoCC 2010) — §3-4 (the mix×distribution factoring); the eval section is dated
Code
- go-ycsb
pkg/generator/zipfian.go,scrambled_zipfian.go,workloads/workloada— the Go port; structure mirrors the Java original
Topic 22 notes — standard benchmarks
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
TPC-H choke points (bench_suite, dbgen-lite)
| SF | rows | Q1 oracle ms | Q1 GB/s | Q6 oracle ms | Q6 GB/s |
|---|---|---|---|---|---|
| 0.05 | 300K | 2.4 | 4.7 | 0.7 | 11.9 |
| 0.25 | 1.5M | 10.2 | 5.6 | 2.7 | 15.7 |
Q1 oracle: HashMap entry per row (even with only 6 groups) + 4 f64 FMAs — hashing dominates, exactly the CP1.2 story: the group domain is tiny, so a real engine replaces the hash with an array and turns Q1 into an expression benchmark. Q6 branchy oracle already at 15.7 GB/s — the 2% selectivity means the branch is nearly always-false, i.e. perfectly predicted (topic 0’s sorted case).
YCSB A-F (1M keys, 500K ops, uniform, BTreeMap store)
| workload | Mops/s | p50 ns | p99 ns | p999 ns |
|---|---|---|---|---|
| A update-heavy | 2.88 | 292 | 792 | 2083 |
| B read-mostly | 4.15 | 208 | 500 | 667 |
| C read-only | 3.72 | 209 | 542 | 958 |
| D read-latest | 4.40 | 208 | 417 | 625 |
| E short-ranges | 1.11 | 917 | 1583 | 2041 |
| F read-mod-write | 2.85 | 292 | 708 | 1541 |
E is 4× slower than C — a 100-element range walk per op; updates add allocation (the vec![1u8;100] per update). The mix table alone predicts the ordering: D ≥ B ≥ C > A ≈ F > E. (C < B is real: C’s every op is a read against a fully-cold… no — same store; likely noise + B’s 5% updates keeping allocator warm. Re-measure when implementing zipf.)
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| q1_flat speedup over HashMap oracle at SF 0.25 — ×? | ||
| q6_branchless vs branchy oracle at 2% selectivity — faster, same, SLOWER? | ||
| q6 at 50% selectivity (edit the predicate): branchy vs branchless ×? | ||
| zipf .99, n=1M: fraction of ops on top-100 keys | ||
| YCSB A uniform → zipf: throughput up or down, and p999? | ||
| YCSB E uniform → zipf: does it move at all? |
Implementation log
- zipf.rs Zipfian + Scrambled — statistical tests green
- tpch.rs q1_flat + q6_branchless — match oracles, bench lanes fill
- prediction table reconciled
- run real TPC-H SF1 in DuckDB (python duckdb; PRAGMA tpch(1/6/9)), record Q1/Q6/Q9 ms + threads=1 numbers, compare with our lanes
- stretch: 50%-selectivity Q6 variant — reproduce topic 17’s branchy crater inside a “TPC-H” query
- stretch: coordinated-omission demo — open-loop driver at 80% of closed-loop max rate, compare p999
Surprises / dead ends:
- Nearest-rank percentile off-by-one: round((n-1)·p) gave 501 for p50 of 1..=1000; switched to ceil(p·n)-1. Benchmark harness bugs are benchmark results bugs.
Questions from the reading guides
Boncz TPC-H (reading-boncz-tpch.md)
- Q1/Q6/Q9 → Cypher choke-point analogues:
- Which dbgen-lite columns need correlation to break q1_flat:
- Q6 2% vs 50% selectivity branchy/branchless crossover:
- Which of the 22 queries an IVM engine answers in O(1):
- What TPC-C NewOrder tests that no TPC-H query can:
YCSB (reading-ycsb.md)
- P(rank 0) = 1/ζ(n,θ) derivation; top-100 mass at n=1M:
- The two fast paths — what fraction of draws at θ=.99:
- Per-workload uniform→zipf prediction (table above):
- Coordinated-omission fix sketch; workload E p999:
- Why plain zipfian on a growing keyspace is wrong (zetan staleness):
OLTP-Bench + TPC-C (reading-oltpbench-tpcc.md)
- D_NEXT_O_ID abort rate under OCC at 4WH×16T:
- Why load-time vs run-time C_LAST constants must differ:
- “TPC-C for graphs” — the supernode hot counter:
- Open vs closed loop p999 on workload E:
- Phased-rate config reproducing an eviction storm:
DuckDB tpch extension (reading-duckdb-tpch.md)
- DuckDB Q1/Q6 SF1 measured; gap analysis vs our lanes:
- answers/ over queries/ — oracle taxonomy:
- dbgen-as-table-function = which topic-11 concept:
- Q9 with join order disabled — how much slower:
- capstone ldbc_datagen table function requirements:
Cross-topic threads
- Q6 branchy-at-2%-is-fine = topic 0’s branch_misprediction sorted case; the 50% crater is topic 17’s filter curve — selectivity decides the winner, benchmarks pick the selectivity.
- Q1’s flat group array = topic 11’s exec engine trick; TPC-H Q1 is the reason that trick exists.
- Zipfian generator = capstone workload crate’s distribution (topic 0) — now with the actual YCSB math and statistical contract.
- TPC-C’s D_NEXT_O_ID = topic 8’s write-hot-key MVCC abort story = topic 9’s contended counter, institutionalized as a benchmark.
- dbgen uniform data = why topic 10’s JOB exists; cardinality lies need correlated data to show up.
- Benchmark harness = topic 16’s oracle discipline: answers ship with queries or you’re measuring wrong fast.
M22 log (capstone)
- standing suite: LDBC SNB interactive + graph micro + ann-bench recall/QPS, one command, results as committed data files
- regression tracking across milestones (M0 BASELINES.md is the floor)
- three-way shootout falkordb-scratch / falkordb-rs-next-gen / FalkorDB with the sins checklist applied
Done when
- Both stub pairs green with lanes filled; prediction table reconciled; DuckDB SF1 numbers recorded and explained; guide questions answered; M22 suite design sketched.
Topic 23 — Full-Text Search & Inverted Indexes
The third great index family after trees and hash tables: term → sorted posting list, plus the machinery that makes it fast (compressed blocks, FST dictionaries, BM25, block-max WAND) and the architecture that makes it writable (Lucene’s LSM-in-disguise segments). Home-turf bonus: RediSearch — what FalkorDB delegates full-text to — is being rewritten in Rust, and its inverted index crate is readable tonight.
Anatomy
query "quick fox"
│ analyzer: tokenize → lowercase → stem → stopwords
▼
term dictionary posting lists (per term, doc-sorted)
┌──────────────┐ TermInfo ┌────────┬────────┬────────┐
│ FST: bytes → │ {doc_freq, │block 0 │block 1 │block 2 │ 128 docs/block,
│ term ordinal │ postings_range} │Δ-packed│Δ-packed│Δ-packed│ delta + bitpack
└──────────────┘ └────────┴────────┴────────┘
skip data: per block
{last_doc, max_score} ← block-max WAND
+ fieldnorms (doc lengths, for BM25's B)
+ fast fields (columnar doc values — topic 12 inside a text index)
graph LR
subgraph write path — Lucene/tantivy segments = LSM
W["docs"] --> MB["in-RAM segment<br/>(memtable)"] --> F["flush: immutable<br/>segment on disk"]
F --> MP["merge policy<br/>(log-size tiers = compaction)"]
end
subgraph read path
Q["query"] --> TD["FST term dict"] --> PL["postings + skips"] --> BMW["block-max WAND<br/>top-k"]
end
Same shape as topic 4’s LSM: immutable segments, background merges, deletes as tombstone bitmaps, a reader that unions segments. Lucene discovered LSM independently because inverted indexes are cheap to build and expensive to update in place — exactly the LSM bet.
The two speed tricks
- Compression that keeps random access: doc ids stored as deltas,
128 per block, bit-packed to the block’s max width (tantivy
postings/compression/mod.rs:3; RediSearch varint-encodes instead —redisearch_rs/varint). Blocks give you skip pointers for free. - Score upper bounds: BM25 saturates at (K1+1)·idf, so every term and every block has a precomputable max score. WAND uses term maxima to find a pivot; block-max WAND (SIGIR’11) refines with per-block maxima and skips whole blocks that provably can’t beat the current top-k threshold.
Measured baselines (fts_bench, M3 Pro, 100K docs / 10M tokens / 7.9M postings)
| lane | result |
|---|---|
| index build | gen 236 ms + build 335 ms (single thread) |
| oracle top-10, common∧common (272K postings) | 8.75 ms |
| oracle top-10, common∧rare (100K postings) | 6.34 ms |
| oracle top-10, rare∧rare (159 postings) | 8 µs |
| vec AND t0∧t1 (100K∧98K) | 97 µs |
| vec AND t0∧t5000 (100K∧172) | 52 µs — walks the dense list anyway |
The 6.34 ms common∧rare lane is WAND’s whole reason to exist: the rare term’s idf ≈ 9 dominates, so almost none of the common term’s 100K postings can reach the top-10 — an exhaustive scorer touches them all anyway. The 52 µs dense∧sparse AND is roaring/galloping’s reason: two-pointer intersection is O(|dense|), not O(|sparse|).
Reading guides
- reading-zobel-moffat.md — Inverted indexes: the whole design space in one survey
- reading-bm25.md — BM25: a derivation, not folklore
- reading-blockmax-wand.md — Block-max WAND: skip everything that provably can’t win
- reading-roaring.md — Roaring bitmaps: no single set representation wins
- reading-tantivy.md — tantivy: Lucene’s architecture in readable Rust
- reading-redisearch.md — RediSearch in Rust: a mutable inverted index
- topic 4: LSM guides — segment merging is the same design
- topic 14:
reading-hnsw.md— the other half of M23’s hybrid search
Experiments
| file | status | what it shows |
|---|---|---|
corpus.rs | provided | Zipfian corpus (term id = rank), tokenizer |
index.rs | provided | postings + 128-block metas with per-block max BM25 |
bm25.rs | provided | K1/B, saturating tf, exhaustive term-at-a-time oracle |
wand.rs wand_topk | stub | block-max WAND: same top-k, fraction of the work |
postings.rs Roaring | stub | array/bitmap containers, AND/OR vs vec oracle |
bin/fts_bench.rs | provided | all lanes, stubs in catch_unwind |
M23 checklist (capstone)
- full-text index on node/edge string properties: analyzer + segment-per-milestone postings, BM25 top-k procedure
- hybrid search: RRF fusion of BM25 top-k with M14’s HNSW top-k
(
score = Σ 1/(60 + rank_i)) - posting lists ARE the graph trick: doc ids = node ids, so full-text hits feed directly into M20’s masked matrix ops
Block-max WAND: skip everything that provably can’t win
Top-k retrieval doesn’t need to score every document — only the
ones whose score upper bound beats the current k-th best. That
one observation (WAND, CIKM 2003) plus per-block score ceilings
(Ding & Suel, SIGIR 2011) is what our wand::wand_topk stub
implements. Prereq: read the original WAND paper’s §2 first — it’s
3 pages.
WAND in one picture
cursors sorted by current doc id; θ = current k-th best score
term cur_doc max_score Σ max so far
"fox" 41 1.9 1.9
"quick" 70 2.3 4.2 ← crosses θ=3.8 HERE
"the" 193 0.4 —
▲
pivot_doc = 70: no doc < 70 can possibly reach θ
(docs 41..69 get at most 1.9 + nothing = 1.9 < θ)
if all cursors before pivot sit AT 70 → score 70 fully
else → advance "fox" to ≥ 70 (skip 42..69 without scoring)
The magic: correctness needs only upper bounds. WAND returns the EXACT top-k (safe-to-k) while scoring a fraction of the docs.
One round of the loop, in code:
#![allow(unused)]
fn main() {
// θ = current k-th best score; upper bounds make skipping SAFE
fn wand_round(cursors: &mut [Cursor], theta: f32) -> Option<DocId> {
cursors.sort_by_key(|c| c.doc()); // by current doc id
let mut ub = 0.0;
let pivot = cursors.iter().position(|c| {
ub += c.term_max_score; // accumulate ceilings
ub > theta // first cursor to cross θ
})?; // none crosses ⇒ done
let pivot_doc = cursors[pivot].doc(); // no doc < pivot_doc can win
if cursors[..=pivot].iter().all(|c| c.doc() == pivot_doc) {
// block-max refinement (the 2011 part): if Σ current BLOCK maxima
// ≤ θ, this pivot is a false positive — jump past
// min(last_doc_in_block) without decompressing anything
Some(pivot_doc) // else: score it fully
} else {
cursors[0].seek(pivot_doc); // skip docs, never score
None
}
}
}
Block-max: the 2011 upgrade
Term-level max_score is one global ceiling — for a common term it’s set by its single best doc, wildly pessimistic everywhere else. Block-max stores max_score per 128-doc block (uncompressed metadata next to compressed postings):
- pivot found with term maxima as before (cheap, monotone);
- then REFINE with the current blocks’ maxima: if Σ block-max ≤ θ,
the pivot is a false positive — skip to
min(block boundary) + 1without decompressing anything (§4’s “shallow” vs “deep” pointer movement: moving a block cursor doesn’t decode the block). - §5’s numbers: ~2.5-4× over WAND at TREC scale, more at deeper k.
Our BlockMeta { last_doc, max_score } in index.rs is exactly
their metadata; tantivy’s is postings/skip.rs:175
(block_max_score) + :186 (last_doc_in_block).
Mapped to tantivy
| paper concept | tantivy anchor |
|---|---|
| pivot selection | query/boolean_query/block_wand_union.rs:8-24 find_pivot_doc — walks scorers sorted by doc, accumulates max_weight until > threshold |
| block metadata | postings/skip.rs:93 SkipReader, :175/:186 |
| term upper bound | Scorer::max_score per term weight |
| union top-k | block_wand_union.rs (OR queries), block_wand_intersection.rs (AND) |
Traps for the implementation (learned by others, cheaply)
- θ must only tighten AFTER the heap holds k entries; seeding θ=-∞ with an empty heap is correct, seeding 0.0 silently drops negative-score models (BM25 here is non-negative, but don’t).
- When the block-max check fails, advance past
min(last_doc of the cursors' current blocks)— advancing only to pivot_doc re-finds the same dead pivot forever (livelock). - Ties at the k-boundary: WAND may return a different doc with an EQUAL score — compare scores, not doc ids (our test does).
docs_scoredcounts full evaluations; postings_skipped counts what you jumped — the paper’s Table 4 metric is “docs evaluated”, make sure yours matches for comparability.
Questions (answer in notes.md)
- For our
[t0, t12000]query (df 99888 vs 83, idf ≈ 0.7 vs 9): after the heap fills with rare∧common docs, θ ≈ ? Can t0 alone ever cross it? Predict wand’s docs_scored (the test demands <25% of 99964). - Why does block-max help MOST on common terms? Relate to the variance of per-block maxima under Zipf tf distributions.
- The paper stores block maxima uncompressed. At 128 docs/block, what’s the metadata overhead per posting, and why is quantizing maxima to u8 safe but quantizing DOWN unsafe?
- Block-max WAND is exact top-k. What changes if the scorer adds M14’s vector similarity (no static bound)? Sketch M23’s hybrid: WAND for BM25 candidates + RRF, vs a fused traversal.
- Deletes-as-bitmap (Lucene liveDocs, RediSearch GC): a block’s max_score may belong to a deleted doc. Is WAND still exact? What’s the merge-time fix?
References
Papers
- Broder, Carmel, Herscovici, Soffer, Zien — “Efficient Query Evaluation using a Two-Level Retrieval Process” (CIKM 2003) — read §2 first (3 pages): the pivot idea
- Ding, Suel — “Faster Top-k Document Retrieval Using Block-Max Indexes” (SIGIR 2011) — §4 (shallow vs deep pointer movement) and §5’s numbers
Code
- tantivy
src/query/boolean_query/block_wand_union.rs(:8-24find_pivot_doc),block_wand_intersection.rs,src/postings/skip.rs(:175block_max_score, :186last_doc_in_block) — the paper, shipped
BM25: a derivation, not folklore
BM25 looks like folklore (two magic constants, a weird fraction) but it’s a derivation: rank documents by P(relevant|doc)/P(irrelevant|doc) under increasingly honest assumptions. Robertson & Zaragoza’s 2009 monograph is the two inventors showing their work, 30 years after Robertson-Spärck Jones — and every piece of the formula answers “what breaks if I drop it”.
The derivation ladder
binary independence model (§3)
terms present/absent, independent ⇒ score = Σ log odds per term
└─ with no relevance info ⇒ the idf shape: log (N - df + 0.5)/(df + 0.5)
+ term frequency via 2-Poisson "eliteness" (§3.3)
docs are elite/non-elite for a term; tf is a noisy signal of eliteness
⇒ tf weight must SATURATE: tf·(k1+1)/(tf + k1) ← not log(tf), not raw tf
+ document length (§3.4)
long docs: more of everything ⇒ normalize tf by len/avg_len,
but only partially (verbosity vs scope hypothesis) ⇒ the B knob
= BM25 (§3.5):
Σ idf(t) · tf·(k1+1) / (tf + k1·(1 - b + b·len/avg_len))
Every piece answers “what breaks if I drop it”:
- no saturation → a doc repeating
quick500× beats a doc withquick fox(spam magnet); - no length norm → encyclopedic docs win everything;
- full length norm (b=1) → long docs can never win, even legitimately comprehensive ones.
Mapped to code (tantivy query/bm25.rs)
The whole scorer is one multiply-add per posting once idf and the length-norm are precomputed:
#![allow(unused)]
fn main() {
const K1: f32 = 1.2;
const B: f32 = 0.75;
fn idf(n_docs: f32, df: f32) -> f32 {
((n_docs - df + 0.5) / (df + 0.5) + 1.0).ln() // +1: Lucene's tweak,
} // never negative at df > N/2
fn bm25(idf: f32, tf: f32, len: f32, avg_len: f32) -> f32 {
let norm = K1 * (1.0 - B + B * len / avg_len); // Lucene: a 256-entry
idf * (tf * (K1 + 1.0)) / (tf + norm) // table, len as u8
}
// tf → ∞ ⇒ score → idf·(K1+1): the saturation ceiling that makes
// WAND's per-term upper bounds possible (next chapter)
}
| formula piece | anchor |
|---|---|
| K1=1.2, B=0.75 (the paper’s “reasonable defaults”, §4.2) | bm25.rs:8-9 |
| idf with +1 under the ln (Lucene tweak: never negative when df > N/2) | bm25.rs:52 |
K1 * (1 - B + B * fieldnorm / average_fieldnorm) precomputed per fieldnorm byte | bm25.rs:59 |
| fieldnorm quantized to 1 byte, 256-entry cache table | fieldnorm/ + the cache in bm25.rs |
Lucene quantizes doc length to a u8 (lossy!) so the whole
length-normalization term is a 256-entry lookup — scoring is one
multiply-add per posting. Our bm25.rs keeps exact lengths; the
experiments’ block maxima would be slightly different under
quantization (question 4).
Why WAND loves BM25
tf saturates at (K1+1) and fieldnorm ≥ some minimum, so score(t, d) ≤ idf(t)·(K1+1) for ALL docs — a static per-term ceiling, refinable per block. Learned scorers without monotone bounds lose this (that’s why neural rerankers run AFTER a BM25/WAND first stage).
Questions (answer in notes.md)
- Derive the tf-saturation limit: as tf→∞ the weight → K1+1. At K1=1.2, what tf reaches 90% of the ceiling (len=avg)? What does that say about keyword stuffing?
- The +0.5s in idf are a smoothing (Jeffreys prior). What happens at df=0 and df=N without them?
- b=0.75: our corpus has uniform lengths 50-150. Predict how much scores change b=0.75 → b=0 here vs on a corpus of tweets+books.
- Lucene’s 1-byte fieldnorm: worst-case relative score error vs exact lengths? Why is this fine for ranking but would corrupt our oracle-equality test?
- RSJ weights need relevance judgments (§3.2); idf is the no-information special case. Where would M23 get click/edge feedback to use the full RSJ weight, and is it worth it?
References
Papers
- Robertson, Zaragoza — “The Probabilistic Relevance Framework: BM25 and Beyond” (Foundations and Trends in IR 2009) — §3 is the derivation ladder; §4.2 the default constants
Code
- tantivy
src/query/bm25.rs— K1/B at :8-9, idf at :52, the precomputed fieldnorm table at :59
RediSearch in Rust: a mutable inverted index
Home turf: this is what FalkorDB delegates full-text to, and the
interesting part is that the C core is being strangler-figged into
Rust crates behind FFI (c_entrypoint/inverted_index_ffi,
varint_ffi) — the exact migration pattern falkordb-rs-next-gen
lives. Read the inverted_index crate as a mutable, in-memory
counterpart to tantivy’s immutable segments
(reading-tantivy.md): every design delta
falls out of “updates must be cheap NOW”.
The structure (inverted_index/src/index/core.rs)
| anchor | what |
|---|---|
core.rs:30 InvertedIndex<E> | blocks: ThinVec<IndexBlock>, n_unique_docs, flags: IndexFlags, gc_marker: AtomicU32, unique_id — encoder is a type parameter (PhantomData<E>), so codec choice is compile-time |
core.rs:75 IndexBlock | { first_doc_id, last_doc_id, num_entries: u16, buffer: Vec<u8> } — a growable byte buffer of varint-encoded entries, chained, NOT fixed 128-wide bitpacked |
core.rs:229 | a delta too large for the codec ⇒ start a new block with delta 0 (IdDelta::from_u64 → None path, codec/mod.rs:28-44) |
codec/mod.rs:53 trait Encoder | write(record, delta), delta_base(block) — one trait, eleven codecs |
codec/ | doc_ids_only / raw_doc_ids_only / freqs_only / freqs_fields / fields_offsets / full / numeric … — the granularity ladder from Zobel-Moffat §3 as a directory listing |
varint/src/lib.rs:98 VarintEncode | the wire format under most codecs |
gc.rs | garbage collection rewrites blocks to purge deleted docs — compaction for a mutable index; gc_marker tells live readers their cursor is stale |
unique_id (core.rs comment) | ABA detection: index freed + reallocated at same address ⇒ cursors notice via id mismatch — a very Redis-module concern |
The write path in miniature (core.rs:229 + codec/mod.rs:28-44):
#![allow(unused)]
fn main() {
// append one posting: varint-encode the delta into the last block;
// a delta the codec can't represent starts a NEW block at delta 0
fn add<E: Encoder>(&mut self, doc_id: u64, rec: &Record) {
let block = self.blocks.last_mut().unwrap();
match E::delta(doc_id, block) { // None ⇒ overflow for this codec
Some(delta) => {
E::write(&mut block.buffer, rec, delta); // byte-at-a-time varint
block.last_doc_id = doc_id;
block.num_entries += 1;
}
None => {
self.blocks.push(IndexBlock::new(doc_id)); // chain a fresh block
self.add::<E>(doc_id, rec); // — simple, robust
}
}
self.n_unique_docs += 1;
}
}
Design deltas vs tantivy (worth internalizing)
tantivy/Lucene RediSearch
mutability immutable segments + merge ONE mutable chained-block list per term
encoding 128-block bitpack (SIMD) varint per entry (byte-at-a-time)
deletes alive-bitmap, purge on merge GC pass rewrites blocks in place
concurrency segment = snapshot gc_marker + unique_id cursor validation
granularity postings files per field codec picked per index flags (11 variants)
why batch search workloads a Redis module: single-threaded-ish,
updates must be cheap NOW, no background
merge infrastructure
The Encoder-as-type-parameter design is the Rust rewrite earning
its keep: the C original dispatched on IndexFlags at runtime per
record; the Rust one monomorphizes eleven codecs and lets FFI pick
the concrete type once (c_entrypoint/inverted_index_ffi).
What M23 should copy vs avoid
- Copy: codec ladder (doc-ids-only for filters, freqs for ranked), new-block-on-delta-overflow (simple, robust), GC marker protocol for readers over a mutable index (FalkorDB’s matrices already have the delta/wait analogue).
- Avoid: per-entry varint for the ranked lane — topic 17 says the
branchy byte-decode loop caps GB/s; 128-block bitpacking + block
maxima buy WAND. RediSearch itself has no block-max WAND; scoring
unions walk everything (why
FT.SEARCHwith scores is expensive on big result sets).
Questions (answer in notes.md)
num_entries: u16and buffer-growth: what’s the effective block size policy, and why does variable block length make block-max metadata harder to bolt on than tantivy’s fixed 128?- The
gc_marker/unique_idcursor-validation dance: map it onto FalkorDB’s delta-matrixwait+ version story. What does each protect against, and which is stricter? - Eleven codecs vs tantivy’s one postings format + fast fields: which RediSearch codecs correspond to “positions” and “doc values” in the Lucene taxonomy?
- Varint vs bitpacked at df=99888/100K docs (delta≈1, one byte each): compute bytes/posting for both. Where does varint actually WIN?
- Sketch M23’s native replacement: which parts of this crate would you lift verbatim into falkordb-rs-next-gen, and where does the graph (node ids = doc ids, roaring hit-sets into masked mxv) change the design?
References
Code
- RediSearch
src/redisearch_rs/—inverted_index/src/index/core.rs(the structure),inverted_index/src/codec/(eleven codecs, one trait),varint/src/lib.rs,inverted_index/src/gc.rs, and the FFI seam inc_entrypoint/inverted_index_ffi
Roaring bitmaps: no single set representation wins
The set representation that ate the world: Lucene doc-id sets,
Spark, ClickHouse, Druid, Pilosa — and the postings::Roaring
stub. The insight is that NO single representation wins: sorted
arrays win sparse, bitmaps win dense, so partition the 32-bit space
into 64K chunks and choose per chunk.
The layout
u32 value = [ high 16 bits | low 16 bits ]
│ │
▼ ▼
sorted Vec of (key, container); container holds the low bits:
Array container: sorted Vec<u16> when |chunk| ≤ 4096
Bitmap container: [u64; 1024] = 8 KiB when |chunk| > 4096
Run container: (start,len) pairs (the '16 paper's addition)
4096 = the crossover where 2 bytes/value (array) meets
8 KiB/65536 possible values (bitmap) — a container is
NEVER worse than 2 bytes per value, and never bigger
than 8 KiB.
The kernel matrix (§3 — what the stub implements)
| A ∩/∪ B | array | bitmap |
|---|---|---|
| array | two-pointer merge (galloping when sizes differ ≥64×) | probe each u16 into the bitmap: O( |
| bitmap | ← same, swapped | 1024 word-wise AND/OR + popcount to pick the OUTPUT container type |
#![allow(unused)]
fn main() {
// the whole design in one match: kernel AND output type chosen per chunk
fn and(a: &Container, b: &Container) -> Container {
match (a, b) {
(Array(x), Array(y)) => two_pointer(x, y), // gallop if ≥64× skew
(Array(x), Bitmap(y)) => // probe the small side
Array(x.iter().copied().filter(|&v| y.get(v)).collect()),
(Bitmap(x), Bitmap(y)) => {
let mut w = [0u64; 1024];
let mut card = 0u32;
for i in 0..1024 {
w[i] = x.words[i] & y.words[i];
card += w[i].count_ones(); // popcount FUSED into the AND
}
if card <= 4096 { to_array(&w) } else { Bitmap(w) }
}
(Bitmap(_), Array(_)) => and(b, a), // commute to the probe case
}
}
}
Two details that carry the performance:
- output container choice: bitmap∩bitmap may produce a sparse result — popcount during the AND, convert to array if ≤4096. Union of bitmaps stays bitmap (never shrinks).
- cardinality is tracked, not recomputed — every kernel returns
it as a byproduct (the popcount is fused into the AND loop; on
M-series that’s
cnton each of 1024 words, memory-bound anyway).
Why postings lists care (vs our two-pointer vec)
Measured in fts_bench: t0 ∧ t5000 (99888 ∩ 172 docs) costs 52 µs
with two-pointer — it walks all 99888. Roaring: t0 at df≈100K over
100K docs is ~1.5 dense chunks → bitmap containers; the 172-element
side probes 172 times → ~1 µs. Same asymmetry galloping fixes for
arrays, but roaring ALSO compresses t0 to 8 KiB·2 instead of 400 KB.
Lucene’s RoaringDocIdSet and RediSearch’s doc tables use exactly
this for filters (the docs_ids_only codec in
redisearch_rs/inverted_index/src/codec/doc_ids_only.rs is the
varint cousin). Note what roaring does NOT store: tf, positions,
scores — it’s the FILTER lane (Cypher WHERE n.name CONTAINS ...
feeding a graph traversal), not the RANKING lane.
Questions (answer in notes.md)
- Derive the 4096 crossover from bytes/value. Where does the run-container (RLE) change the math, and what posting-list shape produces runs (hint: doc ids assigned by insertion order + crawler locality)?
- Our t0 has df 99888 over doc space 100K = 99.9% dense. What does its bitmap∩bitmap AND cost vs the measured 97 µs two-pointer for t0∧t1? Predict before implementing (1024·2 words ANDed…).
- Galloping (skewed array∩array) vs container probing (array∩bitmap): both are O(small·log/const). When does roaring still win despite equal asymptotics? (memory traffic of the big side)
- M20 tie-in: a bitmap container IS a dense GraphBLAS vector chunk; array container = sparse. Roaring’s per-chunk format switch is GraphBLAS’s sparse↔bitmap format lattice at 64K granularity — compare the switch thresholds (4096/65536 vs GB_conform’s).
- M23: full-text hit set → roaring → feed as mask into a matrix traversal. What conversion does FalkorDB pay today going RediSearch → node-id set → GraphBLAS vector, and what would a native roaring-masked mxv save?
References
Papers
- Chambi, Lemire, Kaser, Godin — “Better bitmap performance with Roaring bitmaps” (Software: Practice & Experience 2016, arXiv:1402.6407) — the array/bitmap containers and the kernel matrix (§3)
- Lemire, Ssi-Yan-Kai, Kaser — “Consistently faster and smaller compressed bitmaps with Roaring” (SPE 2016, arXiv:1603.06549) — adds the run container and the SIMD kernels
tantivy: Lucene’s architecture in readable Rust
The reference implementation for everything the previous chapters derived: FST term dictionary, bitpacked posting blocks with block-max skip data, BM25 as a table lookup, and an LSM-shaped indexer. Read it as four subsystems — analysis, term dictionary, postings, and the segment merger — everything below is anchored to source.
The read path, file by file
"quick fox" ──TextAnalyzer──► terms ──FST──► TermInfo ──► postings blocks ──► BM25 + WAND
tokenizer/ termdict/fst_termdict/ postings/ query/
| subsystem | anchor | what to see |
|---|---|---|
| analysis | tokenizer/tokenizer.rs TextAnalyzer — boxed Tokenizer + filter chain (lower_caser, stemmer, stop_word_filter, ngram…) | pipelines as composition, one dyn-dispatch per stream not per token |
| term dict | termdict/fst_termdict/termdict.rs:25 builder wraps tantivy_fst::MapBuilder; :46 insert(term, &TermInfo); :92 open_fst_index (mmap-friendly Fst::new(bytes)) | FST maps term bytes → term ordinal → TermInfoStore — prefix+suffix sharing beats a hash dict AND gives range/regex queries |
| term info | postings/term_info.rs:9-13 TermInfo { doc_freq, postings_range } | df rides in the dictionary — idf is known before touching postings |
| postings | postings/compression/mod.rs:3 COMPRESSION_BLOCK_SIZE = BitPacker4x::BLOCK_LEN (=128); :61 delta-encode against block_minus_one | 128 deltas bit-packed to the block’s max width; SIMD unpack |
| skip data | postings/skip.rs:93 SkipReader; :175 block_max_score(bm25_weight); :186 last_doc_in_block | block-max metadata lives in skip entries — moving blocks never decodes postings |
| scoring | query/bm25.rs:8-9 K1/B; :52 idf; :59 tf-norm via 1-byte fieldnorm table | scoring = table lookup + multiply-add |
| WAND | query/boolean_query/block_wand_union.rs:8-24 find_pivot_doc; sibling block_wand_intersection.rs | the SIGIR’11 paper, shipped |
The postings block format, distilled:
#![allow(unused)]
fn main() {
// 128 doc-id deltas, bit-packed to the WHOLE block's max width
fn write_block(docs: &[u32; 128], prev_last: u32, out: &mut Vec<u8>) {
let mut deltas = [0u32; 128];
for i in 0..128 {
deltas[i] = docs[i] - if i == 0 { prev_last } else { docs[i - 1] };
}
let bits = 32 - deltas.iter().max().unwrap().leading_zeros() as u8;
out.push(bits); // ONE width per block → SIMD unpacks all
bitpack(&deltas, bits, out); // 128 at once, no per-posting branches
}
// next to it, a skip entry: { last_doc, block_max_score } — WAND moves
// across blocks without ever decoding the losers
}
The write path = topic 4 wearing a hat
graph LR
A["IndexWriter<br/>(RAM budget)"] -->|flush| S1["segment (immutable):<br/>.term .idx .pos .fieldnorm .fast"]
S1 --> MP["LogMergePolicy<br/>indexer/log_merge_policy.rs:20-24:<br/>min_num_segments,<br/>max_docs_before_merge,<br/>level_log_size"]
MP -->|merge ~same-size tier| S2["bigger segment"]
D["deletes"] --> DB["alive bitset per segment<br/>(tombstones)"]
LogMergePolicy groups segments into log-size levels and merges
within a level — Lucene’s tiered compaction, not leveled: full-text
tolerates overlapping “levels” because every query fans out over all
segments anyway (there’s no key range to prune, unlike topic 4’s
SSTable ranges).
Fast fields (fastfield/) are the columnar side — doc values for
sorting/faceting — literally topic 12 embedded in a text index.
Suggested 90-minute read order
postings/term_info.rs+termdict/fst_termdict/termdict.rs(15’)postings/compression/mod.rsthenskip.rs(25’)query/bm25.rs(10’)query/boolean_query/block_wand_union.rs— compare with yourwand_topkafter implementing, not before (30’)indexer/log_merge_policy.rs(10’)
Questions (answer in notes.md)
- Why an FST and not a hash map for the term dictionary? List the
three query types the FST enables that a hash can’t, and the cost
(insert path —
MapBuilderneeds sorted keys, hence per-segment build + merge). TermInfo.doc_freqlives in the dictionary. Which of WAND’s inputs does that make free, before any posting is read?- BitPacker4x blocks of 128: what happens to the last <128 postings of a list (see compression/mod.rs’s vint fallback)? Compare with RediSearch’s always-varint choice.
- LogMergePolicy vs topic 4’s leveled compaction: why does overlapping-tiers hurt an LSM’s point reads but not a text index’s queries? What DOES more segments cost here?
- Quickwit runs tantivy segments on object storage (topic 28 preview): which of the five segment files does BM25 top-k actually need to fetch, and in what order — how does the layout minimize round trips?
References
Code
- tantivy — the anchors
above:
src/tokenizer/tokenizer.rs,src/termdict/fst_termdict/termdict.rs,src/postings/term_info.rs,src/postings/compression/mod.rs,src/postings/skip.rs,src/query/bm25.rs,src/query/boolean_query/block_wand_union.rs,src/indexer/log_merge_policy.rs— the 90-minute order above is the recommended pass
Inverted indexes: the whole design space in one survey
Zobel & Moffat’s CSUR 2006 survey compresses 30 years of IR engineering into 50 coherent pages. Read it as “the B-tree paper” of text indexing: everything since (Lucene, tantivy, RediSearch) is an implementation of choices this paper enumerates — which makes it the right first chapter of this topic.
The design space in one diagram
index granularity: doc ids only → +frequencies → +positions → +fields
(each level: bigger index, more query types)
posting order: doc-sorted ─── supports AND/WAND skipping (everyone)
frequency-sorted / impact-sorted ─── early termination
(§8; block-max WAND got the best of both)
compression: Golomb/Rice → variable-byte → word-aligned (Simple-9)
(2006's menu; today: PForDelta / bitpacking / roaring)
construction: in-memory inversion → sort-based → MERGE-BASED
(§5: build runs, merge them = Lucene segments = LSM)
update: rebuild / merge / in-place
(§7 concludes merge wins — Lucene's whole architecture)
What to actually read
| section | why |
|---|---|
| §2-3 | vocabulary + postings anatomy; the doc-id vs word-position granularity trade |
| §4 | compression: deltas are what make postings compressible at all — Zipf gives small gaps for common terms |
| §5 | merge-based construction — recognize topic 4’s LSM before Lucene made it famous |
| §6 | query eval: term-at-a-time vs doc-at-a-time (our oracle is TAAT, WAND is DAAT); the accumulator-limiting trick |
| §7 | index maintenance — why everyone chose immutable segments + merge |
| §8 | ranked retrieval + early termination — the WAND lineage starts here |
Vocabulary decoder ring
- TAAT (term-at-a-time): walk each term’s full list, accumulate
scores per doc — our
oracle_topk, simple, cache-friendly, no skipping. DAAT (doc-at-a-time): cursors advance in lockstep by doc id — enables WAND, needs doc-sorted lists. - accumulators: TAAT’s hash map of partial scores; §6’s insight is you can cap them (only allow ~1% of docs to hold accumulators) and lose almost no effectiveness — the 2006 answer to the problem WAND solves exactly.
- impact-sorted: postings ordered by score contribution, not doc id — perfect early termination, terrible AND. Block-max WAND is doc-sorted lists with impact metadata bolted on blocks.
TAAT in code — our oracle_topk, and the baseline every later
chapter is trying to beat:
#![allow(unused)]
fn main() {
// term-at-a-time: walk each term's WHOLE list, accumulate per doc
fn taat_topk(terms: &[PostingList], k: usize) -> Vec<(DocId, f32)> {
let mut acc: HashMap<DocId, f32> = HashMap::new(); // §6's accumulators
for t in terms {
for p in t.postings() { // every posting, every term —
*acc.entry(p.doc).or_default() // no skipping possible
+= bm25(t.idf, p.tf, p.doc_len);
}
}
top_k(acc, k)
// §6's insight: CAP the accumulator map (~1% of docs) and lose
// almost nothing — the 2006 answer to what WAND later solved exactly
}
}
Questions (answer in notes.md)
- Delta+compress works because Zipf makes common-term gaps small. What’s the expected gap for a term with df = n/2, and why does bitpacking 128-blocks (tantivy) beat per-posting varint (RediSearch) on exactly those terms?
- §6’s capped accumulators vs WAND: both bound work; which gives an exactness guarantee and what does the other buy instead?
- Merge-based construction (§5) vs topic 4’s LSM: map runs/merge passes onto memtable/flush/compaction. Where does Lucene’s tiered merge policy differ from leveled compaction and why does full-text tolerate it?
- Positions multiply index size ~3×. For M23’s node/edge property
search, when do you actually need them (phrase queries on
descriptionprops?) and what’s the cheaper substitute? - The survey predates learned/neural retrieval entirely. Which of its cost models still bind a BM25+vector hybrid (M23), and which are obsoleted by the ANN side?
References
Papers
- Zobel, Moffat — “Inverted Files for Text Search Engines” (ACM Computing Surveys 2006) — read §2-8 with the section map above; §5 and §7 are where Lucene’s architecture comes from
Topic 23 notes — full-text search & inverted indexes
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
Corpus: 100K docs, vocab 50K zipf θ=1.0, ~10M tokens, 7.87M postings. Gen 236 ms, index build 335 ms (single thread, HashMap tf-counting). df(t0)=99888 (in 99.9% of docs — “the”), df(t100)=8259, df(t10000)=83.
BM25 top-10, exhaustive TAAT oracle
| query | ms | postings walked | top1 score |
|---|---|---|---|
| common∧common [t0 t1 t5] | 8.75 | 272,310 | 0.612 |
| mid∧mid [t100 t1000] | 0.507 | 9,098 | 9.142 |
| common∧rare [t0 t12000] | 6.34 | 99,964 | 8.975 |
| rare∧rare [t9000 t15000] | 0.008 | 159 | 9.208 |
The common∧rare row is the WAND poster child: 99,964 postings walked but the rare term (df=83, idf≈9.0) contributes ~93% of the top-1 score — nearly all of t0’s 99,888 postings are provably hopeless once the heap holds 10 docs that contain t12000. ~32 ns/posting for the oracle (hash accumulate dominates — topic 22’s Q1 story again).
Posting-list AND/OR, sorted-vec two-pointer
| pair | AND µs | OR µs | result size |
|---|---|---|---|
| t0∧t1 (99888 ∩ 97580) | 97 | 116 | 97,474 |
| t0∧t5000 (99888 ∩ 172) | 52 | 81 | 172 |
Dense∧sparse costs HALF of dense∧dense despite a 567× smaller output — two-pointer is O(|dense|); the walk of t0 is the price. That asymmetry is the roaring (probe 172 u16s into bitmaps) and galloping motivation.
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| wand docs_scored on common∧rare [t0 t12000] (oracle walks 99,964) | ||
| wand on common∧common [t0 t1 t5] — does block-max help when all idfs are low? | ||
| wand speedup over oracle, common∧rare, wall clock ×? | ||
| roaring t0∧t1 AND (both ~bitmap containers) vs 97 µs vec ×? | ||
| roaring t0∧t5000 AND vs 52 µs vec ×? | ||
roaring t0 memory vs 400 KB Vec<u32> |
Implementation log
- wand.rs
wand_topk— 3 oracle-match tests + work bound green - postings.rs
Roaring— sparse/dense/mixed oracle tests green - prediction table reconciled
- stretch: quantize block max_score to u8 (Lucene-style), verify top-k unchanged (bounds may only round UP)
- stretch: galloping vec_and, three-way race vec/gallop/roaring
- stretch: RRF fusion demo — BM25 top-k + a fake vector top-k,
1/(60+rank)sum, the M23 hybrid in 20 lines
Surprises / dead ends:
- df(t0) = 99,888 of 100K docs: zipf θ=1.0 over a 50K vocab puts rank-0 in essentially every 100-token doc. Realistic (“the”) but it means common-term posting lists here are ~dense bitsets — good for the roaring dense lane, remember it when reading AND numbers.
- catch_unwind lanes again saved the bench binary: stub panics print
[stub — implement …]and the provided lanes still report.
Questions from the reading guides
Zobel & Moffat (reading-zobel-moffat.md)
- Expected gap at df=n/2; why 128-block bitpack beats varint there:
- Capped accumulators vs WAND — which is exact, what does the other buy:
- Runs/merges ↔ memtable/flush/compaction; tiered-vs-leveled for text:
- When M23 needs positions vs cheaper substitutes:
- Which cost models survive the BM25+vector hybrid:
BM25 (reading-bm25.md)
- tf for 90% of the K1+1 ceiling; keyword-stuffing implication:
- idf smoothing at df=0 and df=N:
- b=0.75→0 on uniform-length corpus vs tweets+books:
- 1-byte fieldnorm worst-case score error; why ranking survives:
- Where M23 gets relevance feedback for full RSJ weights:
Block-max WAND (reading-blockmax-wand.md)
- θ after heap fills on [t0 t12000]; can t0 alone cross it:
- Why block-max helps most on common terms (block-max variance):
- Metadata overhead/posting at 128; u8 quantization direction rule:
- Hybrid with unbounded vector scores — WAND candidates + RRF vs fused:
- Deleted docs holding block maxima — still exact? merge-time fix:
Roaring (reading-roaring.md)
- Derive 4096 crossover; when run containers win; doc-id locality:
- Predicted bitmap∧bitmap cost for t0∧t1 vs 97 µs measured vec:
- Galloping vs container probe — where roaring wins on memory traffic:
- Roaring containers ↔ GraphBLAS sparse/bitmap format lattice:
- RediSearch→GraphBLAS conversion cost FalkorDB pays; native saving:
tantivy (reading-tantivy.md)
- FST vs hash dict — three query types, the sorted-insert cost:
- df-in-dictionary makes which WAND input free:
- Tail <128 postings — vint fallback vs RediSearch always-varint:
- Tiered segments fine for text, bad for LSM point reads — why:
- Quickwit on S3: which files fetched, what order:
RediSearch (reading-redisearch.md)
- Effective block-size policy; why variable blocks resist block-max:
- gc_marker/unique_id ↔ delta-matrix wait/version:
- Codec ladder ↔ Lucene positions/doc-values taxonomy:
- Varint vs bitpack bytes/posting at delta≈1; where varint wins:
- What to lift verbatim into falkordb-rs-next-gen; what the graph changes:
Cross-topic threads
- Segments + merge policies = topic 4’s LSM; tiered-not-leveled works because text queries fan out to all segments anyway (no key-range pruning). Deletes-as-bitmap = tombstones; RediSearch GC = in-place compaction.
- BM25 scoring loop = topic 22’s YCSB lesson: the accumulator hash map dominates (32 ns/posting TAAT) — WAND is to search what the flat-array group-by was to Q1: remove the hash from the hot loop.
- Block-max metadata = topic 3’s B-tree fence keys but for SCORES; skip-without-decode = topic 12’s zone maps, exactly (min/max per block, prune before touching data).
- Varint-vs-bitpack = topic 17: byte-at-a-time branchy decode caps throughput; 128-wide unpack is the SIMD lane.
- FST term dictionary = topic 2/3’s ordered-dictionary trade: hash gives O(1) lookup, FST gives prefix/range/regex + compression — same reason B-trees beat hashes for range scans.
- Roaring containers = topic 20’s GraphBLAS sparse↔bitmap format switch at 64K-chunk granularity; M23’s hit-set → masked mxv makes the connection literal.
- Hybrid search RRF = topic 14’s HNSW top-k fused with BM25 top-k — rank-based fusion sidesteps incomparable score scales.
M23 log (capstone)
- analyzer + per-property inverted index over node/edge string props (codec: doc_ids_only for filters, freqs for ranked)
- BM25 top-k procedure with block-max WAND; node ids = doc ids
- hit-set as roaring bitmap → mask input to M20 matrix ops
- RRF hybrid with M14’s HNSW lane
- decide mutable-chained-blocks (RediSearch) vs immutable-segment (tantivy) — leaning segments + merge, matching the LSM the capstone already has
Done when
- Both stubs green with lanes filled; prediction table reconciled; guide questions answered; M23 design decision (segments vs mutable blocks) argued in writing.
Topic 24 — Advanced Graph Algorithms & Analytics
Traversal (topics 13/20) is table stakes; this topic is the analytics
layer — centrality, components, communities, triangles — and the
recurring question: when does the algebraic (LAGraph) formulation
beat the frontier-based (GAP/Ligra) one? M24 turns the answer into a
CALL algo.* procedure library over the sparse core.
The map
per-source whole-graph
paths │ BFS (t20), Dijkstra, │ APSP (don't)
│ delta-stepping (STUB) │
central │ Brandes BC (STUB) │ PageRank (provided),
│ = BFS + backprop │ harmonic/closeness
structure│ │ CC: union-find (provided)
│ │ vs Afforest (STUB),
│ │ triangles (provided),
│ │ k-truss, Louvain→Leiden
graph LR
subgraph frontier world — gapbs, Ligra
F["explicit worklists,<br/>atomics (CAS on depths),<br/>direction switching"]
end
subgraph algebraic world — LAGraph over GraphBLAS
A["frontier = sparse vector,<br/>step = masked mxv/mxm,<br/>semiring picks the algorithm"]
end
F ---|"same asymptotics,<br/>different constants +<br/>parallelism story"| A
The trade in one line: frontier code exploits per-vertex tricks (Afforest skips edges, Brandes’ succ bitmap) that algebra can’t express cheaply; algebra gets batching (LAGr_Betweenness runs MANY sources as one matrix frontier), no atomics, and free format/ direction switching from the runtime (topic 20’s dot3-vs-saxpy).
Measured baselines (algo_bench, M3 Pro, single thread)
RMAT scale 16 (n=65,536, m=1.82M directed, max deg 9,751) vs uniform (same n/m, max deg 59):
| lane | rmat | uniform |
|---|---|---|
| PageRank to 1e-4 | 8 iters, 10.8 ms, 1.35 GTEPS-ish | 6 iters, 7.9 ms |
| Triangle count | 15,645,988 in 376 ms | 5,428 in 158 ms |
| Dijkstra ×3 sources | 33.7 ms, 343K pops | — |
| CC union-find | 18,844 comps, 4.2 ms, all m edges | — |
The TC row is the whole “skew matters” lecture: same n and m, 2883× more triangles — hub neighborhoods intersect. Any TC benchmark on uniform data measures a different algorithm.
The stubs and what each teaches
delta_stepping(sssp.rs) — the Dijkstra↔Bellman-Ford dial: δ=1 buys ordering (no wasted relaxations, no parallelism), δ=∞ is one big Bellman-Ford bucket. Stats expose the trade.brandes(bc.rs) — dependency accumulation replaces per-pair path counting; oracle is the O(n³) definition, so the stub must reproduce exact BC, then sample sources GAP-style.afforest(cc.rs) — union-find was never the bottleneck; EDGE INSPECTIONS are. Two neighbor-rounds + frequent-component sampling skip the giant component’s edges entirely (test demands <50% of m inspected).
Reading guides
- reading-gap.md — The GAP benchmark suite: five graphs so the wrong winner can’t win
- reading-delta-stepping.md — Δ-stepping: the dial between Dijkstra and Bellman-Ford
- reading-brandes.md — Brandes betweenness: restructure the sum, not the data structure
- reading-ligra.md — Ligra: two functions, every frontier algorithm
- reading-louvain-leiden.md — Louvain to Leiden: communities that stay connected
- reading-lagraph-algos.md — Analytics with four verbs: LAGraph’s algorithm shelf
- topic 20:
reading-lagraph.md(BFS push/pull) andreading-beamer-sc12.md— direction switching’s origin
Experiments
| file | status | what it shows |
|---|---|---|
graph.rs | provided | weighted CSR, RMAT (skewed) + uniform generators |
sssp.rs dijkstra | provided | heap Dijkstra with pop counter |
sssp.rs delta_stepping | stub | bucketed SSSP, work-vs-ordering dial |
bc.rs bfs_sigma+bc_brute | provided | path counting + O(n³) definitional oracle |
bc.rs brandes | stub | dependency accumulation, exact + sampled |
cc.rs cc_unionfind | provided | exact baseline, all edges |
cc.rs afforest | stub | sampling CC, edges_inspected ≪ m |
analytics.rs | provided | pull PageRank, degree-ordered triangle count |
bin/algo_bench.rs | provided | rmat-vs-uniform lanes, stubs in catch_unwind |
M24 checklist (capstone)
- algorithm library over the M20 sparse core: PR, BFS, CC, BC, SSSP, TC — algebraic where it wins, frontier where it doesn’t (document each choice)
- Cypher procedure surface:
CALL algo.pagerank(...)— FalkorDB already wrapsLAGr_PageRankinsrc/procedures/proc_pagerank.c:197; copy the shape, replace the engine - GAP-style regression lanes in M22’s standing suite (BFS/SSSP/ PR/CC/BC/TC on RMAT + uniform, both formulations)
Brandes betweenness: restructure the sum, not the data structure
Betweenness centrality by definition is an all-pairs O(n³) sum; Brandes
turned it into O(V·E) with one algebraic observation — and it’s the
cleanest example of speeding an algorithm up by restructuring the SUM
rather than the data structure. Our bc::brandes stub implements it
against the O(n³) definitional oracle; gapbs’s bc.cc and LAGraph’s
LAGr_Betweenness.c show the two production shapes.
The restructuring
definition: bc(v) = Σ_{s≠v≠t} σ_st(v) / σ_st
(our bc_brute: all-pairs BFS + triple loop, O(n³))
Brandes' observation: fix s and define the DEPENDENCY
δ_s(v) = Σ_t σ_st(v)/σ_st
then δ_s satisfies a recurrence over the BFS DAG, deepest first:
δ_s(v) = Σ_{w : v ∈ pred_s(w)} (σ_sv / σ_sw) · (1 + δ_s(w))
so per source: one forward BFS (depths + σ) + one backward sweep.
bc(v) = Σ_s δ_s(v). n sources × O(E) each = O(V·E).
The recurrence is the entire paper — derive it once by hand (partition shortest s→t paths through v by v’s DAG successor w; the 1 accounts for t=w itself).
The per-source backward sweep, transcribed:
#![allow(unused)]
fn main() {
// after a forward BFS from s: depth[], sigma[] (path counts),
// and order = vertices sorted by depth
fn accumulate(bc: &mut [f64], order: &[u32], g: &Csr,
depth: &[i32], sigma: &[f64]) {
let mut delta = vec![0.0; g.n];
for &w in order.iter().rev() { // deepest FIRST
for v in g.in_edges(w) {
if depth[v] + 1 == depth[w] { // (v,w) is a DAG edge
delta[v] += sigma[v] / sigma[w] // split w's paths...
* (1.0 + delta[w]); // ...the 1 = t=w itself
}
}
if w != s { bc[w as usize] += delta[w as usize]; }
}
}
}
The two production shapes
| gapbs bc.cc | LAGraph LAGr_Betweenness.c | |
|---|---|---|
| forward | PBFS (:51): CAS on depths, records succ BITMAP (:76) — “is (u,v) a DAG edge” = one bit | frontier/paths are ns×n MATRICES (:110-164) — a BATCH of sources advances as one masked mxm |
| σ | path_counts accumulated at depth boundaries (depth_index slices the BFS queue by level) | paths += frontier per level, FP64 semiring |
| backward | deepest-first over depth_index, reads succ | transposed mxm per level with bc_update matrix |
| sampling | k sources, scores scaled | sources array — batch size = ns |
| wins | per-edge constants, one bitmap read per edge | no atomics; 4-32 sources amortize each matrix pass |
The batched-matrix trick is the one to remember for M24: BC over 32 sampled sources = the SAME number of graph passes as one source, just with 32-row frontier matrices — SpGEMM amortizes what frontier code cannot (it would need 32 separate BFS queues).
Traps for the stub
- σ must be accumulated ONLY along depth+1 edges (BFS DAG), and
backprop must iterate strictly deepest-first — bucket vertices by
depth after
bfs_sigma, don’t re-walk the queue out of order. - σ overflows u64 fast on dense graphs (σ multiplies along
diamonds) — that’s why everyone (gapbs
CountT, LAGraph FP64, us) uses floats for path COUNTS. Exactness of the RATIO survives. - Disconnected sources: unreachable v has depth -1 — contribute nothing, don’t divide by σ=0 (our RMAT has 18,844 components; the test will catch you).
- Convention check: directed-sum over ordered (s,t) on a symmetric graph double-counts undirected pairs. Fine — but halve if you ever compare against NetworkX’s undirected numbers.
Questions (answer in notes.md)
- Derive the recurrence from the definition (the partition-by- successor argument). Where does the “+1” come from?
- bc_brute is O(n³) time but also O(n²) MEMORY (all-pairs depths+σ). Brandes is O(V·E) time, O(V) extra memory per source. At what n/m does the brute oracle stop fitting in LLC, and does that matter for a CORRECTNESS oracle?
- gapbs’s succ bitmap vs re-checking depth[w]==depth[v]+1: count memory touches per backprop edge for both. Why does the bitmap win despite costing a bit per EDGE?
- LAGraph batches ns sources into one matrix. What limits ns
(memory = ns×n FP64 dense rows in
paths) and where’s the sweet spot on our 65K-node RMAT? - FalkorDB has
proc_betweenness.ccalling LAGraph. M24: what shouldCALL algo.betweenness(samples: 32)return when the graph changed under a delta matrix that hasn’t been flushed (topic 20’s wait) — flush first, or compute on the stale main matrix?
References
Papers
- Brandes — “A Faster Algorithm for Betweenness Centrality” (J. Math. Sociology 2001) — the dependency recurrence is the whole paper; derive it by hand once
Code
- gapbs
src/bc.cc— frontier Brandes with thesuccbitmap trick - LAGraph
src/algorithm/LAGr_Betweenness.c— batched-source matrix formulation (:110-164)
Δ-stepping: the dial between Dijkstra and Bellman-Ford
Meyer & Sanders’ paper put a DIAL between Dijkstra (perfect ordering,
zero parallelism) and Bellman-Ford (perfect parallelism, wasteful
work): bucket vertices by tentative distance and relax a bucket at a
time. This chapter derives the dial, then compares the two production
readings of it — gapbs’s frontier version and LAGraph’s algebraic
one — which our sssp::delta_stepping stub sits between.
The dial
Dijkstra: settle ONE vertex at a time, strict order
→ zero wasted relaxations, zero parallelism
Bellman-Ford: relax EVERYTHING, |V| rounds
→ embarrassing parallelism, embarrassing waste
Δ-stepping: buckets of width Δ by tentative distance
bins[i] = { v : dist(v) ∈ [iΔ, (i+1)Δ) }
process buckets in order; INSIDE a bucket, relax in
parallel and re-relax until stable (light edges
w < Δ can re-insert into the current bucket)
Δ→min_weight ⇒ Dijkstra (every bucket ≤ 1 settle-round)
Δ→∞ ⇒ Bellman-Ford (one bucket, all rounds inside it)
The bucket loop, in one screen:
#![allow(unused)]
fn main() {
fn delta_stepping(g: &Csr, src: u32, delta: u64) -> Vec<u64> {
let mut dist = vec![u64::MAX; g.n]; dist[src as usize] = 0;
let mut bins: Vec<Vec<u32>> = vec![vec![src]]; // bins[i] = [iΔ, (i+1)Δ)
let mut i = 0;
while i < bins.len() {
while let Some(u) = bins[i].pop() { // bucket i can REFILL
let du = dist[u as usize];
if du / delta < i as u64 { continue; } // stale entry — skip
for (v, w) in g.edges(u) { // relax; parallel-safe:
let nd = du + w; // min is idempotent
if nd < dist[v as usize] {
dist[v as usize] = nd;
let b = (nd / delta) as usize; // light edge ⇒ b == i:
bins.resize(bins.len().max(b + 1), vec![]);
bins[b].push(v); // re-enters this bucket
}
}
}
i += 1; // bucket i settled exactly
}
dist
}
}
The paper’s analysis: for random weights and low-diameter graphs there’s a Δ giving near-linear work AND polylog depth. On road networks (huge diameter, few vertices per distance band) the buckets are nearly empty — no parallelism at any Δ. That’s why GAP includes road.
The two implementations
| gapbs sssp.cc | LAGraph LAGr_SingleSourceShortestPath.c | |
|---|---|---|
| bucket | thread-local vector bins (:32-44), merged at sync points | tmasked sparse vector = current bucket (:100-142) |
| relax | explicit RelaxEdges with CAS-free benign races (:69-79) | one GrB_vxm with MIN_PLUS semiring (:151-185) per inner iteration |
| stale entries | left in old bins; skipped when drained (:44 — redundancy beats bookkeeping) | mask + select prune them algebraically |
| light/heavy split | skipped entirely (re-relax instead) | skipped too; Delta is a GrB_Scalar knob |
Same lesson as topic 20’s BFS: the algebraic version is ~15 lines of semiring calls and inherits parallelism from the runtime; the frontier version owns its memory layout and wins constants.
MIN_PLUS is the whole algorithm: dist’ = dist min.+ (dist ⊗ A) — SSSP is matrix “multiplication” over the tropical semiring; Δ-buckets are just a sparsity filter on which rows participate per step.
Implementation traps (for the stub)
- A vertex drained from bucket i whose dist has since improved
below iΔ is STALE — skip it (our Dijkstra’s
d > dist[u]check, bucketed edition). Without this you still get right answers, but the relaxation counter lies. new_dist / deltacan exceed the bins vec — grow it lazily; don’t precompute max_dist/Δ (you don’t know max_dist yet).- Bucket i can refill while you drain it (light edges) — loop until bucket i is empty before moving to i+1, or you break the ordering invariant that makes answers exact.
Questions (answer in notes.md)
- Our RMAT has weights uniform 1..=255. Predict the relaxations-vs-Δ curve for Δ ∈ {16, 128, 1024, 2^40} against Dijkstra’s 343K pops (fill the notes table BEFORE implementing).
- Δ=1 with integer weights: exactly which Dijkstra do you get, and why is it still cheaper than a binary heap (hint: Dial’s algorithm, O(1) bucket ops)?
- Why do thread-local bins + benign write races (gapbs :32) not
corrupt distances? What property of
minmakes the race safe — and which GraphBLAS concept is that (idempotent monoid)? - LAGraph does one vxm per INNER iteration — how does the number of vxm calls relate to (max_dist/Δ + reinsertions)? Where does the algebraic version pay that gapbs doesn’t?
- M24: FalkorDB’s weighted-shortest-path today is
algo.SPpaths/ BFS-flavored. SketchCALL algo.sssp(src, 'weight', delta)over the M20 core: which semiring, which vector becomes the bucket, and where does Δ live in the API?
References
Papers
- Meyer & Sanders — “Δ-Stepping: A Parallelizable Shortest Path Algorithm” (J. Algorithms 2003) — the dial and its analysis; the road-network caveat is in the analysis sections
Code
- gapbs
src/sssp.cc— frontier version, thread-local bins; the :32-44 header comment explains why redundancy beats bookkeeping - LAGraph
src/algorithm/LAGr_SingleSourceShortestPath.c— the algebraic version: one MIN_PLUSGrB_vxmper inner iteration
The GAP benchmark suite: five graphs so the wrong winner can’t win
The yardstick for graph analytics: 6 kernels × 5 graphs, plus
REFERENCE IMPLEMENTATIONS that are themselves state-of-the-art
single-node code. Read the paper for the methodology (it’s topic 22’s
fair-benchmarking argument specialized to graphs), read src/ for
the algorithms — each .cc file opens with a mini-paper.
The suite
kernels: BFS SSSP PR CC BC TC
graphs: twitter (skew) web (locality) road (diameter!)
kron (RMAT synthetic) urand (uniform synthetic)
│
every kernel × every graph, 64 trials from random sources —
because ONE graph shape crowns the wrong winner:
road kills delta-stepping's parallelism (long diameter),
urand kills direction-optimizing BFS (no hubs),
kron/twitter kill anything O(max_degree²)
Code anchors (each file’s header comment = required reading)
| file | algorithm | the trick |
|---|---|---|
src/bfs.cc | direction-optimizing | topic 20’s guide covers it — α=15, β=18 here |
src/sssp.cc:87 | DeltaStep | thread-local bins (:32 comment); :44: redundant relaxation is CHEAPER than removing stale entries — same lazy-deletion bet as our Dijkstra oracle |
src/bc.cc:51 | Brandes | PBFS records a succ BITMAP (:76) so backprop tests “is w my BFS successor” in one bit — no depth recheck |
src/cc.cc:95 | Afforest | :106 neighbor_rounds=2 link sweeps, :69 SampleFrequentElement (1024 samples), :129 final sweep skips the giant component |
src/pr.cc:31-57 | pull PR | kDamp .85, L1-error stop; pr_spmv.cc is the same as one SpMV per iter — the algebraic identity made explicit |
src/tc.cc:52-99 | ordered TC | OrderedCount after RelabelByDegree if WorthRelabelling (:75 samples degree skew) |
Methodology worth stealing for M22/M24
- Trials from random sources, report ALL: source choice changes BFS/SSSP/BC time by >10× on skewed graphs (a hub source vs a leaf). Our bench uses 3 fixed sources — upgrade when it matters.
- Kernel spec ≠ implementation: GAP specifies input/output, so algebraic (LAGraph runs GAP too) and frontier codes compete honestly. LAGr_PageRankGAP exists because GAP’s PR spec (stop on L1 error, handle dangling like gapbs) differs from textbook PR — benchmark specs bind implementations.
- The 5-graph matrix is the point: our rmat-vs-uniform TC baseline (15.6M vs 5.4K triangles, same n/m) is GAP’s argument in one row.
Questions (answer in notes.md)
- Why does GAP include road networks at all — which of the 6 kernels ranks implementations DIFFERENTLY on road vs twitter, and what property (diameter, degree variance) drives each flip?
- sssp.cc:44 argues redundant relaxations beat precise bucket removal. Under what edge-weight distribution does that bet fail?
- bc.cc approximates with 16 sources by default. On our RMAT (18,844 components!), what systematic error does source sampling introduce and how would you stratify?
- pr.cc vs pr_spmv.cc: same math, different memory access. Which wins on kron and why (hint: pull = gather = topic 20’s SpMV 16-19 GB/s lane)?
- GAP has no Louvain/Leiden kernel. What makes community detection benchmark-hostile (hint: nondeterminism, tie-breaking, quality-vs-speed frontier)?
References
Papers
- Beamer, Asanović, Patterson — “The GAP Benchmark Suite” (arXiv:1508.03619) — read for the methodology: why these 5 graphs, why 64 trials from random sources
Code
- gapbs
src/— each kernel’s header comment is a mini-paper; required reading before the code
Analytics with four verbs: LAGraph’s algorithm shelf
Topic 20’s guide covered BFS and the framework; this chapter reads
LAGraph’s ANALYTICS algorithms — CC, TC, BC, SSSP, PR — as answers to
“what does this look like when the only verbs are
mxv/mxm/semiring/mask”. Plus the punchline: FalkorDB already ships
these (proc_pagerank.c:197 calls LAGr_PageRank;
proc_betweenness.c, proc_cdlp.c likewise) — M24 is re-plumbing a
pattern that exists, not inventing one.
LG_CC_FastSV7.c — connected components, algebraically
| anchor | what |
|---|---|
:69-71 | the state: mngp (min neighbor grandparent), gp, gp_new — SV’s hooking/shortcutting as three vectors |
:102 | hooking = ONE mxv: mngp = min_2nd(A, gp) — every vertex reads its neighbors’ grandparents in one masked matrix op |
:145-158 | shortcutting: parent = min(parent, mngp) via mxv on a PARENT MATRIX + gp_new = parent(parent) (extract = pointer chase as assign) |
:335-338 | sampling: FASTSV_SAMPLES per row, sampling = nvals > n*samples*2 && n > 1024 — Afforest’s idea imported |
:231-235 | built-in timing printfs: sample phase vs hash phase vs final mxv — SuiteSparse’s authors profile like topic 0 |
FastSV vs our Afforest stub: same “don’t touch every edge” goal, different mechanism — FastSV samples COLUMNS per row inside matrix ops (still bulk-synchronous), Afforest samples per-vertex neighbor OFFSETS with a union-find (asynchronous, per-edge early exit). Frontier-vs-algebra in one algorithm.
One FastSV round, de-algebra’d — three bulk ops where union-find does per-edge pointer chases:
#![allow(unused)]
fn main() {
fn fastsv_round(a: &SparseMat, parent: &mut [u32], gp: &mut [u32]) -> bool {
// hooking: every vertex reads all neighbors' grandparents AT ONCE
let mngp = a.mxv_min_2nd(gp); // mngp[v] = min gp[u] over u∈N(v)
let mut changed = false;
for v in 0..parent.len() { // shortcutting, elementwise
let m = mngp[v].min(gp[v]);
if m < parent[v] { parent[v] = m; changed = true; }
}
for v in 0..gp.len() { gp[v] = parent[parent[v] as usize]; } // = extract
changed
}
}
LAGr_TriangleCount.c — six formulations of one count
:33-37 0 default (currently Sandia_LUT)
2 Cohen: ntri = sum((L*U) .* A) / 2
3 Sandia_LL: ntri = sum((L*L) .* L)
4 Sandia_UU: ntri = sum((U*U) .* U)
5 Sandia_LUT: ntri = sum((L*U') .* L) ← dot product form
6 Sandia_ULT: ntri = sum((U*L') .* U)
:44-47 LUT fastest on large graphs EXCEPT GAP-urand, where
saxpy-based LL wins — the dot-vs-saxpy split (topic 20)
decided by TRIANGLE DENSITY, not just matrix shape
The masked SpGEMM (L*U').*L never materializes L*U’ — the mask
prunes the multiply (Azad & Buluç). Our scalar triangle_count is
Sandia’s formulation with rank-ordered adjacency instead of tril:
“orient by degree, intersect forward lists” IS (L*L).*L read
row-wise. One measured point: rmat 15.6M triangles in 376 ms vs
uniform 5.4K in 158 ms — method choice (:44) flips exactly because
urand has ~no triangles to prune with.
The rest, rapid-fire
LAGr_PageRankGAP.cvsLAGr_PageRank.c: GAP-spec PR (dangling handled gapbs-style, L1 stop) vs textbook. Benchmark specs fork implementations — topic 22’s lesson in filenames.LAGr_SingleSourceShortestPath.c:151-185: MIN_PLUS delta-stepping (see reading-delta-stepping.md).LAGr_Betweenness.c:110-164: batched-source matrix Brandes (see reading-brandes.md).LG_CC_Boruvka.cexists as the “simple” CC — compare its mxv count per round against FastSV7’s three.
FalkorDB tie-in (M24’s actual shape)
proc_pagerank.c: parse args → get the delta-matrix-backed A →
flush/export to a GrB_Matrix → LAGr_PageRank (:197) → map
scores back to node ids → stream results. The costs to attack in
falkordb-rs-next-gen: the export/flush boundary (can algorithms run
masked over DM/DP directly?) and result materialization (stream
top-k instead of full vectors?).
Questions (answer in notes.md)
- FastSV7:102’s min_2nd semiring: why 2nd (take the neighbor’s gp, ignore edge values) — and what breaks with plain MIN_TIMES on a weighted graph?
- Count matrix ops per FastSV round vs pointer-chases per Afforest round. On a diameter-2 RMAT giant component, which converges in fewer ROUNDS, and why does Afforest still win wall-clock?
- Sandia_LUT (dot) vs Sandia_LL (saxpy) — connect :44-47’s urand exception to topic 20’s dot3-vs-saxpy3 rule. What property of urand (no hubs, no triangles) starves the dot-form’s mask?
- LAGr_PageRankGAP handles dangling vertices with an extra reduction per iteration. Our pull PR ignores them — quantify the error on a graph with 18K single-node components.
- M24 API:
CALL algo.wcc()on a graph with pending deltas — enumerate the three options (flush first / run on main / run on main+DP-DM masked) and their consistency semantics (topic 8’s read-your-writes for procedures).
References
Code
- LAGraph
src/algorithm/—LG_CC_FastSV7.c,LAGr_TriangleCount.c,LAGr_PageRankGAP.c,LAGr_SingleSourceShortestPath.c,LAGr_Betweenness.c,LG_CC_Boruvka.c— each file’s header comment states the formulation before the code - FalkorDB
src/procedures/proc_pagerank.c(:197 callsLAGr_PageRank),proc_betweenness.c,proc_cdlp.c— M24’s shape, already shipping
Ligra: two functions, every frontier algorithm
Two functions — vertexMap and edgeMap — and every frontier
algorithm in ~50 lines each (apps/). Ligra’s contribution is
making direction switching (topic 20’s Beamer trick, invented for
BFS) a FRAMEWORK property every algorithm inherits for free.
The whole framework
vertexSubset: a frontier, physically EITHER
sparse: array of vertex ids (small frontiers)
dense: boolean array of size n (big frontiers)
edgeMap(G, frontier, F, threshold):
if |frontier| + Σ out_degrees(frontier) > m/20: ← ligra.h:238,261
DENSE: for each v ∈ V, scan IN-edges, stop early
(pull; reads frontier bitmap) ligra.h:59
else:
SPARSE: for each u ∈ frontier, push OUT-edges ligra.h:111
F(u,v) does the algorithm-specific update, returns
whether v joins the next frontier
Anchors: ligra/ligra.h:235-272 edgeMapData — the switch;
:238 the m/20 default threshold; :59 edgeMapDense vs :111
edgeMapSparse; :85 edgeMapDenseForward (push in dense clothing,
when early-exit doesn’t apply).
The switch, as code — everything else in Ligra is plumbing around it:
#![allow(unused)]
fn main() {
fn edge_map(g: &Graph, front: &VertexSubset, f: &impl Fn(u32, u32) -> bool)
-> VertexSubset {
if front.len() + front.out_degree_sum(g) > g.m / 20 {
// PULL: scan every vertex's IN-edges, early-exit once claimed
let mut next = DenseBits::new(g.n);
for v in 0..g.n {
for u in g.in_edges(v) {
if front.contains(u) && f(u, v) { next.set(v); break; }
}
}
next.into()
} else {
// PUSH: only frontier vertices' OUT-edges; f returns "v joins next"
front.iter().flat_map(|u| g.out_edges(u)
.filter(|&v| f(u, v)).map(move |v| v)).collect()
}
}
}
Reading the apps (each is a one-pager)
| app | F(u,v) | frontier evolution |
|---|---|---|
apps/BFS.C | CAS parent[v] | classic expanding→shrinking wave |
apps/BC.C | add σ contributions; TWO passes (forward + Brandes backward, both as edgeMaps) | dense mid-BFS — direction switch fires |
apps/Components.C | label-propagation min | frontier = “changed last round” |
apps/BellmanFord.C | writeMin dist | stays dense on low-diameter graphs |
apps/PageRank.C | sum contributions | ALWAYS dense — edgeMap degenerates to SpMV |
The lesson in the table’s last row: for whole-graph kernels (PR), Ligra ≡ SpMV and the algebraic formulation is identical. Frontiers only earn their complexity when they SHRINK — Ligra generalizes the case where they do.
Ligra vs GraphBLAS, honestly
- edgeMap’s F is an arbitrary function with CAS — semirings must be (monoid, binop) pairs. Afforest’s “link only the r-th neighbor” fits neither cleanly (it’s not an edgeMap either — it’s a strided edge SAMPLE; frameworks leak).
- Ligra’s dense mode reads IN-edges: it needs both G and Gᵀ resident — same memory doubling FalkorDB pays for its transposed twin (topic 20). Nobody escapes the transpose.
- The m/20 threshold vs Beamer’s α/β vs SuiteSparse’s dot-vs-saxpy auto-switch: three names for one decision — work(push) ∝ frontier out-degree sum vs work(pull) ∝ m with early exit.
Questions (answer in notes.md)
- Derive when m/20 is the wrong threshold: construct a frontier whose out-degree sum is just under m/20 but whose PUSH cost exceeds pull’s (hint: early-exit effectiveness depends on how FULL the next frontier will be, which the threshold can’t see).
- edgeMapDenseForward (:85) pushes from ALL vertices without early exit. When does it beat edgeMapDense (pull with break)?
- BC.C runs Brandes’ backward pass as edgeMaps over the TRANSPOSE. Map each Ligra construct onto the LAGr_Betweenness matrix ops — which of the two batches sources, and why can’t Ligra?
- Components.C is label propagation (frontier = changed vertices); our Afforest stub is sampling+union-find. Compare edges touched on a graph that’s one giant component vs 18K components.
- M24: should the capstone’s algorithm library expose an edgeMap- style callback API to users (arbitrary Rust closures over edges) or a fixed algorithm menu like FalkorDB’s procedures? What does Ligra’s F-with-CAS cost a SAFE embedding (Rust: Send+Sync bounds, no UDF aborts mid-frontier)?
References
Papers
- Shun & Blelloch — “Ligra: A Lightweight Graph Processing Framework for Shared Memory” (PPoPP 2013) — §3-4 for the two primitives and the threshold; the apps section reads faster as code
Code
- ligra —
ligra/ligra.h(:235-272edgeMapData, the switch) andapps/(each algorithm is a one-pager)
Louvain to Leiden: communities that stay connected
Community detection’s most-used algorithm (Louvain) has a bug in its GUARANTEES, not its code: it can output communities that are internally DISCONNECTED. Traag, Waltman & van Eck demonstrate it, explain why, and fix it with one extra phase. Read it as a correctness paper wearing a clustering costume — very topic-16.
Modularity + Louvain in five lines
Q = (1/2m) Σ_ij [ A_ij − k_i·k_j/2m ] · δ(c_i, c_j)
"edges inside communities, minus what a degree-preserving
random graph would put there" (γ = resolution knob)
Louvain: repeat until stable:
1. local moves: greedily move single vertices to the neighbor
community with max ΔQ (fast: ΔQ is O(deg) to eval)
2. aggregate: contract each community to a super-vertex,
recurse on the smaller graph
The local-move kernel — the part both algorithms share and Leiden speeds up with a queue:
#![allow(unused)]
fn main() {
fn local_move(v: u32, g: &Csr, comm: &mut [u32], tot: &mut [f64]) -> bool {
let mut w_to = HashMap::new(); // topic 20's SPA, again
for (u, w) in g.edges(v) { *w_to.entry(comm[u]).or_insert(0.0) += w; }
let (kv, m2) = (g.wdeg(v), g.total_weight_x2());
let (mut best, mut best_gain) = (comm[v], 0.0);
for (&c, &w_vc) in &w_to { // ΔQ is O(deg) to evaluate:
let gain = w_vc / m2 // edges gained inside c
- kv * tot[c] / (m2 * m2); // minus null-model expectation
if gain > best_gain { best = c; best_gain = gain; }
}
// NOTE: ΔQ never asks "does removing v disconnect my old community?"
if best != comm[v] { move_vertex(v, best, comm, tot); true } else { false }
}
}
The bug (paper §2, Fig. 1 — internalize this figure)
A vertex v can be the BRIDGE holding community C together. Local moves later relocate v (its ΔQ is evaluated against current neighbors, not C’s connectivity) — C is left in two pieces that the aggregation phase then FREEZES into one super-vertex forever. Up to 25% of Louvain communities on real graphs end up disconnected (§Results); iterating Louvain makes it WORSE, not better.
The root cause generalizes: greedy local search + irreversible aggregation = errors that can’t be undone. (Compare topic 21’s rule-ordering trap: greedy destructive rewriting parks in a local optimum; egg’s fix was also “don’t destroy — keep options open”.)
Leiden’s fix
1. local moves (as Louvain, but with a QUEUE — only revisit
vertices whose neighborhood changed: faster)
2. REFINEMENT: inside each community, re-cluster from singletons,
merging only within the community, RANDOMIZED proportional to
ΔQ — communities split into their well-connected parts
3. aggregate on the REFINED partition (but keep phase-1 communities
as the initial coarse assignment)
Refinement is the undo mechanism: aggregation now operates on pieces that are guaranteed γ-connected (Theorem: Leiden communities are connected; iterated Leiden converges to subset-optimal partitions). Empirically it’s also FASTER than Louvain (the queue) — the fix costs nothing.
Engine-side notes (for M24)
- ΔQ evaluation needs, per vertex: weights to each neighbor community + community total degrees — a hash-or-array accumulator keyed by community id. That’s topic 20’s SPA again; skew (hub vertices touch many communities) decides array vs hash.
- Aggregation = building the quotient graph = SpGEMM: S·A·Sᵀ with S the n×k assignment matrix. Louvain/Leiden over the M20 core is two masked SpGEMMs + a local-move kernel.
- Determinism: local-move ORDER changes the output. For a database
procedure (
CALL algo.community()), fix the seed and document that reruns on the same snapshot match (topic 16’s reproducibility bar) — Leiden’s randomized refinement makes seeding mandatory.
Questions (answer in notes.md)
- Reproduce Fig. 1’s failure in your head (or on paper) with a 5-vertex example: which move disconnects the community and why was its ΔQ positive?
- The resolution limit: modularity at γ=1 can’t see communities smaller than ~√(2m). Where does that bite a fraud-ring query on a payments graph, and which knob (γ, or CPM as the paper hints) fixes it?
- Leiden’s refinement merges randomly ∝ exp(ΔQ/θ). What breaks if you make it greedy-deterministic (the paper tells you — §Methods)?
- Map one Leiden iteration onto the M20 sparse core: which steps are SpGEMM, which are the SPA-style local kernel, and where do delta matrices interact with aggregation?
- Louvain communities can be disconnected — write the topic-16 style property test for a community-detection procedure (connectivity check per community = one BFS each, or one FastSV on the induced subgraph).
References
Papers
- Traag, Waltman, van Eck — “From Louvain to Leiden: guaranteeing well-connected communities” (Scientific Reports 2019, arXiv:1810.08473) — §2 and Fig. 1 are the bug; §Methods has the randomized refinement and why greedy breaks it
Topic 24 notes — advanced graph algorithms & analytics
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
Graphs: RMAT scale 16 (n=65,536, m=1,819,338 directed after symmetrize+dedup, max deg 9,751) vs uniform (same n, m=2,096,564, max deg 59). Build 258 ms.
| lane | rmat | uniform |
|---|---|---|
| PageRank (pull, ε=1e-4) | 8 iters, 10.8 ms, 1.35 GTEPS-ish | 6 iters, 7.9 ms, 1.59 |
| Triangle count (degree-ordered) | 15,645,988 in 375.8 ms | 5,428 in 158.0 ms |
| Dijkstra ×3 sources | 33.7 ms, 342,909 pops | |
| CC union-find | 18,844 components, 4.2 ms, all m inspected |
- TC: same n, comparable m, 2,883× more triangles on RMAT — hub neighborhoods intersect; uniform graphs have nothing to count. Per-triangle cost is what the skew hides: rmat does 24 ns/triangle only because intersections are fat; uniform pays 29 µs/triangle.
- Dijkstra pops = 1.74×n per source — lazy deletion’s stale-entry tax on a skewed graph.
- PR converges FASTER on uniform (6 vs 8 iters): hubs concentrate rank and slow the L1 error’s decay.
- 18,844 components at avg_deg 16: RMAT’s leaf quadrant (d=0.05) strands vertices; real twitter-shaped data does the same — CC benchmarks that assume one component are lying.
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| delta_stepping relaxations at Δ=16 vs Dijkstra’s 343K pops (per source ~114K) | ||
| Δ=2^40 (pure Bellman-Ford): relaxations ×? over Δ=128 | ||
| best-wall-clock Δ for weights 1..=255 on this RMAT | ||
| afforest edges_inspected as % of m (test bound: <50%) | ||
| brandes 8 sources on scale-13 RMAT — ms (8 BFS + 8 backprops over 460K edges) | ||
| brandes full-source n=128 vs bc_brute O(n³) — which is faster and ×? |
Implementation log
- sssp.rs delta_stepping — matches Dijkstra 3 configs, extremes test
- bc.rs brandes — matches O(n³) brute on n=128, sampled lane runs
- cc.rs afforest — partition matches union-find, <50% edges inspected
- prediction table reconciled
- stretch: Δ sweep plot (relaxations + buckets vs Δ), find the knee
- stretch: label-propagation CC (Ligra Components.C style) as a third lane — compare edges touched vs afforest on 1-component vs 18K-component graphs
- stretch: Louvain phase-1 local moves with modularity trace; property test from reading-louvain-leiden.md Q5 (community connectivity check)
Surprises / dead ends:
- RMAT top-1% edge share at scale 12 is 19.1% — under the 20% I first asserted in the skew test (share grows with scale: 36.6% at 16). Skew assertions need scale-aware bounds; loosened to 14% + hub-degree check.
- 18,844 components surprised me at avg_deg 16 (uniform G(n,m) at that degree would be 1 giant + few strays; RMAT’s 0.05 quadrant starves the low-id… actually high-id leaves). Afforest’s “skip the giant component” trick still applies — 71% of vertices are in it.
Questions from the reading guides
GAP (reading-gap.md)
- Road-vs-twitter kernel ranking flips; diameter vs degree variance:
- When redundant-relaxation (sssp.cc:44) loses:
- BC source sampling bias on 18K-component RMAT; stratification:
- pr.cc vs pr_spmv.cc on kron — gather cost:
- Why no community-detection kernel in GAP:
Delta-stepping (reading-delta-stepping.md)
- Relaxations-vs-Δ curve prediction (table above):
- Δ=1 integer weights = Dial’s algorithm; why O(1) beats heap:
- Benign races + min = idempotent monoid; the GraphBLAS name:
- vxm count vs max_dist/Δ; where algebra pays:
- CALL algo.sssp over M20: semiring, bucket vector, Δ in API:
Brandes (reading-brandes.md)
- Recurrence derivation; where the +1 comes from:
- Brute’s O(n²) memory vs oracle-fitness:
- succ bitmap vs depth recheck — memory touches per edge:
- Batch size ns limits in LAGr_Betweenness; sweet spot at n=65K:
- BC under unflushed deltas — flush vs stale-main:
Ligra (reading-ligra.md)
- Frontier where m/20 threshold picks wrong:
- edgeMapDenseForward vs edgeMapDense (early exit value):
- BC.C constructs ↔ LAGr_Betweenness ops; who batches:
- Label-prop vs afforest edges touched, 1 vs 18K components:
- Callback API vs fixed menu for M24; safe-embedding costs:
Louvain→Leiden (reading-louvain-leiden.md)
- 5-vertex disconnection example:
- Resolution limit on fraud rings; γ vs CPM:
- Greedy-deterministic refinement — what breaks:
- Leiden iteration on M20 core: SpGEMM vs SPA steps:
- Connectivity property test for algo.community:
LAGraph algos (reading-lagraph-algos.md)
- min_2nd semiring rationale; MIN_TIMES failure on weights:
- FastSV rounds vs Afforest rounds; why Afforest wins wall-clock:
- Sandia_LUT urand exception ↔ dot3-vs-saxpy3:
- Dangling-vertex error of our pull PR on 18K components:
- algo.wcc under pending deltas — three options, semantics:
Cross-topic threads
- Direction switching (Ligra m/20, Beamer α/β, SuiteSparse dot-vs- saxpy) = one decision, three communities — topic 20’s BFS stub already implements it; Ligra shows it generalizes past BFS.
- Afforest/FastSV sampling = “do less work than reading the input” — same instinct as block-max WAND (topic 23): metadata/bounds prove most of the input irrelevant.
- Brandes’ restructured sum = IVM thinking (topic 27 preview): δ_s(v) is an incrementally-maintainable aggregate over the DAG.
- Louvain’s irreversible-aggregation bug = topic 21’s rule-ordering trap: greedy + destructive = stuck; Leiden’s refinement = egg’s keep-both-forms.
- Modularity ΔQ accumulator = topic 20’s SPA; aggregation = S·A·Sᵀ SpGEMM; TC’s six formulations = semiring/mask algebra as a query planner (pick the formulation like topic 10 picks join orders).
- GAP’s 5-graph matrix = topic 22’s “change any one ⇒ different number” — graph SHAPE is the workload axis benchmarks forget.
- proc_pagerank.c’s flush-then-run = topic 20’s delta-matrix wait: analytics force synchronization; M24 must decide the semantics.
M24 log (capstone)
- algo crate over M20 core: PR (pull SpMV), CC (FastSV + Afforest, race them), BC (batched-matrix Brandes), SSSP (MIN_PLUS delta-stepping), TC (masked SpGEMM, method picker)
- procedure surface
CALL algo.*copying FalkorDB’s proc_pagerank.c arg/yield shape - snapshot semantics: procedures run post-wait (documented), or masked-over-deltas (measured first)
- GAP lanes into M22’s standing suite
Done when
- Three stubs green with lanes filled; prediction table reconciled; guide questions answered; the frontier-vs-algebra choice per algorithm written down with our own numbers backing it.
Topic 25 — Graph Neural Networks & Graph ML for a database engine
Why this matters for FalkorDB: message passing is SpMM over a semiring — the M20 sparse core is already a GNN inference engine waiting for a feature matrix. And GraphRAG (your own GraphRAG-SDK) is pulling graph databases into the ML serving path: embeddings stored next to nodes, vector index + pattern match in one Cypher query. This topic is about seeing GNNs as sparse linear algebra you already own, not as a framework to import.
The one-slide version
"GNN layer" what an engine sees
─────────────────────────────────────────────────────────
H' = sigma( A_hat . H . W ) SpMM (aggregate, sparse)
│ │ │ └─ dense matmul (transform, small)
│ │ └─ n x d feature matrix (dense, FAT rows)
│ └─ normalized adjacency (CSR — you have this)
└─ elementwise (free)
node2vec / DeepWalk random walks (you have CSR
walks -> skip-gram traversal) + word2vec SGD
GraphRAG embeddings in a vector index
(topic 14) + Cypher pattern
match — ONE query, two indexes
Message passing = SpMM, with receipts
PyG’s MessagePassing.propagate (message_passing.py:421) has a fused
fast path: if the layer defines message_and_aggregate, the per-edge
message() + scatter aggregate() pair is replaced by one call.
What do the big three layers put there?
| layer | message_and_aggregate | anchor |
|---|---|---|
| GCNConv | spmm(adj_t, x, reduce=sum) | gcn_conv.py:273 |
| SAGEConv | spmm(adj_t, x[0], reduce=mean) | sage_conv.py:149-152 |
| GATConv | — (can’t fuse: per-edge softmax weights) | gat_conv.py:392-408 |
GCN and SAGE are literally one SpMM per layer. GAT is the exception that
proves the rule: its edge weights depend on the current features
(attention), so it needs SDDMM (sampled dense-dense matmul: compute
leaky_relu(a . [h_u || h_v]) only where A has a nonzero) followed by a
row-softmax, then the SpMM. SDDMM+SpMM is the masked-SpGEMM pattern from
topic 24’s triangle counting wearing a learning costume.
flowchart LR
X["X: n x d features"] --> T["dense matmul<br/>X W (transform)"]
T --> S["SpMM<br/>A_hat (X W) (aggregate)"]
A["A_hat: CSR"] --> S
S --> R["relu"] --> T2["... layer 2 ..."] --> Z["Z: n x k"]
A -. "GAT only: SDDMM<br/>per-edge scores + softmax" .-> A2["A(H): learned weights"] -.-> S
Associativity is a query plan. A(XW) costs 2·nnz·hidden for the
SpMM; (AX)W costs 2·nnz·in_dim. With in_dim=1433 (Cora) and hidden=16,
transform-first is 90x cheaper on the sparse side. Same decision as join
ordering (topic 10) — the frameworks hardcode the good order; an engine
with a cost model could choose.
Our numbers (Apple M3 Pro, SBM n=16,384, m=566K directed, 2026-07-10)
| lane | result |
|---|---|
| SBM build (64 blocks x 256) | 34.4 ms |
| uniform walks 65,536 x 40 steps | 61.2 ms, 42.8 Msteps/s |
| SpMM (D^-1 A) x X[16384x64] | 3.42 ms/iter, 21.2 GFLOP/s |
| dense matmul [16384x64]x[64x64] | 5.12 ms/iter, 26.2 GFLOP/s |
The headline: naive scalar SpMM reaches 81% of dense matmul’s throughput on this graph. Sparse’s irregular gather is amortized by the 64-float dense rows it drags along — a GNN’s SpMM is memory-friendly in exactly the way topic 20’s SpMV (1-wide) is not. Fat right-hand sides forgive sparsity.
Random-walk embeddings (DeepWalk -> node2vec)
walk corpus skip-gram (word2vec, unchanged)
┌────────────────────┐ for each center u, context c in window:
│ 5 12 7 7 3 12 ... │ maximize sigma(z_u . c_c)
│ 9 2 44 2 9 61 ... │ + for k random "negative" c':
│ ... │ maximize sigma(-z_u . c_c')
└────────────────────┘ (PyG Node2Vec.loss, node2vec.py:135 —
vertices are words, exactly this expression)
walks are sentences
DeepWalk uses uniform walks. node2vec biases them with two knobs evaluated against the PREVIOUS vertex t (second-order walk): weight 1/p to return to t, 1 to move to a mutual neighbor of t, 1/q to move away. Low q = outward/ DFS-ish = communities; high q = local/BFS-ish = structural roles. The implementation trap: per-edge alias tables are O(m·avg_deg) memory — rejection sampling (bound max(1, 1/p, 1/q)) is O(1) and what our stub prescribes.
The stubs (experiments/)
| stub | contract |
|---|---|
walks::node2vec_walks | p=q=1 matches degree-stationary distribution; q orders exploration (ring of cliques); p orders backtrack rate |
embed::train_skipgram | SBM intra-block cosine > inter-block + 0.2 |
gcn::gcn_norm + gcn_forward | matches dense definitional oracle to 1e-4; rows sorted; transform-before-aggregate |
Provided: CSR + SBM/ring-of-cliques generators (graph.rs), dense Mat +
glorot init (dense.rs), SpMM + row-normalized adjacency (spmm.rs),
uniform walks (walks.rs), dense GCN oracle (gcn.rs), gnn_bench.
GraphRAG: where this lands in the database
Your GraphRAG-SDK already does the serving half against FalkorDB:
vector_store.py:344 — CALL db.idx.vector.queryNodes('Chunk', 'embedding', $top_k, vecf32($vector)), then Cypher expands from the hits
(retrieval/strategies/relationship_expansion.py). What’s missing is the
production half: embeddings computed OUTSIDE (OpenAI API) and written back
with SET c.embedding = vecf32($vector) (:219). M25 closes the loop:
compute node2vec/GCN embeddings with the engine’s own SpMM, store into the
M14 vector index, answer hybrid queries without leaving the database.
flowchart TD
G["graph (M20 CSR/delta)"] -->|"random walks / SpMM"| E["embeddings n x d"]
E -->|store| V["vector index (M14 HNSW)"]
Q["hybrid Cypher query"] --> V
Q --> P["pattern match (M10 executor)"]
V --> J["join: candidates ∩ pattern"] --> R["results"]
P --> J
Reading guides
- reading-node2vec.md — node2vec: the neighborhood is a query, p and q are its knobs
- reading-gcn.md — GCN: the two-line neural network your engine already runs
- reading-graphsage.md — GraphSAGE: sample the neighborhood, learn the function
- reading-gat.md — GAT: when the edge weights are computed per query
- reading-pyg-message-passing.md — PyTorch Geometric: one abstraction, the whole GNN literature
- reading-transe.md — TransE: relations as vector translations
- reading-graphrag-sdk.md — GraphRAG-SDK: a RAG pipeline read as a workload spec
Cross-topic links
- Topic 20: SpMM/semirings — the aggregation kernel is M20’s
mxmwith a dense B; direction switching does NOT apply (always dense frontier, like Ligra’s PageRank row in topic 24’s reading-ligra.md). - Topic 14: the vector index that stores what this topic computes.
- Topic 10: associativity-as-query-plan; GAT’s SDDMM = masked SpGEMM (topic 24 TC).
- Topic 27 (ahead): are embeddings incrementally maintainable views over the graph? (Spoiler: walks no, GCN partially — see notes.md.)
GAT: when the edge weights are computed per query
GCN’s A_hat weights are structural constants (degree math). GAT makes them FUNCTIONS of the features on each edge — learned, per-edge, softmax- normalized. For an engine, the interesting part is what that does to the kernel: aggregation stops being one SpMM and becomes SDDMM + softmax + SpMM.
The layer (§2.1)
e_uv = LeakyReLU( a^T [ W h_u || W h_v ] ) per EDGE (u,v) ∈ A
alpha = softmax_v( e_uv ) normalize over v's in-edges
h'_v = sigma( Σ_u alpha_uv · W h_u ) weighted aggregate
kernel view:
step 1: SDDMM — dense scores computed ONLY where A is nonzero
(a mask! topic 24's masked-SpGEMM pattern: (dense op) .* A)
step 2: row-softmax over the sparse score matrix
step 3: SpMM with the fresh weights
PyG anchors: score assembly alpha_j + alpha_i at gat_conv.py:392 (the
a^T [x||y] split into two halves — a_src·h_u + a_dst·h_v, computed as
per-NODE terms then added per-edge: an optimization worth noticing),
softmax(alpha, index, ptr) at :404 (segmented softmax over CSR rows),
message = x_j * alpha at :408. No message_and_aggregate — the fused
SpMM path can’t apply because the matrix values are recomputed per
forward pass.
The three kernels for one destination row, spelled out:
#![allow(unused)]
fn main() {
fn gat_row(a_t: &Csr, v: u32, wh: &Mat, a_src: &[f32], a_dst: &[f32]) -> Vec<f32> {
// SDDMM: dense scores, computed ONLY at A's nonzeros (in-edges of v)
let e: Vec<f32> = a_t.row(v)
.map(|u| leaky_relu(a_src[u as usize] + a_dst[v as usize])).collect();
// segmented softmax over the CSR row (max pass, then exp-sum pass)
let mx = e.iter().fold(f32::MIN, |m, &x| m.max(x));
let z: f32 = e.iter().map(|&x| (x - mx).exp()).sum();
// SpMM with the fresh weights — this row of A exists only for this query
let mut out = vec![0.0; wh.d];
for ((u, _), &ev) in a_t.row(v).zip(&e) {
let alpha = (ev - mx).exp() / z;
for k in 0..wh.d { out[k] += alpha * wh.row(u)[k]; }
}
out
}
}
Why databases should care
- The sparse-softmax is a segmented reduction over CSR rows — same shape as topic 20’s row-wise SpMV, run twice (max, then exp-sum). GAT costs ~3 extra passes over the edges vs GCN’s one.
- Multi-head attention (K independent alpha sets, concat) multiplies everything by K — it’s K SpMMs with shared structure, different values. A delta-matrix engine would store one structure + K value arrays (FalkorDB’s multi-value matrix problem, again).
- Dynamic edge weights kill precomputation: GCN’s A_hat is a materialized view; GAT’s attention matrix is a per-query computed view. The materialize-vs-compute line runs exactly through this pair of papers.
Questions (answer in notes.md)
- Why is the softmax over IN-edges of v (not out-edges of u), and what does that force about the storage direction (A vs A^T — topic 20’s transpose tax)?
- Count edge passes per GAT layer vs GCN layer. On our 566K-edge SBM at 21 GFLOP/s SpMM, estimate the forward-time ratio.
- The a_src/a_dst per-node split at gat_conv.py:332 turns O(m·d) score work into O(n·d) + O(m). Which database trick is this (hint: factor computation out of a join)?
- GAT attention weights are data — a fraud analyst asks “WHY did this node score high?” Sparse alpha is the explanation. What Cypher surface would expose it (edges with attention > t)?
- For M25: is GAT worth engine support at all, or is GCN/SAGE + the vector index the 95% case? Argue from the kernel inventory each needs.
References
Papers
- Veličković, Cucurull, Casanova, Romero, Liò, Bengio — “Graph Attention Networks” (ICLR 2018, arXiv:1710.10903) — §2.1 is the layer; the rest is evaluation
Code
- pytorch_geometric
torch_geometric/nn/conv/gat_conv.py— score split :392, segmented softmax :404, message :408; note the absentmessage_and_aggregate
GCN: the two-line neural network your engine already runs
Kipf & Welling made GNNs a two-line equation. Read §2 for the layer, §3 for why it’s a first-order spectral approximation (skimmable), and appendix B for the actual dimensions — then notice everything is operations your engine already has.
The layer
H(l+1) = sigma( D^-1/2 (A + I) D^-1/2 · H(l) · W(l) )
└──┬──┘ └──────────┬─────────┘ └─┬─┘ └─┬─┘
relu A_hat: fixed, sparse, n x d d x h
precomputed ONCE dense tiny dense
A + I: self-loops so a vertex keeps its own features (renormalization trick, §2.2). Without it, deep stacking oscillates.- Symmetric normalization
D^-1/2 · D^-1/2: averages neighborhoods without letting hub degrees explode activations. Compare topic 24’s PageRank pull matrix (row-normalizedD^-1 A) — same idea, symmetric so the operator stays PSD-friendly. - Two layers, softmax, cross-entropy on the few labeled nodes. That’s the
whole model:
Z = softmax(A_hat · relu(A_hat X W1) · W2)(eq. 9).
PyG’s gcn_norm (gcn_conv.py:45-71) is the reference implementation of
A_hat: fill_diag with 1, deg^-0.5 masked at inf, scale rows then columns.
Our gcn::gcn_norm stub reproduces it in CSR; the dense oracle
gcn_norm_dense is the definitional check.
What the engine sees
One layer, no framework — a query plan with two operators:
#![allow(unused)]
fn main() {
fn gcn_layer(a_hat: &Csr, h: &Mat, w: &Dense) -> Mat {
let t = h.matmul(w); // transform FIRST: n×d · d×h — because
// h < d, this shrinks what SpMM drags
let mut out = Mat::zeros(h.n, w.cols);
for v in 0..a_hat.n { // aggregate: one SpMM row at a time
for (u, w_vu) in a_hat.row(v) { // w_vu = 1/√(d_v·d_u)
for k in 0..w.cols { out[v][k] += w_vu * t[u][k]; }
}
}
out.relu() // sigma — free
}
}
Per layer: one SpMM (2·nnz·h FLOPs) + one small dense matmul
(2·n·d·h). On Cora (n=2708, nnz=13K, d=1433, h=16) the DENSE transform
dominates; on our SBM (nnz=566K, d=64) they’re comparable — measured
3.42 ms SpMM vs 5.12 ms dense at 64-wide. The associativity choice
(A X) W vs A (X W) swaps which term carries the big dimension:
transform-first wins whenever h < d. Frameworks hardcode this; a database
would COST it (topic 10).
Inference on a static graph needs no autograd, no framework: A_hat is a materialized matrix, weights are two small constants — a GCN forward is a query. That’s the M25 claim in one sentence.
Limits worth knowing (they motivate the next two papers)
- Full-batch: every layer touches every vertex — memory O(n·d) per layer. GraphSAGE’s answer: sample (reading-graphsage.md).
- Fixed, feature-independent weights in A_hat. GAT’s answer: learn them per-edge (reading-gat.md).
- Oversmoothing: stacking k layers ≈ k-step diffusion → features converge to the dominant eigenvector; deep GCNs die. Two layers is not a style choice, it’s the working regime.
Questions (answer in notes.md)
- Show
A_hat = D^-1/2 (A+I) D^-1/2has eigenvalues in [-1, 1] and why that matters for stacking (the renormalization trick’s actual job). - Two GCN layers = each vertex sees its 2-hop neighborhood. Relate the receptive field to topic 24’s BFS frontier — what graph property makes “2 hops” already cover most of an RMAT graph, and what does that do to oversmoothing there?
- Count FLOPs both association orders for Cora and for our SBM bench config; where’s the crossover h/d ratio?
- The graph is BAKED into A_hat at training time. What happens to a trained GCN’s accuracy when the graph gets 10% new edges — and which part (A_hat or W) can the database refresh cheaply?
- For M25: a GCN forward over the M20 delta-matrix graph — do pending
deltas participate in A_hat, and is that the same decision as topic
24’s
CALL algo.wccthree-option question?
References
Papers
- Kipf & Welling — “Semi-Supervised Classification with Graph Convolutional Networks” (ICLR 2017, arXiv:1609.02907) — §2 for the layer, §3 skimmable, appendix B for the dimensions
Code
- pytorch_geometric
torch_geometric/nn/conv/gcn_conv.py—gcn_norm(:45-71) is the reference A_hat construction ourgcn::gcn_normstub reproduces
GraphRAG-SDK: a RAG pipeline read as a workload spec
Your own SDK, re-read as a database workload spec. Every Python line here
is a feature request against FalkorDB: what it does client-side in
asyncio is what M25 should evaluate doing engine-side. Layout:
src/graphrag_sdk/{ingestion, storage, retrieval, core}.
The pipeline as a dataflow
flowchart LR
D["docs"] --> C["chunking"] --> X["LLM entity/relation<br/>extraction"] --> R["resolution<br/>(dedup entities)"]
R --> G["graph_store:<br/>nodes + RELATES edges"]
R --> V["vector_store:<br/>embed chunks/entities/rels"]
Q["question"] --> RT["SemanticRouter<br/>(router.py:19)"] --> S["strategy"]
S --> V2["ANN: db.idx.vector.queryNodes"] --> E["Cypher expansion"] --> A["assembly -> LLM"]
G -.-> E
V -.-> V2
storage/vector_store.py — the DB contract
| anchor | what |
|---|---|
:344 | CALL db.idx.vector.queryNodes('{label}', 'embedding', $top_k, vecf32($vector)) — chunk ANN |
:378 | same over __Entity__ — entity ANN |
:426 | queryRelationships('RELATES', ...) — EDGE vectors, with a Cypher cosine-scan fallback (:414) if unsupported |
:219,:234,:312 | SET c.embedding = vecf32($vector) — embeddings computed OUTSIDE, written back as properties |
:133 | full-text index too — hybrid = vector + FT + graph, three indexes on one store |
The read path is database-native; the WRITE path (embedding computation) is an external API call per chunk/entity. M25’s thesis: with node2vec/GCN kernels in the engine, structural embeddings never leave the database — only text embeddings need the round-trip.
retrieval/strategies — hybrid queries, hand-rolled
relationship_expansion.py:12expand_relationships: ANN hits →MATCH (a:__Entity__ {id: eid})-[r:RELATES]->(b)(:35) and a 2-hop variant (:62). This is a client-side JOIN between the vector index and the graph: k queries where one Cypher query with a vector predicate should do — the exact hybrid query M25’s capstone must serve in ONE plan.multi_path.py:48runs chunk-ANN, entity-ANN, edge-ANN concurrently, reranks with client-side_cosine_sim(:362) — a scatter-gather union of three indexes with score fusion done in Python. Compare topic 23’s WAND: score fusion is what the engine’s top-k machinery is FOR.router.py:19SemanticRouter picks a strategy per question — a query PLANNER driven by embeddings instead of statistics (topic 9 with vibes).
Systems smells to fix in M25
- k+1 round trips: ANN then per-hit expansion — push the join down.
- Client-side rerank: cosine in Python over returned vectors — the index already computed distances; return them.
- Embedding writes are not transactional with the entities they describe (batch SET after ingest) — staleness window with no read-your-writes story (topic 8).
- No incremental re-embed: edit a chunk → re-embed everything or drift silently (topic 27’s IVM question, in RAG costume).
Questions (answer in notes.md)
- Write the ONE Cypher query that replaces expand_relationships’ ANN + k MATCHes. What must the planner know to not execute it as k+1 lookups anyway?
- multi_path fuses three scores client-side — design the engine-side fusion: is it WAND-able (topic 23) given vector distances aren’t monotone doc-at-a-time?
- Which of the four smells does
SET c.embedding = vecf32(...)inside the SAME transaction as entity creation fix, and what does it cost the ingest pipeline’s throughput? - The router is a planner with no cost model. What statistic would make “graph expansion vs pure ANN” a COSTED choice (selectivity of the pattern? recall@k of the index?)?
- M25 acceptance test: pattern + similarity in one query, verified against this SDK’s answers on the same data — sketch it.
References
Code
- GraphRAG-SDK
src/graphrag_sdk/—storage/vector_store.py(the DB contract),retrieval/strategies/relationship_expansion.py,retrieval/strategies/multi_path.py,retrieval/router.py; read each as a feature request against the engine
GraphSAGE: sample the neighborhood, learn the function
Two contributions wearing one acronym: (1) inductive — learn an aggregator FUNCTION, not per-node embeddings, so unseen nodes get embeddings by running the function; (2) neighbor sampling — cap the per-node fan-in so minibatches have bounded cost. The second one is the databases-relevant idea: it’s a page-budget for graph access.
The algorithm (Alg. 1)
for layer l = 1..K:
for each node v in batch:
h_N(v) = AGG_l( { h_u : u in SAMPLE(N(v), S_l) } ) ← fixed fan-in S_l
h_v = sigma( W_l · [ h_v || h_N(v) ] ) ← concat, not sum
- AGG ∈ {mean, LSTM, max-pool}. Mean-SAGE ≈ GCN without the symmetric
normalization; PyG’s SAGEConv fuses it as
spmm(adj_t, x, reduce=mean)(sage_conv.py:149-152) with the self path as a separatelin_r(sage_conv.py:108,139) — concat implemented as sum of two linears. - SAMPLE: uniform, S_l per layer (paper uses S1=25, S2=10).
One mean-SAGE layer for one node, sampling included:
#![allow(unused)]
fn main() {
fn sage_layer(g: &Csr, h: &Mat, v: u32, s: usize,
w_self: &Dense, w_nbr: &Dense, rng: &mut Rng) -> Vec<f32> {
let mut agg = vec![0.0; h.d];
let sample = g.neighbors(v).choose_multiple(rng, s); // fan-in capped at s
for &u in &sample { // uniform sample of N(v)
for k in 0..h.d { agg[k] += h.row(u)[k]; }
}
for k in 0..h.d { agg[k] /= sample.len() as f32; } // AGG = mean
// "concat then W" done as sum of two linears (PyG's lin_l/lin_r trick)
relu(add(w_self.mul(h.row(v)), w_nbr.mul(&agg)))
}
}
The fan-out explosion (why sampling exists)
batch of B seeds, K=2 layers, fan-in S1=25, S2=10:
layer-2 needs: B·10 neighbors
layer-1 needs: B·10·25 = 250·B nodes touched
WITHOUT sampling on a hub graph: B · d_hub² — one Twitter celebrity
in the batch pulls in millions. Sampling = bounding worst-case I/O.
This is a query optimizer problem stated in ML clothes: the full
neighborhood is the correct answer, the sample is an approximation with a
resource bound. PyG’s NeighborLoader (loader/neighbor_loader.py:10)
industrializes it; the sampled subgraph handed to the model is exactly a
database view — materialized per batch, biased by design.
Engine-side notes
- Uniform neighbor sampling over CSR = pick S offsets in a row — O(S), cache-friendly, and identical to Afforest’s “look at r neighbors” trick (topic 24): both refuse to pay for the full adjacency because a sample answers well enough.
- Inductive matters for databases: node2vec/GCN-transductive embeddings go stale on insert (the vertex wasn’t in training). A SAGE aggregator is a stored FUNCTION: new node → one forward pass over its (sampled) neighborhood → embedding. That’s the only variant that composes with a write-heavy database.
- The bias is real: sampled aggregation is an unbiased estimator of mean aggregation only pre-nonlinearity; variance shows up as accuracy noise. Benchmarks quote it; topic 22 says measure it yourself.
Questions (answer in notes.md)
- Why does mean aggregation + separate self-linear (lin_r) approximate concat? What expressiveness is lost vs true concat?
- Compute nodes-touched for B=512, S=(25,10) vs full 2-hop on our SBM (avg_deg 34.6) and on an RMAT hub (deg 9,751, topic 24) — where does sampling stop being optional?
- SAMPLE(N(v), S) per epoch is a fresh random view — relate to Afforest’s neighbor_rounds sample (topic 24). One is for variance reduction, one for work skipping; do they meet?
- An insert arrives: which embeddings does a SAGE model let you refresh lazily, and what’s the staleness semantics (topic 8 vocabulary) of “embedding computed at snapshot T, queried at T+k”?
- For M25’s
algo.embed(): transductive (node2vec) vs inductive (SAGE) as the stored artifact — which do you ship first, and what does the vector index (topic 14) need to know about staleness either way?
References
Papers
- Hamilton, Ying, Leskovec — “Inductive Representation Learning on Large Graphs” (NeurIPS 2017, arXiv:1706.02216) — Alg. 1 and the sampling discussion; the aggregator zoo is skimmable
Code
- pytorch_geometric
torch_geometric/nn/conv/sage_conv.py(:108,139,146-152 — concat as two linears, fusedspmmwithreduce=mean) andtorch_geometric/loader/neighbor_loader.py(:10 — sampling, industrialized)
node2vec: the neighborhood is a query, p and q are its knobs
Read node2vec as a sampling-strategy paper: the contribution is not the learning (that’s word2vec, untouched) but a parameterized family of neighborhood definitions. A database person should recognize the move: “what is a node’s context?” is a query, and p/q are its knobs.
The walk bias (§3.2 — the whole paper is this figure)
came from t, now at v — where next?
x1 (dist 1 from t: mutual neighbor) weight 1
/
t ──── v ── x2 (dist 2 from t: away) weight 1/q
\ \
\ x3 (dist 2) weight 1/q
└───── t (return) weight 1/p
SECOND-order: the distribution depends on the edge (t, v) you arrived
by, not just on v. That's why preprocessing is per-EDGE, not per-node.
- q > 1: stay near t — BFS-flavored samples → embeddings encode structural roles (hubs look like hubs).
- q < 1: push outward — DFS-flavored → embeddings encode communities (homophily). Our test pins this: on a ring of cliques, q=0.25 must visit >1.15x more distinct vertices per walk than q=4.
- p large: don’t backtrack. p small: stay glued to the previous vertex.
Skip-gram with negative sampling (§3.1, inherited)
Maximize log sigma(z_u . c_v) for co-visited pairs, log sigma(-z_u . c_n)
for k random negatives. PyG’s Node2Vec.loss (node2vec.py:135-160) is a
direct transcription — read it as the reference: two embedding lookups,
inner product, -log(sigmoid), positive + negative terms summed.
Walk generation there is torch.ops.pyg.random_walk (node2vec.py:64) — a
custom C++/CUDA op, because Python-level walking would dominate runtime.
Our measured yardstick: 42.8 Msteps/s scalar Rust on M3 Pro.
The systems trap: alias tables (§3.2.1)
Original impl precomputes an alias table per directed edge over the destination’s neighbors: O(1) sampling but O(m · avg_deg) memory — on our 16K-vertex SBM that’s 566K x 34.6 ≈ 20M table entries for a toy graph. This is the documented reason node2vec “doesn’t scale”; it’s the sampling that doesn’t. Fixes:
- rejection sampling (KnightKing, our stub’s prescription): draw uniform from N(v), accept with w/w_max, w_max = max(1, 1/p, 1/q). O(1) memory; expected draws worsen as p, q leave 1.
- or accept first-order walks (DeepWalk) — on many benchmarks the p/q gain is small; know what you’re buying.
One biased step via rejection, the whole mechanism:
#![allow(unused)]
fn main() {
fn step(g: &Csr, t: u32, v: u32, p: f64, q: f64, rng: &mut Rng) -> u32 {
let w_max = 1f64.max(1.0 / p).max(1.0 / q);
loop {
let x = g.neighbors(v).choose(rng); // uniform proposal, O(1)
let w = if x == t { 1.0 / p } // return to t
else if g.has_edge(t, x) { 1.0 } // mutual neighbor: dist 1
else { 1.0 / q }; // away: dist 2 from t
if rng.f64() < w / w_max { return x; } // accept ∝ true bias —
} // no per-edge alias table
}
}
Engine-side notes
- Walks are embarrassingly parallel and CSR-native — a database can generate them without materializing anything (cursor per walker).
- has_edge(t, x) for the distance-1 check = binary search in the sorted CSR row — O(log deg). Bloom-style edge sketches would trade accuracy for speed; the walk is already stochastic, so approximate membership is admissible (nice essay question, see notes.md).
- Determinism (topic 16 bar): seeded walks + seeded SGD = reproducible embeddings; document that parallel SGD (Hogwild) breaks this.
Questions (answer in notes.md)
- Why must the walk bias be second-order to distinguish BFS-ish from DFS-ish? What can a first-order bias (weight by degree, say) not express?
- Rejection sampling’s expected draw count at p=1, q=0.25 on our ring of cliques — derive it from the weight distribution at a bridge vertex.
- The paper evaluates with logistic regression on frozen embeddings. What does that measurement HIDE that an end-to-end GNN shows?
- Embeddings as a materialized view: an edge insert invalidates which walks? Why is the answer “unboundedly many” (and what does that say about incremental maintenance — topic 27)?
- For
CALL algo.node2vec()in M25: which of (p, q, walk_len, walks_per_node, dim, window, negs, epochs, lr, seed) belong in the API, and which should be fixed opinions? Compare FalkorDB’s proc_pagerank arg surface (topic 24).
References
Papers
- Grover & Leskovec — “node2vec: Scalable Feature Learning for Networks” (KDD 2016, arXiv:1607.00653) — §3.2 (the walk bias) is the whole paper; §3.1 is inherited word2vec
Code
- pytorch_geometric
torch_geometric/nn/models/node2vec.py—loss(:135-160) is a direct SGNS transcription; walks are a custom op (:64)
PyTorch Geometric: one abstraction, the whole GNN literature
Read PyG the way topic 20 read SuiteSparse: as an existence proof that
one abstraction (here MessagePassing) covers a whole literature, and as
a map of which kernels actually matter. 90 minutes, code-first.
Read in this order
| stop | file:line | what to see |
|---|---|---|
| 1 | torch_geometric/nn/conv/message_passing.py:39 | the base class — every conv layer subclasses this |
| 2 | :421 propagate() | the dispatcher: fused path check at :469-470 (if self.fuse) |
| 3 | :565/:577/:598/:609 | the four overridables: message, aggregate, message_and_aggregate, update |
| 4 | nn/conv/gcn_conv.py:45-71 | gcn_norm — A_hat construction (our stub’s reference) |
| 5 | gcn_conv.py:270-274 | GCN’s two personalities: per-edge message (COO gather-scatter) vs fused spmm(adj_t, x) |
| 6 | nn/conv/sage_conv.py:146-152 | SAGE: same fusion, reduce=mean |
| 7 | nn/conv/gat_conv.py:392-408 | GAT: why fusion is impossible (per-edge softmax) |
| 8 | utils/_spmm.py:12 | the spmm shim — dispatches to torch.sparse CSR, torch_sparse, or EdgeIndex backends |
| 9 | nn/models/node2vec.py:64,101-160 | walks as a custom op + SGNS loss |
| 10 | loader/neighbor_loader.py:10 | minibatch sampling (GraphSAGE industrialized) |
The two execution modes
edge_index (COO 2 x m) adj_t (CSR/SparseTensor)
───────────────────── ────────────────────────
gather x_j per edge message_and_aggregate:
message(x_j) -> m x d temp! spmm(adj_t, x)
scatter-reduce by dst no m x d materialization
= "materialize the join" = "pipelined aggregation"
The COO path builds an m x d intermediate — the unaggregated message tensor. On our SBM bench that would be 566K x 64 floats = 145 MB per layer, vs SpMM’s zero temporaries. PyG docs call switching to SparseTensor a “memory-efficient aggregation”; a database person calls it not materializing a join before a group-by. Same lesson as topic 20’s masked SpGEMM never materializing L·U’ (topic 24 TC).
The COO path, de-tensored — see the m×d temp being born:
#![allow(unused)]
fn main() {
fn propagate_coo(edges: &[(u32, u32)], x: &Mat, msg: impl Fn(&[f32]) -> Vec<f32>)
-> Mat {
let mut tmp = Vec::with_capacity(edges.len()); // m×d — THE temporary
for &(src, _) in edges { tmp.push(msg(x.row(src))); } // gather + message
let mut out = Mat::zeros(x.n, x.d);
for (&(_, dst), m) in edges.iter().zip(&tmp) { // scatter-reduce by dst
out.row_mut(dst).add_assign(m);
}
out // fused CSR path: spmm(adj_t, x) — same result, no tmp at all
}
}
SDDMM is the other primitive: GAT’s per-edge scores are dense-dense
products sampled at A’s nonzeros. DGL exposes it directly (dgl.ops.gsddmm);
PyG hides it inside edge_updater. SpMM + SDDMM together span every
mainstream GNN — that’s the entire kernel inventory M25 needs.
What PyG pays for generality
message()as an arbitrary Python callable = Ligra’s F-with-CAS (topic 24 reading-ligra.md Q5) — flexible, unfusable, and needs inspection tricks (propagateintrospects the signature to build kwargs). A fixed semiring menu (GraphBLAS) fuses always but expresses less. Same tradeoff, third community.torch.compilesupport forced a template-generatedpropagate(message_passing.py builds a specialized module) — JIT-ing away the dynamism it advertised. Frameworks converge on: dynamic API, static hot path.
Questions (answer in notes.md)
- Trace one GCNConv.forward on paper: which lines run gcn_norm, which
dispatch to spmm, where the bias adds. What’s cached across calls
(hint:
self._cached_adj_t) and what’s the database name for it? - The COO path’s m x d temp vs CSR spmm: compute both memory footprints for our bench config and for RMAT scale 16 (topic 24) at d=128.
spmm’sreduce='max'isn’t a semiring on floats-with-gradients — what breaks in backward, and how does that constrain “GNN over GraphBLAS” ambitions (M20’s semiring menu)?- NeighborLoader returns a renumbered subgraph per batch — relate to topic 5’s buffer-pool page pinning: what’s the working set, who evicts?
- If M25 exposes ONE kernel to Cypher (
CALL algo.spmm?), which PyG surface is the right shape to copy, and what stays engine-internal?
References
Code
- pytorch_geometric —
read in the table’s order:
torch_geometric/nn/conv/message_passing.py(:39 base class, :421propagate, :469-470 fuse check),nn/conv/gcn_conv.py,nn/conv/sage_conv.py,nn/conv/gat_conv.py,utils/_spmm.py,nn/models/node2vec.py,loader/neighbor_loader.py
TransE: relations as vector translations
The knowledge-graph embedding paper: relations as VECTOR TRANSLATIONS. Three pages of model, a decade of descendants. Read it for the scoring function and the training loop — both trivially implementable — and for what it means to index the result.
The model, whole
triple (h, r, t) — "head, relation, tail": (Alice, works_at, Acme)
embed everything in R^d: want z_h + z_r ≈ z_t
score(h,r,t) = || z_h + z_r − z_t || (L1 or L2; lower = truer)
z_Alice ●────z_works_at────▶● z_Acme one arrow per RELATION,
z_Bob ●────z_works_at────▶● z_BobCorp shared by all its edges
Training: margin ranking loss over corrupted triples —
max(0, γ + score(h,r,t) − score(h',r,t')) where the corrupted triple
swaps head OR tail with a random entity. Plus the detail everyone forgets:
entity embeddings are re-normalized to the unit ball every batch (else the
loss is trivially minimized by inflating norms).
The whole training step:
#![allow(unused)]
fn main() {
fn train_step(ent: &mut Mat, rel: &Mat, (h, r, t): Triple,
gamma: f32, lr: f32, rng: &mut Rng) {
ent.renormalize_unit_ball(); // the detail everyone forgets
let (hc, tc) = corrupt(h, t, rng); // swap head OR tail, random entity
let pos = l2(ent.row(h) + rel.row(r) - ent.row(t));
let neg = l2(ent.row(hc) + rel.row(r) - ent.row(tc));
if gamma + pos - neg > 0.0 { // margin violated: push
sgd(ent, rel, (h, r, t), (hc, r, tc), lr); // pos triple closer,
} // neg triple apart
}
}
Known failure modes (they define the descendants)
- 1-to-N relations:
works_atmaps many heads to one tail → all employees collapse towardz_Acme − z_works_at. TransH/TransR project per-relation; RotatE rotates instead of translates. - Symmetric relations:
z_r ≈ −z_rforcesz_r ≈ 0→married_tobecomes “same embedding”. Translation can’t express symmetry. - Composition it CAN do:
z_born_in + z_city_of ≈ z_born_in_country— translations compose by addition. Pick your relation algebra, pick your model.
Why this topic includes it
Property graphs ARE knowledge graphs when edges carry types — FalkorDB’s
per-relation delta matrices (one matrix per edge type, topic 20) mirror
TransE’s one-vector-per-relation exactly. And the serving question is a
vector-index question: “predict missing tail” = argmin_t score(h,r,t) =
nearest-neighbor query for point z_h + z_r in the entity index — the
M14 HNSW answers KG completion natively. Embed with anything; serve with
the database.
Questions (answer in notes.md)
- Prove the symmetric-relation collapse (score(h,r,t) = score(t,r,h) for all pairs ⟹ what about z_r?).
- Corrupted-triple sampling assumes false negatives are rare — when is that wrong on a real KG, and which database statistic (topic 9 cardinality) would fix the sampler?
- Link prediction = ANN query: what FILTER does the vector index need (exclude known tails — the “filtered ranking” protocol) and how does that interact with HNSW’s search (topic 14’s filtered-search problem)?
- TransE on our SBM (untyped edges, one relation): what degenerates, and what does that say about when KG embeddings beat node2vec?
- M25 stretch:
CALL algo.transe(rel_types...)— where do per-relation vectors live (graph metadata? a relations table?) and do they update transactionally with edge-type DDL?
References
Papers
- Bordes, Usunier, Garcia-Durán, Weston, Yakhnenko — “Translating Embeddings for Modeling Multi-relational Data” (NeurIPS 2013) — three pages of model; read for the scoring function and training loop
Topic 25 notes — GNNs & graph ML
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
SBM: 64 blocks x 256 = 16,384 vertices, m=566,564 directed (avg_deg 34.6), p_in=0.12, p_out=0.00025, build 34.4 ms.
| lane | result |
|---|---|
| uniform walks 65,536 x 40 | 61.2 ms, 42.8 Msteps/s |
| SpMM (D^-1 A) x X[16384x64] | 3.42 ms/iter, 21.2 GFLOP/s |
| dense matmul [16384x64]x[64x64] | 5.12 ms/iter, 26.2 GFLOP/s |
- SpMM at 81% of dense matmul throughput — the 64-float rows the gather drags along amortize the irregular access. Fat RHS forgives sparsity; topic 20’s SpMV (RHS width 1) never gets this mercy.
- Walk generation is rng-bound, not memory-bound: 42.8 Msteps/s ≈ 23 ns per step (rng + one CSR row index) — the corpus for skip-gram costs less than one training epoch will.
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| node2vec p=1,q=0.5 Msteps/s vs uniform’s 42.8 (rejection + has_edge binary search per candidate) | ||
| ring-of-cliques distinct-per-walk: q=0.25 vs q=4.0 ratio | ||
| skipgram 1 epoch over 2.6M-step corpus, d=64, 5 negs — seconds | ||
| SBM intra-cos − inter-cos margin after 1 epoch | ||
| gcn_norm (CSR, n=16K) ms vs one spmm iter (3.42 ms) | ||
| gcn 2-layer forward 64→64→16: predicted from kernel lanes (2 spmm-ish + 2 dense) |
Implementation log
- walks.rs node2vec_walks — 4 tests (stationary dist, uniform match, q exploration order, p backtrack order)
- embed.rs train_skipgram — SBM block separation > 0.2 margin
- gcn.rs gcn_norm + gcn_forward — dense oracle to 1e-4, sorted rows
- prediction table reconciled
- stretch: TransE on a typed toy KG (score + margin loss), test: true triples outrank corrupted after training
- stretch: neighbor-sampled SAGE mean-aggregation forward; compare full-2-hop vs S=(25,10) nodes-touched on the SBM
- stretch: aggregate-first vs transform-first FLOP crossover sweep (vary d_in at fixed hidden) — plot against measured times
Surprises / dead ends:
- (from building the infra) an SBM inter-block edge count of p_out x inter_pairs = 0.00025 x ~134M pairs ≈ 33.5K sampled edges was the cheap O(m) route — the naive O(n²) Bernoulli sweep over inter pairs would have been 134M rng calls for the same result.
Questions from the reading guides
node2vec (reading-node2vec.md)
- Second-order necessity (what first-order bias can’t express):
- Rejection-sampling draw count at q=0.25 on a bridge vertex:
- What frozen-embedding + logistic-regression evaluation hides:
- Edge insert invalidates unboundedly many walks — IVM implications:
CALL algo.node2vecarg surface vs proc_pagerank:
GCN (reading-gcn.md)
- A_hat eigenvalues in [-1,1] — renormalization’s job:
- 2-hop receptive field on RMAT (low diameter → oversmoothing):
- FLOP crossover for (AX)W vs A(XW), Cora vs our SBM:
- Graph baked into A_hat: what W survives 10% new edges:
- Pending deltas in A_hat = topic 24’s algo.wcc three options:
GraphSAGE (reading-graphsage.md)
- mean + lin_r ≈ concat — lost expressiveness:
- Nodes-touched B=512 S=(25,10) vs full 2-hop (SBM, RMAT hub):
- Sampling for variance vs sampling for work (Afforest):
- Lazy embedding refresh semantics on insert (topic 8 words):
- Transductive vs inductive as the stored artifact — ship which:
GAT (reading-gat.md)
- Softmax over in-edges forces which storage direction:
- Edge passes per GAT vs GCN layer; forward-time ratio estimate:
- a_src/a_dst split = factor-out-of-join:
- Attention as explanation — Cypher surface:
- Is GAT worth engine support (kernel inventory argument):
PyG (reading-pyg-message-passing.md)
- GCNConv.forward trace; _cached_adj_t’s database name:
- COO m x d temp vs CSR spmm memory, bench config + RMAT d=128:
- reduce=‘max’ breaks backward — GraphBLAS-GNN constraint:
- NeighborLoader subgraph ↔ buffer-pool working set:
- The one kernel to expose to Cypher:
TransE (reading-transe.md)
- Symmetric-relation collapse proof:
- False-negative corruption vs cardinality stats:
- Filtered ranking = filtered ANN (topic 14):
- TransE degeneracy on untyped SBM:
- Per-relation vectors: where they live, DDL transactionality:
GraphRAG-SDK (reading-graphrag-sdk.md)
- The one-query replacement for expand_relationships:
- Engine-side 3-way score fusion — WAND-able?:
- Transactional embedding writes — cost to ingest:
- Costing the router (stats for graph-vs-ANN choice):
- M25 acceptance test sketch:
Cross-topic threads
- Aggregation = M20 SpMM with dense RHS; today’s number says the sparse kernel is NOT the bottleneck at d=64 — the transform is comparable. Direction switching never fires (frontier always dense — Ligra’s PageRank row, topic 24).
- Associativity (AX)W vs A(XW) = topic 10 join ordering; GAT’s SDDMM = topic 24’s masked SpGEMM; PyG’s COO-vs-CSR modes = materialize the join vs pipeline the aggregate.
- GraphSAGE neighbor sampling = Afforest’s neighbor_rounds (topic 24) = block-max skipping (topic 23): pay for a sample/bound, not the input.
- Embeddings as materialized views (topic 27 preview): walks are non-incremental (one edge → unboundedly many stale walks); GCN’s A_hat·H·W is algebra — delta-able in principle; SAGE’s stored aggregator makes staleness LOCAL (recompute = one sampled forward).
- GraphRAG hybrid = topic 14 (ANN) + topic 23 (score fusion / top-k) + topic 10 (planning the join between indexes).
- Reproducibility bar (topic 16): seeded walks + seeded SGD; Hogwild parallelism trades it away — same determinism-vs-speed line as Leiden’s seeded refinement (topic 24).
M25 log (capstone)
- embeddings pipeline:
CALL algo.node2vec(...)/CALL algo.gcn_embed(...)computing with M20’s SpMM, writing vecf32 properties into the M14 vector index in one transaction - hybrid query: pattern match +
db.idx.vector.queryNodesin one Cypher plan (kill GraphRAG-SDK’s k+1 round trips — pushdown join) - snapshot semantics for embedding procedures (same decision matrix as topic 24’s algo.wcc: flush / main-only / masked)
- staleness metadata: embedding rows carry the snapshot id they were computed at; queries can demand max-staleness
- stretch: SDDMM kernel in the M20 core (unlocks GAT + attention-as- explanation queries)
Done when
- Three stubs green with lanes filled; prediction table reconciled; guide questions answered; a one-page “which embeddings can the engine own” memo (node2vec vs GCN vs SAGE vs external text embeddings) with our own numbers behind it.
Topic 26 — Indexing & Probabilistic Data Structures
Why this matters: indexes are bets — write amplification paid for read speed. Probabilistic structures make a sharper bet: be slightly wrong in a bounded, one-sided way and win orders of magnitude in space/time. Redis PFCOUNT, RocksDB’s bloom-per-SST, roaring in Lucene/ClickHouse — this is production math, not exotica.
Our motivation numbers first (Apple M3 Pro, 10M sorted u64, 2026-07-10)
| point-miss lookup | ns | memory |
|---|---|---|
| binary search over sorted vec | 167 | 76 MB (the data) |
| BTreeMap | 218 | ~200 MB |
| HashSet | 24 | 224 MB |
| blocked bloom (stub target) | ~15-25 | 12 MB at 10 bits/key |
The whole topic in one row: the bloom filter should answer “definitely absent” at HashSet speed with 5% of HashSet’s memory — by being wrong (one-sided!) 1% of the time. And binary search’s 167 ns is ~23 dependent cache misses; the learned index bets most of that tree walk is predictable.
The three families
FILTERS: "is X in the set?" one-sided error (no false negatives)
bloom ── blocked bloom ── cuckoo ── xor ── ribbon
(k probes) (1 cache line) (+delete) (static, (rocksdb's pick:
1.23x info space near xor,
bound) streaming build)
SKETCHES: "how many / how often?" bounded relative error
HLL (count distinct) count-min (frequencies) t-digest (quantiles)
LEARNED / SUCCINCT: "where is X?" bounded position error
RMI ── PGM (eps-guarantee PLA) ── ALEX (updatable gapped arrays)
Elias-Fano (postings/adjacency in near-information-theoretic space)
Bloom math you should be able to reproduce
k probes, b bits/key: FPR ≈ (1 − e^(−k/b))^k
optimal k = b·ln2 → at 10 bits/key: k≈7, FPR ≈ 0.82%
rule of thumb: every +4.8 bits/key HALVES... no — ×10 needs +4.8 bits?
memorize instead: 10 bits/key ≈ 1%, 16 ≈ 0.04%, each bit/key is ~2× FPR
Blocked bloom (RocksDB FastLocalBloomImpl, util/bloom_impl.h:144) puts
all k probes in ONE 512-bit cache line: a miss costs exactly one memory
access instead of k. The price is Poisson crowding — some lines hold too
many keys and their FPR spikes (bloom_impl.h:42 CacheLocalFpRate sums
the two tails). Measured claim to verify in the stub: ~1.5-2× the standard
FPR at the same bits/key, for k× fewer misses.
The lineage in one diagram
flowchart LR
B["bloom '70<br/>k probes, k misses"] --> BB["blocked bloom<br/>1 line, FPR tax"]
B --> C["cuckoo CoNEXT'14<br/>fingerprints in buckets:<br/>DELETE + better FPR<br/>at high bpk"]
C --> X["xor JEA'20<br/>static, 1.23 bits/fp-bit,<br/>build-once peel-graph"]
X --> R["ribbon arXiv'21<br/>same space family,<br/>banded linear algebra,<br/>rocksdb bloom_v2 successor"]
Cuckoo’s enabling trick (RedisBloom cuckoo.c:122 getAltHash): the
alternate bucket is i XOR hash(fp) — computable from the fingerprint
alone, so residents can be kicked without knowing their original keys.
Deletion falls out: fingerprints are discrete residents, not smeared bits.
HLL: counting distinct in 12 KB
One hashed key contributes only its leading-zero count. Register j keeps
the max rank seen among keys landing there; harmonic-mean magic turns
16,384 six-bit maxima into a cardinality estimate at 0.81% standard error
(P=14). Redis (hyperloglog.c) adds a sparse encoding — ZERO/XZERO/VAL
opcodes (:380) — so an HLL tracking 100 elements costs ~30 bytes, not
12 KB, and promotes to dense at 3 KB (:593 hllSparseToDense). Merge =
register-wise max = perfect sharding (PFMERGE; AVX2 version at :1116).
Learned indexes: the index IS a model
PGM (pgm_index.hpp:67): recursively fit piecewise-linear segments with a HARD error bound ε — lookup = walk 2-3 segment levels, binary-search a 2ε+2 window. On smooth key distributions, segments ≪ n and the hot path fits in cache where a B-tree’s top levels don’t even. ALEX answers the update question with gapped arrays + model-based insertion (alex_nodes.h; exponential search from the predicted slot). The honest question our bench asks: does PGM’s 167→~100 ns win survive keys that aren’t uniform, and does ALEX survive adversarial inserts? (Predict in notes.md first.)
Geo indexes: 2D keys through 1D indexes
Same theme as learned indexes — encode structure into the key. Redis/
valkey GEO is not a spatial index at all: it’s a 52-bit interleaved
geohash stored as a zset score. Bit-interleave lat/lon
(interleave64, geohash.c:52 — the Morton/Z-order trick with magic
masks), and prefix-similar codes = spatially-near points, so a bounding
box becomes a handful of zset RANGE queries
(scoresOfGeoHashBox, geo.c:338: score range = hashcode << shift to
hashcode+1 << shift). GEOSEARCH = pick a cell size covering the radius
(geohashEstimateStepsByRadius, geohash_helper.c:64), scan the cell +
its 8 neighbors (membersOfAllNeighbors, geo.c:375), then exact
haversine post-filter — a candidate-generation + verification pattern,
exactly like a bloom filter’s “maybe” answer.
the menu:
Z-order/geohash interleave bits; 1D-index reuse discontinuities at
(zset, B-tree, anything) cell boundaries
Hilbert curve better locality (no big jumps) costlier encode
R-tree bounding-box tree (Guttman'84); overlap ⇒ multi-path
PostGIS via GiST descent; R* splits
S2 / H3 sphere-native cells (Google/Uber) discrete cells only,
hierarchy = prefix great for sharding
The deep lesson: postgres didn’t hardcode any of these — GiST is an
extensible index AM (topic 26’s indexam guide) where R-tree is just
one picksplit/penalty implementation. Geohash-in-a-zset is the
opposite move: zero new index structures, reuse what you have.
→ guide: reading-geo-indexes.md
The stubs (experiments/)
| stub | contract |
|---|---|
bloom::BlockedBloom | zero false negatives; FPR < 2.5% at 10 bpk (< 4× theory); halves 8→16 bpk; whole-cache-line sizing |
cuckoo::CuckooFilter | no FN at 90% load; FPR < 1% (12-bit fp); delete works AND leaves others intact; graceful full-failure |
hll::Hll | < 3% error at 1K/100K/5M; merge registers == union registers exactly |
pgm::LearnedIndex | ε-window always contains the key; uniform 1M keys → < 2K segments; ε holds on hostile distributions |
Roaring already has a stub in topic 23 (postings.rs — array/bitmap
containers); this topic’s reading guide adds run containers + galloping +
SIMD over roaring-rs.
Reading guides
- reading-bloom-to-ribbon.md — Bloom → blocked → ribbon: fifty years of filter fixes
- reading-cuckoo-xor.md — Cuckoo & XOR filters: fingerprints you can delete
- reading-hyperloglog.md — HyperLogLog: count distinct in 12 KB
- reading-learned-indexes.md — Learned indexes: the index is a model of the CDF
- reading-roaring-internals.md — Roaring bitmaps: adaptive containers for integer sets
- reading-geo-indexes.md — Geo indexes: 2D queries through the 1D index you already have
- reading-postgres-indexam.md — Postgres index AMs: nbtree, GIN, BRIN — the exact baseline
Cross-topic links
- Topic 4 (LSM): blooms exist because LSM point-misses touch every level.
- Topic 12: BRIN ≈ zone maps; topic 23: roaring = the postings kernel, {last_doc, max_score} skip data = a filter on score.
- Topic 20: roaring’s array↔bitmap switch = GraphBLAS sparse↔bitmap at 64K granularity (the same density crossover, measured twice).
- Topic 9 (HLL for count-distinct) → M26’s approximate
count(DISTINCT).
Bloom → blocked → ribbon: fifty years of filter fixes
A filter answers “definitely absent / maybe present” in ~10 bits per key, which is why every LSM read path starts with one. Bloom’s 1970 design has exactly two sins — space and cache misses — and this chapter follows the fixes for each into the two filters RocksDB actually ships.
Why this sequence
Bloom’s 1970 filter is information-theoretically ~44% wasteful (1.44·log2(1/fpr) bits/key vs the log2(1/fpr) lower bound) and cache-hostile (k probes = k misses). Fifty years of fixes attack exactly those two sins:
sin #1: k cache misses sin #2: 1.44x space
bloom '70 ───────────┬─────────────────────────────┬──────────
▼ ▼
blocked bloom: all k probes in one line ribbon: linear algebra over
(pay ~1.5-2x FPR for it) GF(2), ~1.10x space, static
1. The math you must own before reading code
Derive (don’t memorize) FPR ≈ (1 − e^(−kn/m))^k:
- One insert with one probe leaves a given bit 0 with prob (1 − 1/m).
- After kn probes: (1 − 1/m)^kn ≈ e^(−kn/m) — fraction of bits still 0.
- A miss query needs all k of its probe bits set: (1 − e^(−kn/m))^k.
- Minimize over k: optimal k = (m/n)·ln2 ≈ 0.69·bits_per_key. At 10 bpk → k≈7.
Q1. At optimal k, exactly half the bits are set. Why is that intuitive? (Hint: a bit-array with maximal entropy per bit.)
2. bloom_impl.h — RocksDB’s two generations
| anchor | what it is |
|---|---|
LegacyBloomImpl (:364-476) | old format: one cache line per key (AddHash :432 picks num_lines), but probes derived by weak shift-rotate — measurable FPR bias |
FastLocalBloomImpl (:144) | current “format_version=5” bloom: 512-bit (64-byte) blocks, probes from h *= 0x9e3779b9 golden-ratio remix |
AddHashPrepared (:206) | the probe loop: each probe uses bits (h >> 27) & 511 of a re-multiplied h — 9 bits per probe, all inside one line |
HashMayMatchPrepared (:231) | query = same loop, early-exit on first zero bit |
CacheLocalFpRate (:42) | the honesty function: computes blocked-bloom FPR as the expectation over the Poisson distribution of keys-per-block |
The entire query path, de-SIMD’d (this is HashMayMatchPrepared):
#![allow(unused)]
fn main() {
const PROBES: u32 = 6;
fn may_contain(bits: &[u64], num_blocks: u32, h1: u32, mut h2: u32) -> bool {
let block = fastrange32(h1, num_blocks) as usize * 8; // 8 words = 512 bits
for _ in 0..PROBES {
let bit = (h2 >> 23) & 511; // top 9 bits pick 1 of 512
if bits[block + (bit / 64) as usize] & (1u64 << (bit % 64)) == 0 {
return false; // early exit, ONE line touched
}
h2 = h2.wrapping_mul(0x9e3779b9); // golden-ratio remix per probe
}
true // maybe
}
}
Read CacheLocalFpRate carefully — it’s the whole blocked-bloom trade in
10 lines. A block that got 2× the average keys has much worse FPR, and the
weighted sum is worse than the naive StandardFpRate at the same bpk.
That’s the number our stub’s fpr < 4× theory test bounds.
Q2. FastLocalBloomImpl uses h1 to pick the block (via
fastrange, not modulo) and h2 to derive all probe bits. Our stub does the
same. Why must the block choice NOT reuse bits that pick probes?
Q3. Why 512-bit blocks and not 64-bit words? (Two effects fight: smaller blocks = fewer distinct probe positions = FPR tax explodes; the answer is the cache line is the natural “free” granule.)
3. ribbon_impl.h — filters as linear algebra
The conceptual jump: a bloom filter sets bits; a ribbon filter solves for bits. Each key contributes one equation over GF(2):
row(key) · S = fingerprint(key) ← S is the filter, r fingerprint bits
Query recomputes row·S and compares. False positive = a non-key whose equation happens to hold: 2^−r exactly, so space ≈ r·(1+overhead) bits/key — overhead is the fraction of unusable slots, ~10% for standard ribbon vs 44% for bloom.
The “ribbon” trick makes solving cheap: StandardHasher (:165) gives each
key a coefficient vector that is nonzero only in a kCoeffBits-wide (:114,
= 64 or 128) band starting at a hashed position. Banded Gaussian
elimination is then O(n) with tiny constants — StandardBanding (:471,
num_starts_ = num_slots - kCoeffBits + 1 at :504) does incremental
back-substitution as keys stream in (BandingAddRange :577).
Q4. Ribbon construction can fail (singular system) and RocksDB
retries with a different hash seed (StandardRehasherAdapter :416). Cuckoo
insertion can also fail (MAX_KICKS). Blocked bloom never fails. What does
this monotone-vs-solve distinction cost each design at build time?
Q5 (cross-check with topic 4). RocksDB picks ribbon for the bottom
LSM levels and blocked bloom for the hot top levels
(level_compaction_dynamic_level_bytes + RibbonFilterPolicy’s
bloom_before_level). Why does that split follow directly from
“ribbon: ~30% less space but several× slower to build and query”?
4. Tie back to the stub
Our bloom::BlockedBloom is FastLocalBloomImpl minus SIMD:
hash2 gives (h1, h2); fastrange32(h1, blocks) picks the block;
6 probes each take 9 bits from a rotating h2. After implementing, compare
your measured FPR-vs-theory ratio against what CacheLocalFpRate predicts
for your keys-per-block Poisson mean.
References
Papers
- Bloom — “Space/Time Trade-offs in Hash Coding with Allowable Errors” (CACM 1970) — 5 pages, read whole
- Dillinger & Walzer — “Ribbon filter: practically smaller than Bloom and Xor” (arXiv:2103.02515, 2021)
Code
- rocksdb
util/bloom_impl.h+util/ribbon_impl.h— Peter Dillinger’s blog-style comments inside the headers are the best docs; read code and comments together
Cuckoo & XOR filters: fingerprints you can delete
Bloom smears each key across k shared bits; cuckoo filters store each
key as one discrete fingerprint in one of two buckets — which buys
deletion and a better space/FPR trade, at the price of inserts that can
fail. XOR filters then drop updatability entirely and win more space.
The reference implementation here is RedisBloom’s cuckoo.c.
1. The one trick that makes cuckoo filters possible
Cuckoo hashing moves keys between two candidate buckets. But a filter stores only fingerprints — after insertion the original key is gone, so how do you compute a victim’s alternate bucket to kick it?
Partial-key cuckoo hashing (paper §3.1; getAltHash, cuckoo.c:122):
i1 = hash(key)
i2 = i1 XOR hash(fingerprint) ← involution: i1 = i2 XOR hash(fp)
The alternate is computable from (current bucket, fingerprint) alone.
This forces the bucket count to a power of two (XOR must stay in range —
RedisBloom asserts it at filter creation) and it means the two buckets
aren’t independent — a fingerprint’s candidate pair is determined by only
log2(buckets) + fp_bits bits, which caps how large the table can get
before FPR degrades (paper §4).
Q1. Why hash the fingerprint in i1 XOR hash(fp) instead of the
simpler i1 XOR fp? (Paper §3.1: with small fp values, unhashed XOR only
perturbs the low bits — kicked keys land nearby and clump.)
2. cuckoo.c — the production shape
| anchor | what it does |
|---|---|
getAltHash :122 | the involution above |
Filter_Find :146 | check fp in both candidate buckets |
Filter_FindAvailable :241 | first empty slot in either bucket |
Filter_KOInsert :307 | the kicking loop: evict a resident (ii = getAltHash(fp, ii) :321), swap, retry up to maxIterations |
CuckooFilter_InsertFP :256 | try all subfilters’ empty slots first, kick only in the newest, grow a new subfilter when kicking fails |
CuckooFilter_Delete :216 | delete = find + zero the slot, newest subfilter first |
The insert path with the kicking loop, in one screen:
#![allow(unused)]
fn main() {
fn insert(&mut self, key: &[u8]) -> bool {
let (mut fp, i1) = self.fp_and_index(key); // fp: 12 bits, never 0
let i2 = (i1 ^ self.hash_fp(fp)) & self.mask; // partial-key involution
if self.put_if_free(i1, fp) || self.put_if_free(i2, fp) { return true; }
let mut i = if coin_flip() { i1 } else { i2 };
for _ in 0..MAX_KICKS { // 500
fp = self.swap_with_random_resident(i, fp); // evict someone
i = (i ^ self.hash_fp(fp)) & self.mask; // victim's OTHER bucket
if self.put_if_free(i, fp) { return true; }
}
false // paper behavior; RedisBloom grows a subfilter instead
}
}
Note what RedisBloom adds over the paper: a chain of subfilters (like an
LSM of filters). When kicking fails at MAX_KICKS it doesn’t return “full” —
it allocates a new subfilter and inserts there. Our stub instead returns
false (the paper behavior) — the graceful-failure test pins that.
Q2. Deletion is only safe if the key was actually inserted (deleting a
false-positive fingerprint removes someone else’s resident, creating a
false negative for them). Redis documents this contract. How would you
misuse CF.DEL to silently corrupt a filter, and why can’t bloom have this
failure mode (nor deletion at all)?
Q3. Why 4 slots per bucket? Paper Table 2: with 1 slot, load factor
tops out ~50%; with 4, ~95%. But more slots = more fingerprints compared
per query = higher FPR (2 × slots × 2^−f). Where’s our stub’s FPR bound
(12-bit fp, 4 slots, ~0.9 load) relative to the < 1% test?
3. Xor filters — drop updates, win space
The xor filter takes cuckoo’s fingerprint idea and asks: if the set is static, why pay for empty slots and kicking at all? Store an array B of fingerprints such that for every key:
B[h0(x)] XOR B[h1(x)] XOR B[h2(x)] = fingerprint(x)
Construction “peels” a random 3-uniform hypergraph: repeatedly find a key that is the only one touching some slot, assign that slot last (stack), pop and back-fill. Succeeds w.h.p. when slots ≥ 1.23 × keys — hence 1.23 × f bits/key, beating both bloom (1.44×) and cuckoo (~1.05/α× but α≤0.95 plus empty-slot overhead), with exactly 3 memory accesses per query.
Q4. The peeling stack is why xor filters are build-once: adding one key invalidates the topological order. Ribbon (see reading-bloom-to-ribbon.md) gets the same space family but supports streaming build via banded elimination. Rank bloom/cuckoo/xor/ribbon along (updatable, space, query misses) and match each to: memtable filter, routing table with churn, immutable SST.
4. The lineage, with the trade each hop makes
flowchart TD
B["bloom: k smeared bits/key<br/>1.44x space, k misses, no delete"]
BB["blocked bloom: 1 miss<br/>pays ~1.5-2x FPR"]
CK["cuckoo: discrete fingerprints<br/>delete + ~0.18% FPR at 12 bits<br/>pays: build can fail, pow2 sizing"]
X["xor: static peeling<br/>1.23x, 3 flat misses<br/>pays: no updates ever"]
RB["ribbon: banded GF(2) solve<br/>~1.10x, streaming build<br/>pays: slower build/query CPU"]
B --> BB
B --> CK --> X --> RB
5. Tie back to the stub
cuckoo::CuckooFilter is cuckoo.c minus subfilter chaining: pow-2 buckets
of 4 × u16, 12-bit fp (never 0 = empty), random-victim kicking to
MAX_KICKS=500. The delete_actually_removes test is the point of the whole
exercise — it’s the test a bloom filter cannot pass.
References
Papers
- Fan, Andersen, Kaminsky, Mitzenmacher — “Cuckoo Filter: Practically Better Than Bloom” (CoNEXT 2014) — §3 algorithm, §4 why partial-key works, §5 space analysis; skim the eval
- Graf & Lemire — “Xor Filters: Faster and Smaller Than Bloom and Cuckoo Filters” (ACM JEA 2020, arXiv:1912.08258) — §2-3
Code
- RedisBloom
src/cuckoo.c— the production shape, including the subfilter-chain growth the paper doesn’t have
Geo indexes: 2D queries through the 1D index you already have
Spatial search looks like it demands a new index structure — valkey’s GEO commands prove it doesn’t: interleave the coordinate bits into one integer and a plain sorted index becomes a spatial one. This chapter walks that trick, why the curve you pick matters (Z-order vs Hilbert), and the families that do build real spatial structures (R-tree, S2, H3).
The valkey GEO trick: no spatial index at all
GEOADD key lon lat member
│
▼
lat, lon each quantized to 26 bits within their range
│
▼ interleave64(lat_bits, lon_bits) geohash.c:52
52-bit Morton code: y25 x25 y24 x24 ... y0 x0
│ (interleave via magic-mask shifts — the same
│ bit-twiddling as HAKMEM/Bit Twiddling Hacks)
▼
ZADD key <52-bit code as double score> member
── the "index" is the zset you already had
GEOSEARCH radius r:
step = geohashEstimateStepsByRadius(r, lat) geohash_helper.c:64
(pick cell level so one cell ≳ the radius; higher lat ⇒
cells narrow ⇒ adjust — spherical reality leaks in)
for cell + 8 neighbors: geo.c:375
score range = [hash << (52-2·step), (hash+1) << ...]
geo.c:338
ZRANGEBYSCORE → candidates geo.c:367
exact haversine filter on candidates
The interleave is five magic-mask rounds (geohash.c:52 does exactly this):
#![allow(unused)]
fn main() {
fn interleave64(xlo: u32, ylo: u32) -> u64 {
let spread = |mut v: u64| { // 26 bits → every other bit
v = (v | (v << 16)) & 0x0000FFFF0000FFFF;
v = (v | (v << 8)) & 0x00FF00FF00FF00FF;
v = (v | (v << 4)) & 0x0F0F0F0F0F0F0F0F;
v = (v | (v << 2)) & 0x3333333333333333;
v = (v | (v << 1)) & 0x5555555555555555;
v
};
spread(xlo as u64) | (spread(ylo as u64) << 1) // y25 x25 ... y0 x0
}
}
Two ideas worth stealing:
- Reuse the index you have. A sorted structure + a space-filling-curve key = a spatial index. FalkorDB could do the same over any sorted node-property index.
- Candidate-then-verify. The 9-cell scan over-fetches (corners of the square aren’t in the circle); the exact filter fixes it. One-sided error, then verification — a bloom filter’s control flow, applied to geometry.
Why Z-order has seams (and Hilbert doesn’t)
Z-order visits cells: Hilbert visits cells:
0 ─ 1 4 ─ 5 0 ─ 1 E ─ F
│ ╱ │ │ │
2 ─ 3 6 ─ 7 3 ─ 2 D ─ C
BIG JUMP neighbors stay
(3 → 4 crosses the 1 apart on the
whole quadrant) curve, mostly
Adjacent cells can be far apart on the Z-curve, so one bounding box decomposes into many score ranges (valkey caps it by scanning the fixed 3×3 neighborhood instead of decomposing precisely). Hilbert curves keep neighbors closer at the cost of a more expensive encode — the trade S2 takes (Hilbert on a cube projected to the sphere).
The other families
- R-tree (Guttman ’84): tree of bounding boxes; children may
OVERLAP, so a lookup may descend multiple paths — the
penalty/picksplitheuristics (minimize area/overlap enlargement) are the whole game; R* re-inserts to fix bad early splits. PostGIS = R-tree implemented as a GiST extension — read reading-postgres-indexam.md with this in mind: GiST is the AM that letspicksplit/penaltybe plugins. - S2 (Google): sphere → 6 cube faces → quadtree per face → Hilbert-ordered 64-bit cell IDs. Hierarchy = prefix relation, so containment tests are integer ops; coverings of a region are sets of cells at mixed levels.
- H3 (Uber): hexagons (equidistant neighbors — nicer for gradients/flows), icosahedron-based, but hexes don’t nest cleanly — the hierarchy is approximate. Great for sharding/aggregation, weaker for exact containment.
Questions
- Why 26 bits per axis (52 total)? Connect to the zset score being a double — what goes wrong at 27 bits, and what precision in meters does 26 give at the equator?
geohashEstimateStepsByRadiustakes the latitude as an argument (geohash_helper.c:64). Why does the same radius need a different cell level at 60°N than at the equator, and what breaks near the poles (see the clamps)?- The 9-cell candidate scan over-fetches by roughly what factor (area of 3×3 cells vs the inscribed circle)? When is precise Z-range decomposition (many small ranges) worth it instead?
- An R-tree lookup can descend multiple children; a B-tree never
does. What property of the keys makes single-path descent
impossible for boxes, and how does R*
picksplitreduce (not eliminate) it? - S2 cell IDs make “is cell A inside cell B” a prefix check on integers. Show the bit layout that makes this work, and why H3’s hexagons can’t have the same exact property.
- M26 mapping: sketch
GEO.ADD/GEO.SEARCHfor the capstone graph — node position as a property, 52-bit Morton key in the sorted property index M26 already builds. What’s the only new code (encode + 9-cell range computation + haversine), and what’s reused verbatim?
References
Papers
- Guttman — “R-Trees: A Dynamic Index Structure for Spatial Searching” (SIGMOD 1984)
- Beckmann, Kriegel, Schneider, Seeger — “The R*-tree” (SIGMOD 1990)
Code & docs
- valkey
src/geohash.c,src/geohash_helper.c,src/geo.c - s2geometry.io — S2 cell hierarchy docs
- h3geo.org — H3 hex grid docs
HyperLogLog: count distinct in 12 KB
count(DISTINCT x) over billions of elements, 0.81% error, 12 KB of
state, and per-shard sketches that merge losslessly in any order — one
probabilistic observation buys all of it. This chapter derives the
estimator, then walks redis’s production implementation, which adds a
sparse encoding and a better count formula on top.
1. The idea in three sentences
Hash every element; the probability that a hash starts with j zero bits is 2^−(j+1), so the maximum leading-zero run seen is a (very noisy) log2 of the cardinality. Split the stream into m=2^P substreams by the low P bits and keep one 6-bit max (“register”) per substream; averaging m noisy estimates cuts the error to ~1.04/√m — 0.81% at P=14. Duplicates are free: max() is idempotent, which is also why union = register-wise max, exactly.
hash(x) = |...... 50 bits pattern ......|.. 14 bits ..|
↓ ↓
rank = lzcnt+1 (1..51) register index j
regs[j] = max(regs[j], rank) m = 16384
The whole write path is five lines, and the merge is one:
#![allow(unused)]
fn main() {
const P: u32 = 14;
const M: usize = 1 << P; // 16384 registers, 1 byte each here
fn add(regs: &mut [u8; M], x: &[u8]) {
let h = hash64(x);
let j = (h & (M as u64 - 1)) as usize; // low P bits: which register
let pat = h >> P; // remaining 50 bits: the pattern
let rank = (pat.trailing_zeros() + 1).min(64 - P + 1) as u8;
regs[j] = regs[j].max(rank); // max is idempotent: dups free
}
fn merge(a: &mut [u8; M], b: &[u8; M]) {
for j in 0..M { a[j] = a[j].max(b[j]); } // == the HLL of the union, exactly
}
}
Q1. Why must the index bits and the pattern bits not overlap? (What
correlation would rank and j share, and what does it do to the m
independent-substreams assumption?)
2. hyperloglog.c anchors — the dense path (what our stub implements)
| anchor | what it does |
|---|---|
| :196-198 (header comment area) | P=14, 6-bit registers, the dense layout |
hllPatLen :467 | hash, split index/pattern, count zero run — mirrors our add recipe exactly (note: redis sets bit 63 as a sentinel so the loop terminates; we cap rank at 64−P+1 instead) |
hllDenseSet :502 | the 6-bit pack/unpack shift dance (:354 comment walks it) — we spend a byte per register to skip this |
hllDenseRegHisto :528 | builds reghisto[rank] — count() consumes the histogram, not the registers |
hllSigma :1016, hllTau :1033 | Ertl’s two series (linear-counting-like correction at the low end, saturation correction at the high end) |
hllCount :1058 | the estimator: m·tau(...), fold histogram with repeated halving, + m·sigma(reghisto[0]/m), then alpha_inf·m²/z |
hllMergeDense :1279 (AVX2 :1116, NEON :1218) | merge = per-register max, vectorized |
Ertl’s estimator replaced the old empirical-bias-table + linear-counting switchover from HLL-in-Practice §5. That’s worth pausing on: Google’s fix was piecewise empirical patching; Ertl re-derived the estimator so one formula is unbiased across the whole range. Redis shipped Google’s version for years, then switched (see the comment above hllCount).
The estimator, transcribed (this is hllCount minus the caching):
#![allow(unused)]
fn main() {
fn count(regs: &[u8; M]) -> f64 {
let mut histo = [0u32; 64];
for &r in regs { histo[r as usize] += 1; } // count() reads the HISTOGRAM
let m = M as f64;
let q = 64 - P; // max rank = q + 1
let mut z = m * tau((m - histo[q as usize + 1] as f64) / m);
for k in (1..=q).rev() { z = 0.5 * (z + histo[k as usize] as f64); }
z += m * sigma(histo[0] as f64 / m); // zero registers → low-range fix
ALPHA_INF * m * m / z // alpha_inf = 1/(2 ln 2)
}
}
Q2. reghisto[0] counts never-touched registers. sigma() blows up to
+inf as that fraction → 1. Show that for n ≪ m the estimator degenerates to
linear counting m·ln(m/V) where V = zero registers — i.e., the low-range
“switch” is now built into the formula.
3. The sparse encoding — why PFCOUNT keys start at 30 bytes
Dense = 12 KB always, even for 3 elements. The sparse encoding (:380-383 opcode table) run-length-encodes the mostly-zero register array:
ZERO: 00xxxxxx → 1..64 zero registers in ONE byte
XZERO: 01xxxxxx yyyyyyyy → 1..16384 zero registers in two bytes
VAL: 1vvvvvxx → a value 1..32, repeated 1..4 times
An empty HLL = XZERO(16384) = 2 bytes + header. hllSparseSet (:675) is
a 150-line opcode splice — an insert into a compressed stream — and
promotes to dense (hllSparseToDense :593) when the encoding exceeds
hll-sparse-max-bytes (3 KB default) or any rank > 32 arrives (VAL only
has 5 value bits).
Q3. Why can sparse only represent ranks ≤ 32, and why is that almost never the trigger for promotion in practice? (What cardinality does a rank of 33 imply for that substream?)
Q4 (cross-topic). ZERO/XZERO/VAL vs roaring’s array/bitmap/run containers (reading-roaring-internals.md): both are “adaptive encodings that promote when density crosses a threshold.” Name the density metric each one switches on.
4. Sharding — the killer feature
merge(A,B).regs == union(A∪B).regs exactly (our test demands register
equality, not approximate counts). So HLLs commute with any partitioning:
per-shard, per-hour, per-node sketches merge losslessly in any order — a
semilattice (max is associative, commutative, idempotent). This is why
topic 9’s count(DISTINCT) can be pushed below a shuffle, and why M26’s
approximate distinct-count needs no coordination.
Q5. PFADD on a dense HLL touches 1 register; PFMERGE touches all 16384. Redis stores HLLs as strings and PFADD is O(1) amortized. Sketch how you’d maintain a per-label HLL inside a graph engine’s write path (topic 26 M-log) without making every node-insert O(m).
5. Tie back to the stub
hll::Hll = dense redis at byte granularity: add is hllPatLen +
register max, count is hllCount’s tau/sigma transcribed, merge is
hllMergeDense scalar. The < 3% error test at n ∈ {1K, 100K, 5M} spans
the ranges the old estimator needed three different formulas for.
References
Papers
- Heule, Nunkesser, Hall — “HyperLogLog in Practice” (Google, EDBT 2013) — §3-5 are the practical fixes; the original Flajolet ’07 analysis is optional
- Ertl — “New cardinality estimation algorithms for HyperLogLog sketches” (arXiv:1702.01284, 2017) — §2-3; the estimator redis uses now
Code
- redis
src/hyperloglog.c— the 200-line header comment is a full spec of the encodings; read it before the functions
Learned indexes: the index is a model of the CDF
An index maps key → position. If the key distribution is smooth, a handful of linear models approximates that map with a bounded error you binary-search away — replacing a tree walk’s cache misses with two multiply-adds. Three designs mark the territory: RMI (the provocation), PGM (the guarantee — our stub), and ALEX (the one that takes writes).
1. Reframe: a B-tree is already a model
Kraska’s opening move: an index maps key → position, i.e. it approximates the CDF of the key distribution scaled by n. A B-tree is a piecewise-constant approximation with worst-case-everything guarantees; if the CDF is smooth, a few linear models predict position in O(1) with a small error to binary-search away:
pos ≈ n · CDF(key)
B-tree: log_B(n) node hops, each a cache miss (167 ns measured, ~23 misses)
learned: 1-2 model evals + binary search of 2ε (the bet: most of the
window tree walk is predictable)
RMI (Kraska §3) = a fixed 2-stage hierarchy of models where stage 1 picks the stage-2 model. Its flaw: no error bound — a bad model means a long exponential search, and there’s no principled way to size the stages.
2. PGM — the version with a guarantee (this is our stub)
PGM inverts the design: fix the error ε first, then compute the minimum number of linear segments such that every key’s predicted position is within ε of the truth. Then index the segments’ first keys with… another PGM, recursively, until one segment remains.
anchor (~/repos/PGM-index/include/pgm/) | what it is |
|---|---|
pgm_index.hpp:32-33 | PGM_SUB_EPS/PGM_ADD_EPS — the window is [pos−ε, pos+ε+2), clamped; the +2 matters (segment boundaries) |
pgm_index.hpp:67 | class PGMIndex; build :88 loops make_segmentation per level |
segment_for_key :134 | the recursive descent: each level is itself ε-bounded, so each hop is a constant-size search (:143-152), not a binary search over all segments |
search :192 | predict, widen by ε, return the window — our search_window |
piecewise_linear_model.hpp:45 | OptimalPiecewiseLinearModel — O’Rourke ’81 streaming convex-hull method |
add_point :96, hull updates :154-190 | maintains upper/lower convex hulls of the feasible-slope region; segment closes when hulls cross |
make_segmentation :276 | the greedy driver: if (!opt.add_point(x,y)) { out(segment); start fresh } |
The hull method is optimal (fewest segments for a given ε). Our stub uses the simpler shrinking cone: keep an interval [lo, hi] of feasible slopes through the segment’s first point; each new point narrows it; emit when empty. Same ε guarantee, ≥ as many segments, and O(1) state instead of two hulls.
#![allow(unused)]
fn main() {
struct Cone { x0: u64, y0: f64, lo: f64, hi: f64 } // slopes through (x0,y0)
fn add_point(c: &mut Cone, x: u64, y: usize, eps: f64) -> bool {
let (dx, dy) = ((x - c.x0) as f64, y as f64 - c.y0);
c.lo = c.lo.max((dy - eps) / dx); // each point NARROWS the feasible
c.hi = c.hi.min((dy + eps) / dx); // slope interval...
c.lo <= c.hi // ...empty ⇒ emit segment, start fresh
}
}
Q1. Construct 4 points where the cone closes a segment but the hull method keeps going. (Hint: the cone forces every prediction line through the first point; optimal PLA doesn’t.)
Q2. ε trades segment count against final-search width. Segments live in cache; the 2ε window is one or two line fetches into the data. Given the motivation numbers (167 ns ≈ 23 misses), predict the ns/lookup curve for ε ∈ {16, 64, 256} on 10M uniform keys before running filter_bench.
Q3. uniform_data_compresses_hard demands < 2K segments for 1M random
u64. Why is a uniform CDF the easy case, and what real key patterns are
near-uniform? (auto-increment IDs, timestamps at steady ingest, …) What
breaks it? (hot/cold tenants, hash-distributed keys with gaps, …)
3. ALEX — answering “but what about inserts?”
A static PGM re-builds on change. ALEX makes the data layout absorb updates: nodes are gapped arrays (~50% slack), and the model is used not only to search but to place — model-based insertion puts a key at its predicted slot, so the model stays accurate as data arrives.
anchor (~/repos/ALEX/src/core/alex_nodes.h) | what it is |
|---|---|
class AlexDataNode :293 | gapped array + per-node linear model; num_keys_ :325 vs slots = the gap budget |
predict_position :1448 | the model eval |
find_key :1456 | predict, then exponential_search_upper_bound :1462 from the predicted slot — cost is O(log distance-of-model-error), no ε needed |
find_insert_position :1497 | same predict-then-search on the insert path |
| :28, :474, :1513 | the gap machinery: bitmap marks gap vs key; inserts shift toward the closest gap, not the array end |
Exponential search is the right primitive when the error is usually 0-2 slots but unbounded: cost adapts to actual error, and it’s why ALEX can skip PGM’s hard-ε accounting. When a node overflows its density bound it splits and retrains — the B-tree skeleton reappears, but with models as node search and gaps as write absorbers.
Q4. Adversarial inserts: append keys so every new key lands at the same predicted slot (e.g. exponentially clustered values). What happens to ALEX’s shifts-per-insert, and which classical structure degrades the same way under sorted-order inserts? (This is the “does ALEX survive adversarial inserts?” question in notes.md — predict, then read the paper’s §5.5.)
Q5 (cross-topic). ALEX’s gapped array + model placement vs a B-tree leaf with slotted-page free space (topic 2): both reserve slack to make inserts local. What does ALEX’s model buy over the B-tree’s binary search within the leaf, and when is it worth zero? (Uniform small leaves fit in one cache line either way.)
4. The honest scoreboard
build lookup (smooth keys) lookup (hostile) inserts
B-tree O(n log n) ~log_B(n) misses same native
RMI train fast, NO bound can be terrible no
PGM O(n) 1-3 hops + 2ε window MORE segments, PGM-dynamic:
bound still holds LSM-of-PGMs
ALEX O(n) predict + exp search retrain storms native, gapped
The ε guarantee is the deep difference: PGM degrades in space (more
segments) on hostile data while lookup stays bounded; RMI degrades in
time; ALEX degrades in write amplification. Our
epsilon_holds_on_hostile_distribution test pins the PGM behavior.
References
Papers
- Kraska, Beutel, Chi, Dean, Polyzotis — “The Case for Learned Index Structures” (SIGMOD 2018, arXiv:1712.01208) — §1-3 (RMI), skim the rest
- Ferragina & Vinciguerra — “The PGM-index” (VLDB 2020, pgm.di.unipi.it)
- Ding et al. — “ALEX: An Updatable Adaptive Learned Index” (SIGMOD 2020, arXiv:1905.08898)
Code
- PGM-index
include/pgm/—pgm_index.hpp+piecewise_linear_model.hpp - ALEX
src/core/—alex_nodes.his where the gapped-array machinery lives
Postgres index AMs: nbtree, GIN, BRIN — the exact baseline
Every structure in this chapter answers the same question our filters and sketches answer — “where might X be?” — but with exactness paid for in space and cache misses. Read nbtree, GIN, and BRIN as the prices the probabilistic structures undercut.
1. nbtree — what 23 cache misses buys you
_bt_search (nbtsearch.c:100) walks root→leaf: read page, _bt_binsrch
(:33, called at :153) within the page, follow the downlink, repeat. Three
things bloom/PGM don’t have to deal with:
- Concurrency:
_bt_moveright(:211) — a reader racing a page split recovers by walking right-links (Lehman & Yao); no lock coupling on the descent. The README’s L&Y section is the payoff read. - Suffix truncation & deduplication: internal keys are truncated
separators, duplicate leaf keys share a posting list
(
_bt_binsrch_posting:34) — nbtree has been absorbing compressed-postings ideas from the GIN/roaring world. - Write path: every insert dirties a leaf (WAL, FPIs, topic 3) — the write amplification that makes “just add another index” a real bet.
Q1. Our motivation table: BTreeMap miss = 218 ns in memory. A postgres btree probe on a cold cache is 3-4 page reads. Where does the learned index’s “the top of the tree is predictable” claim break for postgres? (Hint: pages move; TIDs aren’t positions in a sorted array; VACUUM.)
2. GIN — inverted index = topic 23 wearing a trench coat
GIN maps key → posting list of TIDs, exactly a search engine’s term →
docIDs. The compression is varbyte delta encoding:
ginCompressPostingList (ginpostinglist.c:196) packs TID deltas into ≤ 7
bytes each; ginPostingListDecode (:284 →
ginPostingListDecodeAllSegments :297) unpacks. Big lists graduate from
inline posting lists to a posting tree (a btree of TID segments), and
writes buffer in a pending list merged by (auto)vacuum — a mini-LSM
inside postgres, same write-absorption move as ALEX’s gaps and the LSM
memtable.
Q2. GIN’s varbyte deltas vs roaring’s containers
(reading-roaring-internals.md): varbyte
wins on tight clusters (deltas of 1 → 1 byte), roaring wins on random
access (galloping needs to seek; varbyte must decode linearly from a
segment boundary). Which does an && (array-overlap) query with two
selective keys want, and which does a full bitmap scan want?
3. BRIN — the zone map that admits it’s a filter
- BRIN stores per-block-range summaries: min/max per 128-page range
(
brininsertbrin.c:349 unions new values into the range’sBrinMemTuple - 157-170;
bringetbitmap:301 returns candidate page ranges, never rows). It is exactly topic 12’s zone map, and it is already probabilistic in the useful direction: one-sided — it can say “range definitely has no qualifying rows,” never “row definitely exists.”
The entire query-side logic fits in a filter:
#![allow(unused)]
fn main() {
fn bringetbitmap(ranges: &[MinMax], q: (Val, Val)) -> Vec<PageRange> {
ranges.iter().enumerate()
.filter(|(_, r)| r.min <= q.1 && q.0 <= r.max) // overlap ⇒ MAYBE
.map(|(i, _)| page_range(i)) // 128 heap pages each
.collect() // one-sided: prunes ranges, never confirms rows
}
}
answers "definitely not here" bits per key
bloom per KEY, any order ~10
BRIN/zone map per RANGE, needs clustering ~0.001 (128 pages/entry)
btree exact position ~50-100 (the whole tree)
BRIN is 10,000× smaller than bloom when the column is correlated with physical order (append-only timestamps) and useless when it isn’t (min/max of every range spans everything).
Q3. State the precise condition under which a BRIN index on column c prunes well, in terms of the overlap of per-range [min, max] intervals. Which of: insert timestamp, UUID v4, monotonically-allocated node ID, falls where?
Q4 (the M26 synthesis). The capstone milestone wants: range index under MVCC + LSM blooms + roaring label filters + HLL count-distinct. Map each onto the postgres AM it shadows (nbtree / none — postgres lacks LSM blooms / GIN / none — postgres computes count(DISTINCT) exactly). Which of the four does postgres’s absence hurt most for a graph workload, and why is that the one topic 4 already measured? (Point-miss cost × miss rate of MATCH lookups.)
4. The one-table summary
| AM | granularity | answer type | write cost | shadow in this topic |
|---|---|---|---|---|
| nbtree | row (TID) | exact | leaf dirty + WAL per insert | the 167/218 ns baseline lanes |
| GIN | key → TID set | exact set | pending-list amortized | roaring/postings (topic 23) |
| BRIN | 128-page range | one-sided maybe | update range summary | zone maps (topic 12), bloom’s cousin |
References
Code (postgres, src/backend/access/)
nbtree/README— genuinely one of the best docs in any codebase; read it fully (the Lehman & Yao section is the payoff)nbtree/nbtsearch.c— the descentgin/ginpostinglist.c+gin/README— varbyte posting listsbrin/brin.c+brin/README— block-range summaries
Roaring bitmaps: adaptive containers for integer sets
The workhorse of every “set of row/node IDs” problem: chop the u32
space into 64K chunks and store each chunk in whichever of three
encodings is smallest for its density. This chapter extends topic 23’s
guide (topics/23-search/reading-postings.md) and its postings.rs
stub — array/bitmap containers exist there already; here we add the
third container, the density algebra, and the SIMD story, following
the roaring-rs port.
1. Recap + the missing third container
A roaring bitmap chops the u32 space into 64K chunks by the high 16 bits; each chunk stores its low-16-bit members in whichever container is smallest:
| container | roaring-rs type | when | size |
|---|---|---|---|
| array | ArrayStore (sorted Vec<u16>) | card ≤ 4096 | 2 bytes/element |
| bitmap | BitmapStore (1024 × u64) | card > 4096 | 8 KB flat |
| run | IntervalStore (sorted (start, end) pairs) | few runs | 4 bytes/run |
Anchors: store/mod.rs:28-31 (enum Store { Array, Bitmap, Run }),
container.rs:9-11 (ARRAY_LIMIT = 4096, RUN_MAX_SIZE = 2048),
container.rs:70 (ensure_correct_store — every mutation may demote/promote).
The thresholds are pure arithmetic, not tuning:
- 4096 × 2 bytes = 8 KB = the bitmap’s fixed cost → array wins below, bitmap above.
- A run container beats the bitmap iff runs × 4 bytes < 8 KB →
RUN_MAX_SIZE = 2048.
Q1. Topic 20’s GraphBLAS switches sparse↔bitmap per matrix; roaring switches per 64K chunk. Same density crossover, different granularity. What workload makes per-chunk adaptivity decisively better? (Hint: a graph with one dense community and a long sparse tail of node IDs.)
2. The density algebra — ops pick kernels pairwise
Every binary op dispatches on the container pair — 3×3 kernels, each the
natural algorithm for that shape (store/mod.rs:207-224 shows the
is_disjoint/is_subset matrix; the BitAnd/BitOr impls follow the same
pattern):
∩ array ∩ bitmap ∩ run
array merge or GALLOP probe bits per elem probe intervals
bitmap (symmetric) 1024 x (a & b) mask interval spans
run (symmetric) (symmetric) interval intersection
The galloping case is the one topic 23 met as skip-lists/WAND: when
|A| ≪ |B|, walk A and exponentially search B — O(|A|·log|B|) beats the
linear merge. Same asymmetry-exploiting move as ALEX’s exponential search
(reading-learned-indexes.md) and topic 23’s
galloping in MAXSCORE.
#![allow(unused)]
fn main() {
fn intersect_gallop(small: &[u16], big: &[u16], out: &mut Vec<u16>) {
let mut lo = 0;
for &x in small { // |small| ≪ |big|
let mut step = 1; // gallop: 1, 2, 4, 8, ...
while lo + step < big.len() && big[lo + step] < x { step <<= 1; }
let hi = (lo + step + 1).min(big.len());
match big[lo..hi].binary_search(&x) { // then binary in the bracket
Ok(i) => { out.push(x); lo += i + 1; }
Err(i) => { lo += i; }
}
} // O(|small| · log|big|)
}
}
Q2. Union of two arrays can overflow ARRAY_LIMIT. container.rs:106
checks union_cardinality <= ARRAY_LIMIT before choosing the output
container. Why is computing the exact union cardinality first cheaper than
“build array, promote if too big”?
3. The SIMD story (paper §3, store/array_store/vector.rs)
array_store/ splits into scalar.rs and vector.rs — the same kernels
twice, and the module picks at compile time. The paper’s two famous kernels:
- Array ∩ array: compare a block of A against a block of B with a
shuffle network; SPE’18 §3.2’s
_mm_cmpistrm-style or the simpler broadcast-compare.vector.rsuses portablestd::simd— read its intersect and note the tail fallback to scalar. - Bitmap card: population count over 1024 words; the paper’s Harley-Seal
AVX2 popcount is why
intersection_len(array_store/mod.rs:258) style cardinality-only ops never materialize a result container.
Q3. Cardinality-only ops (intersection_len, is_disjoint) are the
hot path in query planning (estimate selectivity before executing —
topic 9). Why does roaring make these zero-allocation while full ops
allocate?
4. Run containers and sortedness
Run shines exactly when data arrives clustered: sequential IDs, time
ranges, “all rows in partition.” insert_range (store/mod.rs:107-109)
into a Run is O(runs); into a Bitmap it’s word-fill; into an Array it’s a
splice. This is why roaring formats have an explicit optimize()/run
conversion pass after bulk load rather than checking on every insert.
Q4 (cross-topic thread). Three adaptive encodings, one idea:
| roaring | redis HLL sparse | postgres GIN posting | |
|---|---|---|---|
| unit | 64K chunk | register stream | TID list segment |
| encodings | array/bitmap/run | ZERO/XZERO/VAL | varbyte deltas |
| promote when | card > 4096 | bytes > 3 KB or rank > 32 | page overflow → posting tree |
Fill in the demotion column yourself: which of the three ever converts back down, and why is demotion rarer than promotion everywhere?
5. Tie back to the stubs
Topic 23’s postings.rs stub already fixes array↔bitmap promotion at 4096.
After this guide: (a) add the galloping intersect to your mental model of
why FalkorDB label filters should be roaring, not Vec<u64>; (b) M26’s plan
(roaring for label/type filtering) inherits the run container for
“all nodes created in bulk-load order” — measure whether your ID allocator
produces runs.
References
Papers
- Lemire et al. — “Roaring Bitmaps: Implementation of an Optimized Software Library” (Software: Practice & Experience 2018, arXiv:1709.07821) — §2 containers, §3 SIMD kernels, skim benchmarks
Code
- roaring-rs
roaring/src/bitmap/— the Rust port;store/holds the three containers and the pairwise kernels
Topic 26 — Notes & measurements
Machine: Apple M3 Pro, macOS. cargo run --release --bin filter_bench
(10M sorted random u64 keys, 1M queries per lane). Date: 2026-07-10.
Measured baselines (provided lanes)
| lane | ns/lookup | note |
|---|---|---|
| binary search (miss) | 167 | ~23 dependent cache misses over 76 MB |
| BTreeMap (miss) | 218 | pointer-chasing tax over binary search |
| HashSet (miss) | 24 | the speed target — at 224 MB, the memory anti-target |
| binary search (hit) | 169 | hit ≈ miss: the cost is the walk, not the compare |
The whole topic in these four numbers: a filter should say “definitely absent” at ~HashSet speed with ~5% of its memory (12 MB at 10 bpk), and a learned index should collapse most of binary search’s 23-miss walk.
Predictions BEFORE implementing the stubs
| stub lane | prediction | reasoning |
|---|---|---|
| blocked bloom 10 bpk | ~15-25 ns, FPR 1.2-1.8% | 1 line fetch + 6 probes; theory 0.83% × 1.5-2 blocked tax |
| blocked bloom 8→16 bpk | FPR ~4× apart | each bit/key ≈ 2× FPR; test only demands halving |
| cuckoo 12-bit fp, 0.9 load | ~30-50 ns, FPR ~0.2% | 2 buckets = 2 possible misses; 8 slots × 2^−12 × load |
| hll 10M adds | err < 1.5% | σ = 0.81% at P=14; one seed, so anywhere within ~2σ |
| learned ε=64 | ~90-120 ns | segment descent cached + ~7-step window search, still 2-3 data misses |
| learned ε=256 | faster or slower than ε=64? | fewer segments (better cached) vs wider window — my bet: within 10% |
| learned segments, 1M uniform | ~500-1500 | cone ≥ optimal; optimal for uniform ≈ n/(ε²-ish scaling) is far under n/500 |
(Fill the measured column after implementing; keep wrong predictions — they’re the record.)
Measured (stub lanes) — TODO after implementation
| lane | measured | prediction hit? |
|---|---|---|
| blocked bloom 8/10/16 | — | — |
| cuckoo | — | — |
| hll | — | — |
| learned 16/64/256 | — | — |
Questions to answer while reading (from the guides)
- Bloom Q1: why optimal k ⇒ half the bits set?
- Bloom Q4: build-can-fail (ribbon/cuckoo) vs monotone (bloom) — cost where?
- Cuckoo Q1: why
i1 XOR hash(fp)and noti1 XOR fp? - Cuckoo Q2: how does deleting a never-inserted key corrupt the filter?
- HLL Q2: show sigma() term ⇒ linear counting for n ≪ m.
- HLL Q4: ZERO/XZERO/VAL vs roaring containers — the density metric each switches on.
- Learned Q1: 4 points where the cone splits but optimal PLA doesn’t.
- Learned Q4: ALEX under adversarial (clustered) inserts — predict, then paper §5.5.
- Roaring Q1: workload where per-chunk adaptivity beats per-matrix (topic 20).
- Postgres Q3: BRIN pruning condition; place timestamp / UUIDv4 / monotone ID.
Cross-topic threads
- Topic 4 (LSM): blooms exist because a point-miss touches every level.
RocksDB’s ribbon-below/bloom-above split (
bloom_before_level) is a space-vs-CPU knob per level — cold levels are big (space matters) and rarely probed (CPU doesn’t). - Topic 12 / BRIN: zone maps are one-sided filters at range granularity; bloom is the same one-sidedness at key granularity, ~10,000× the bits.
- Topic 20 / roaring: array↔bitmap density crossover measured a third time (GraphBLAS per-matrix, roaring per-chunk, HLL sparse per-stream).
- Topic 23: postings stub already holds the array/bitmap containers; galloping intersect = MAXSCORE’s skip = ALEX’s exponential search — one primitive, three topics.
- Topic 9: HLL’s exact-merge semilattice is what lets approximate count(DISTINCT) push below shuffles/shards.
Capstone M-log (M26, per PLAN)
Target: secondary range indexes under MVCC + bloom filters in the LSM
backend + roaring bitmaps for label/type filtering + HLL fast path for
approximate count(DISTINCT).
- Blocked bloom (this topic’s stub, made SIMD later) per SST; 10 bpk to start, verify the ~1% FPR × per-level miss cost against topic 4’s measured point-miss lane before spending 16 bpk.
- Label filter = one roaring bitmap per label over node IDs. Bulk loader allocates IDs monotonically ⇒ expect run containers; measure runs/label after load (reading-roaring-internals Q on ID allocator).
- HLL per (label, property) maintained on the write path — O(1) per insert (one register max), merged across shards at query time. Exact-merge register equality is the test.
- Learned index: NOT in M26. The ε window is elegant but our keys (node IDs) are already dense integers — a plain array is the perfect model. Revisit if/when property range indexes over timestamps show smooth CDFs.
Infra notes
- Stub lanes wrapped in
catch_unwindso filter_bench degrades to[stub — implement …]lines until each structure lands. - Tests: 2 provided pass (hash avalanche/fastrange), 15 fail as
todo!()panics — the contract to implement against. - HLL stub spends a byte per register (16 KB vs redis’s 12 KB packed) to
skip the 6-bit shift dance; the estimator recipe in
hll.rsdoc comments is transcribed line-by-line from redishllCount/hllSigma/hllTau.
Done when
- All 17 tests green (
cargo test --release). - filter_bench stub lanes measured; predictions table graded honestly.
- Blocked bloom FPR ratio vs theory explained via Poisson crowding
(compare against
CacheLocalFpRate’s expectation). - Can derive bloom FPR + optimal k on paper, and state cuckoo’s partial-key involution from memory.
- One paragraph: which of bloom/cuckoo/xor/ribbon for (a) memtable, (b) immutable SST, (c) routing table with churn — with the space and build-failure trade for each.
Topic 27 — Streaming & Incremental View Maintenance
Why this matters: recomputing from scratch is the enemy. A standing query over a changing graph should cost per-change, not per-database. Differential dataflow and DBSP made that rigorous — and FalkorDB’s delta matrices (topic 20) are already halfway there conceptually: DP/DM are positive and negative Z-sets waiting for an algebra.
Our motivation numbers first (Apple M3 Pro, 50K nodes / 500K edges, batches of 100 changes, 2026-07-10)
| standing query | full recompute / batch | incremental target |
|---|---|---|
| triangle count | 97.2 ms | ~µs (stub) — batch·d̄ probes, not m·d̄ |
| 2-hop wedge join | 894.3 ms | ~µs-ms (stub) — bilinear delta rule |
| reachability from src | 24.7 ms (re-BFS) | semi-naive: each edge relaxed O(1) times ever |
The gap is 3-5 orders of magnitude, and none of it requires cleverness — just refusing to touch data that didn’t change.
The one algebraic idea
Changes are Z-sets: collections with i64 weights (+1 insert, −1 delete). Operators split into two classes:
LINEAR (stateless to incrementalize) NONLINEAR (need state)
map, filter, flat_map, union distinct, count, sum, top-k,
op(ΔA) = Δop(A) — deltas stream through min/max — deleting the last
copy must RETRACT the output,
BILINEAR (need arranged inputs) which requires knowing how
join: Δ(A⋈B) = ΔA⋈B + A⋈ΔB + ΔA⋈ΔB many copies existed: state
That table is the topic. DBSP’s contribution: any query built from
these pieces auto-incrementalizes by circuit rewriting; the state each
nonlinear operator needs is exactly an integral (z^-1 feedback) of its
input. Differential’s contribution: it also works inside recursion, with
deltas indexed by (iteration, input-version) lattice timestamps.
flowchart LR
subgraph "DBSP incrementalization"
dI["ΔIN (z-set/tick)"] --> I1["I (integrate)"] --> Q["Q (the query)"] --> D1["D (differentiate)"] --> dO["ΔOUT"]
end
The chain I→Q→D is the specification; the engineering is pushing I and D through Q’s structure until only nonlinear operators keep integrals — those integrals are Materialize’s arrangements (shared, indexed, compacted update logs).
Timestamps, watermarks, and why “when” is half the problem
Timely’s insight (Naiad): every message carries a logical timestamp; the
scheduler broadcasts progress (“no more messages ≤ t will ever arrive” —
a frontier/watermark, MutableAntichain timely frontier.rs:380). Only
when the frontier passes t may a nonlinear operator emit finalized output
for t. That single mechanism subsumes: batch boundaries, out-of-order
data, iteration rounds (timestamps extend to (epoch, round) pairs), and
exactly-once output (emit per closed timestamp).
RisingWave makes the same call with different machinery: barriers flow
through the dataflow (Chandy-Lamport style), every operator checkpoints
its state to S3 at barrier alignment — its Op enum
(stream_chunk.rs:45: Insert/Delete/UpdateDelete/UpdateInsert) is a Z-set
weight wearing protocol clothing.
The systems, placed
| timely/differential | DBSP/Feldera | Materialize | RisingWave | |
|---|---|---|---|---|
| theory | lattice timestamps | abelian-group circuits | differential underneath | ad-hoc deltas + barriers |
| recursion | full (Naiad loops) | nested circuits | WITH RECURSIVE (limited) | no |
| state | arrangements in RAM | batch/trace spine, spillable | arrangements + persist (S3 log) | Hummock LSM on S3 |
| consistency | multi-versioned by design | per-tick | strict serializable reads | barrier-aligned snapshots |
The stubs (experiments/)
| stub | contract |
|---|---|
djoin::delta_join + IncrementalJoin | equals join(A+ΔA, B+ΔB) − join(A,B) exactly, deletes retract output rows, 30-batch drift-free |
tri::IncrementalTriangles | tracks the full-recompute oracle under insert+delete churn; K4-minus-an-edge = −2; batch of 20 costs < 4K probes on a 40K-edge graph |
reach::SemiNaiveReach | matches re-BFS after every batch; ≤ 4 relaxations/edge across ALL batches; intra-component edges cost 0 |
Provided: zset.rs (consolidation, merge, the distinct-is-not-linear
test), graph.rs (churn generator + all three full-recompute oracles),
ivm_bench (prices the enemy even before the stubs exist).
Deliberate scope cut: SemiNaiveReach is insert-only. Deleting an edge
from a reachability result is the problem that needs differential’s
timestamp machinery — see reading-differential-dataflow.md §4 for why.
Reading guides
- reading-naiad-timely.md — Naiad: the clock that unified batch, streaming, and iteration
- reading-differential-dataflow.md — Differential dataflow: retractions that survive recursion
- reading-dbsp.md — DBSP: incremental view maintenance as a calculus
- reading-materialize-risingwave.md — Materialize vs RisingWave: two production IVM bets
- reading-kafka-log.md — Kafka: the log is the database
Further references: “MillWheel” (VLDB 2013) — where watermarks (low watermarks over event time) entered production streaming; the heuristic ancestor of timely’s proof-carrying frontiers, and the lineage behind Google Dataflow/Beam and Flink’s model.
Cross-topic links
- Topic 20: FalkorDB delta matrices — DP=+Δ, DM=−Δ,
wait= integrate; what’s missing vs DBSP is pushing queries through the deltas instead of forcing a merge first. That gap is exactly M27. - Topic 4: an arrangement’s batch/spine/compaction IS an LSM over update triples — merging batches consolidates weights like compaction drops tombstones.
- Topic 8: retractions are the MVCC intuition inverted — instead of versions hiding rows from the past, negative weights erase rows from derived futures.
- Topic 24: semi-naive frontier = delta-stepping’s bucket discipline; both refuse to re-derive settled facts.
- Topic 5/15: Kafka = the WAL promoted to the database; Materialize’s persist and RisingWave’s Hummock both re-derive state from a shared log.
DBSP: incremental view maintenance as a calculus
The VLDB ’23 best paper reduces incremental view maintenance to an algebra of four stream operators and two identities, so that incrementalizing ANY query becomes a mechanical rewrite. This chapter works through the algebra, then anchors it in Feldera’s production Rust implementation, where every operator of the calculus is a file.
1. DBSP’s move: make IVM a calculus, not a system
Differential is a brilliant system; DBSP is the theory that explains it with four operators. Streams are functions ℕ→group; circuits are built from:
z^-1 delay (one-tick memory) operator/z1.rs:221 Z1
I integrate: running sum operator/integrate.rs:85
D differentiate: x[t] - x[t-1] operator/differentiate.rs:38
Q any query, lifted pointwise
with the two identities D(I(x)) = x and I(D(x)) = x. The incremental
version of any query is defined as Q^Δ = D ∘ Q ∘ I — and then a
rewrite system pushes I/D inward:
linear Q: Q^Δ = Q (deltas stream through)
bilinear join: (A⋈B)^Δ = ΔA⋈I(B) + I(A)⋈ΔB + ΔA⋈ΔB
^ the z^-1-delayed integrals = arrangements
chain rule: (Q1∘Q2)^Δ = Q1^Δ ∘ Q2^Δ (incrementalize COMPOSITIONALLY)
The chain rule is the paper’s practical bombshell: you incrementalize operator-by-operator, so a whole SQL dialect (joins, aggregates, window functions, recursion) is covered by giving each primitive its ^Δ form once. That’s Feldera’s SQL-to-circuit compiler.
The bilinear join’s ^Δ form as an operator — note the state is
exactly two integrals, one of them delayed (z^-1):
#![allow(unused)]
fn main() {
struct IncJoin { ia: ZSet, ib_delayed: ZSet } // I(A), z^-1(I(B))
fn step(&mut self, da: &ZSet, db: &ZSet) -> ZSet {
// (A⋈B)^Δ = ΔA ⋈ z^-1(I(B)) + I(A) ⋈ ΔB
self.ia.merge(da); // integrate A first...
let out = join(da, &self.ib_delayed) // ...ΔA sees B BEFORE this tick
.plus(&join(&self.ia, db)); // ΔB sees A including ΔA:
self.ib_delayed.merge(db); // the ΔA⋈ΔB term, absorbed
out // = the view delta, exactly
}
}
Q1. Prove the bilinear rule from Q^Δ = D∘Q∘I by expanding I(a)[t]·I(b)[t] − I(a)[t−1]·I(b)[t−1]. Note where z^-1 appears — that’s why the code’s join keeps delayed traces.
Q2. Z-sets with i64 weights form an abelian group; sets don’t
(no negatives). Where exactly does the theory need inverses? What happens
to distinct — and why does the paper single it out as the operator that
breaks linearity (compare our zset.rs distinct_is_not_linear test)?
2. Feldera code anchors
| anchor | what it is |
|---|---|
algebra/zset/ | the ZSet/IndexedZSet traits — weighted collections as a trait hierarchy over “batch” storage |
operator/z1.rs:221 | Z1 — the delay; DelayedFeedback :37 is how cycles (recursion) are wired |
operator/integrate.rs:85 | integrate — the running trace; integrate_nested :158 for inner circuit clocks |
operator/differentiate.rs:38 | D; note differentiate_with_initial_value :105 for bootstrapping from a snapshot |
operator/join.rs:123/:283/:350 | join, stream_join_generic, join_generic — the ^Δ forms specialized |
operator/distinct.rs, aggregate.rs | the nonlinear ops, each carrying its integral |
operator/delta0.rs | injects an outer-clock stream into a nested circuit — the paper’s δ₀ |
Nested circuits are DBSP’s recursion answer: an inner circuit with its own clock runs to fixpoint per outer tick — same expressive result as differential’s lattice times, but staged (outer tick, then inner fixpoint) rather than a general product order.
Q3. Differential timestamps: arbitrary lattice, updates at mixed times consolidate freely. DBSP: strict tick-by-tick semantics, recursion via nesting. What does DBSP give up (hint: out-of-order input within a tick; multi-epoch overlap of iterations) and what does it gain (engineering simplicity, per-tick transactional semantics — Feldera’s “synchronous circuit” story)?
3. The database claims
- Per-tick transactions: each input Z-set batch = one transaction; outputs are exactly the view deltas for that transaction. This is the contract M27’s standing Cypher queries want: mutation batch in, result delta out, push to subscribers.
- State = integrals: every stateful operator’s memory is I(something),
spillable to storage (feldera’s
storage/crate) — checkpointing is checkpointing integrals, nothing else (z1.rs’sCommittedZ1:241). - The FalkorDB mapping (M27): delta matrix DP−DM is ΔA for one tick;
wait= I. A standing pattern query is Q; what M27 must build is Q^Δ — masked SpGEMM terms ΔA·A + A·ΔA + ΔA·ΔA instead of recomputing A² (our tri.rs stub is exactly this with scalar sets).
Q4. Take MATCH (a)-[]->(b)-[]->(c) RETURN count(*) — the wedge
count in ivm_bench. Write its DBSP circuit (two-input bilinear join +
linear count), mark which arrows carry deltas and which carry integrals,
and identify what FalkorDB already stores (A, ΔA as delta matrices) vs
what M27 must add (the arranged join state — nothing! wedges need only A
itself: the integrals ARE the adjacency matrices).
References
Papers
- Budiu, Chajed, McSherry, Ryzhyk, Tannen — “DBSP: Automatic Incremental View Maintenance for Rich Query Languages” (VLDB 2023, arXiv:2203.16684) — read §1-4 (the algebra), §5 (recursion) if the differential guide left questions
Code
- feldera
crates/dbsp/src/— the production implementation;algebra/zset/,operator/z1.rs,operator/integrate.rs,operator/differentiate.rs,operator/join.rs,operator/delta0.rsper the anchor table
Differential dataflow: retractions that survive recursion
Differential dataflow is the system that made incremental computation work inside iteration: deltas carry lattice timestamps, so deleting an input edge correctly retracts everything derived through it, round by round. This chapter reads the short CIDR ’13 paper alongside the modern Rust code — arrangements, join_traces, iterate — which our topic-27 stubs are simplified excerpts of.
1. The delta discipline
A differential Collection is a stream of (data, time, diff) updates —
our Z-set entries with a timestamp attached. Every operator consumes and
produces updates only; the “current collection” never materializes
except inside arrangements. Consolidation
(consolidation.rs:24 consolidate, :88 consolidate_updates) is our
ZSet::from_updates verbatim: sort, sum diffs, drop zeros.
2. Arrangements — the indexed update log
arrange (operators/arrange/arrangement.rs:311, core at :336) turns an
update stream into an Arranged (:45): a trace = LSM-of-batches of
(key, val, time, diff), shared by reference among all operators that need
that index. This is the topic-4 rhyme made literal:
batch = immutable sorted run of updates (an SST)
spine = the merging hierarchy of batches (leveled compaction)
advance = "no reader needs times < f anymore":
times collapse, diffs consolidate (tombstone GC below
— the WEIGHT-level merge the horizon)
Q1. Two queries join against the same collection on the same key. In postgres you’d build one index used by two plans. What is the differential equivalent, and why does Materialize describe arrangement sharing as its main memory optimization?
3. join_traces — the bilinear rule with fuel
join_traces (operators/join.rs:69): each input is arranged; when a new
batch of A arrives, join it against B’s trace (all of B’s history up to
the frontier), and vice versa — exactly our stub’s ΔA⋈B + A⋈ΔB + ΔA⋈ΔB,
with the cross term handled by batch/trace ordering. The Deferred state
(:311) and the work/fuel loop (:348, effort accounting :355-395) are
the production detail our stub skips: a huge delta must not stall the
worker, so join work is metered and yields — cooperative scheduling at
the operator level (topic 7’s lesson, again).
Q2. Our IncrementalJoin::step integrates deltas into state after
emitting. join_traces must pick an order too: a batch of A joins B’s
trace as of which frontier? Work out why getting this wrong
double-counts the ΔA⋈ΔB term.
4. Iteration — where differential earns its name
iterate (operators/iterate.rs:192 Variable, set :262) runs a loop
body inside a nested scope; updates carry (outer, round) timestamps. The
magic our insert-only reach.rs cannot do: when an input edge is deleted,
differential re-derives only the (round, edge)-dependent updates, because
each derived fact is stored with the full lattice time at which it held.
Deletion of an edge retracts facts derived through it at round r, which
may re-derive at round r+2 via another path — all handled by the same
consolidation arithmetic, no support counting, no over-deletion bug.
examples/bfs.rs:101-107 is the whole algorithm:
#![allow(unused)]
fn main() {
nodes.iterate(|inner| {
inner.join_map(&edges, |_k, l, d| (*d, l + 1)) // relax
.concat(&nodes) // keep roots
.reduce(...min...) // keep shortest
})
}
Q3. Semi-naive evaluation falls out: at round r+1, the join only sees diffs at round r. Verify against our reach.rs relaxation counter: what does differential’s per-round diff discipline guarantee that our “BFS from new frontier” hand-rolls?
Q4 (the hard one). Why does incremental recursion need the lattice (product partial order) rather than a total order? Construct the case: input change at epoch 2 while iteration from epoch 1 is still running — which updates must NOT be merged?
5. Tie back to the stubs
Our three stubs are differential with the general machinery deleted:
delta_join = join_traces without times/fuel; IncrementalTriangles =
a 3-way delta join specialized by hand; SemiNaiveReach = iterate for
monotone inserts only. The point of reading the real thing is to see
what the generality costs (arrangements, lattice times, compaction) and
what it buys (retractions inside recursion — the thing none of our stubs
can do).
References
Papers
- McSherry, Murray, Isaacs, Isard — “Differential Dataflow” (CIDR 2013) — short; read all of it, twice
Code
- differential-dataflow
differential-dataflow/src/—consolidation.rs,operators/arrange/arrangement.rs,operators/join.rs,operators/iterate.rs; plusexamples/bfs.rs— 40 lines that do what our reach.rs stub cannot
Kafka: the log is the database
Before any view can be maintained incrementally, the changes have to live somewhere with the right guarantees — and Kafka is the industry’s answer. This chapter reads the 2011 paper’s design bets (all still load-bearing) and Kreps’ “the log is the database” ideology as the substrate every IVM system in this topic tails.
1. Why this paper is in the IVM topic
Every system in this topic consumes changelogs and maintains derived state. Kafka is the answer to “where does the changelog live, and what guarantees does it have?” The thesis (from the blog, distilling the paper): the log is the database; tables are caches of log prefixes. Which is topic 5’s WAL rule promoted from implementation detail to system architecture — postgres logical replication, Debezium CDC, Materialize sources, RisingWave sources: all tail someone’s WAL through exactly this abstraction.
2. The paper’s actual design bets (2011, all still load-bearing)
topic ─ partition 0: [ append-only segment files ] ← offset = position
─ partition 1: [ ... ] (no per-message id,
no broker index!)
- Dumb broker, smart consumer: the broker keeps NO per-consumer state; a consumer is (partition, offset). Rewind = replay = free. Contrast every prior MQ where acking mutated broker state per message.
- Sequential I/O + page cache + sendfile: no in-process message cache; zero-copy from segment file to socket. Topic 6’s lesson (don’t fight the OS cache) chosen deliberately.
- Offsets as consumer-owned watermarks: delivery semantics degrade to “where do you store your offset, and is that store transactional with your output?” — which is the whole exactly-once question.
- Ordering per partition only: total order costs coordination (topic 15); per-key order is what state maintenance actually needs (updates to the same key must not reorder — Z-set merges for DIFFERENT keys commute anyway).
Q1. Consumer-side offset + idempotent/transactional sink = the only real “exactly-once.” Map RisingWave’s barrier checkpoint (offsets stored IN the same checkpoint as operator state) onto this recipe. What plays the role of the transactional sink?
Q2. Log compaction (retain latest record per key) turns a topic into
a table changelog that new consumers can bootstrap from. Compare to an
arrangement’s advance/consolidation (differential guide §2) and an LSM’s
tombstone GC (topic 4): same operation, three communities. What must a
compacted topic keep that an LSM needn’t? (Hint: deletes need tombstones
readable by late-joining consumers for a grace period.)
3. The rosetta table
| Kafka | database internals |
|---|---|
| partition | WAL shard / redo stream |
| offset | LSN |
| consumer group rebalance | replica assignment (topic 15) |
| log compaction | checkpoint + WAL truncation, per key |
| retention window | how far behind a replica may fall before full resync (PSYNC backlog, topic 15) |
| topic with schema registry | the WAL made a public, typed API |
Q3. “Turning the database inside out”: instead of app → DB → CDC → caches, write to the log first and derive EVERYTHING (DB included). What classical guarantee gets harder in the inside-out design? (Read-your-writes: the deriving views lag the log.) Which system in reading-materialize-risingwave.md solves that with timestamps, and how?
Q4 (M27). FalkorDB already has the log (Redis replication / AOF, topic 5’s guide). A standing-query subscriber is a consumer of view deltas. Decide: do subscribers get (a) the raw mutation log (Kafka style — they rebuild), or (b) per-query result deltas (Materialize SUBSCRIBE style)? What does (b) require the server to persist if a subscriber disconnects for an hour — and where’s the retention-window trade from §2 hiding in your answer?
References
Papers
- Kreps, Narkhede, Rao — “Kafka: a Distributed Messaging System for Log Processing” (NetDB 2011) — 7 pages, read whole
- Kreps — “The Log: What every software engineer should know about real-time data’s unifying abstraction” (2013 blog) — the ideology; read after the paper
Materialize vs RisingWave: two production IVM bets
Both systems sell “materialized views that stay fresh,” built on opposite bets: Materialize productionizes differential dataflow (one delta algebra, arrangements in RAM), RisingWave hand-writes incremental executors with explicit state in an LSM on S3. Reading them side by side shows which parts of IVM are theory and which are operations — and which parts a single-writer graph engine gets for free.
1. Materialize: differential dataflow, productionized
The compute layer (src/compute/src/render/) compiles SQL plans into
differential dataflows. The parts worth reading:
| anchor | what it is |
|---|---|
render/join/delta_join.rs:47 | “dogs^3” delta-query joins: an n-way join becomes n dataflows, each starting from one input’s changes — the bilinear rule generalized so NO intermediate arrangements are built |
delta_join.rs:315/:402 | half_join construction (and the newer half_join2): ΔA against B’s arrangement, time-stamped so the n paths don’t double-count — our stub’s “state BEFORE the delta” rule, industrial edition |
render/reduce.rs | the nonlinear ops, each with its arrangement |
src/compute/src/arrangement/ | arrangement sharing across dataflows — one index, many standing queries |
src/persist-client/ | the durable shard log: compute is stateless-ish; state rehydrates from persist (topic 28’s disaggregation, applied to IVM) |
The signature idea: indexes are arrangements are memory. A Materialize “index” is an arrangement pinned in RAM shared by every query that can use it; capacity planning is arrangement accounting.
Q1. Delta joins need an arrangement per input per join key but no intermediate state. Linear (binary-tree) joins need intermediate arrangements but fewer per-input ones. Materialize chooses delta joins when the arrangements already exist. Map this onto topic 10’s join-ordering cost model: what’s the analogue of “interesting orders”?
2. RisingWave: streaming executors + LSM state on S3
No differential core — hand-written incremental executors
(src/stream/src/executor/), each managing explicit state in Hummock
(a shared LSM over object storage):
| anchor | what it is |
|---|---|
common/src/array/stream_chunk.rs:45 | enum Op { Insert, Delete, UpdateDelete, UpdateInsert } — Z-set weights as a protocol; Update split into paired Delete+Insert so downstream operators never need “modify” |
stream/src/executor/hash_join.rs:158 | HashJoinExecutor: both sides’ rows in state tables; need_degree_table :117 + degree tables :269 track match counts so outer joins can retract NULLs correctly — hand-rolled weight bookkeeping |
executor/barrier_align.rs | two-input operators align on barriers before emitting — the consistency unit |
executor/aggregate/, top_k/ | each nonlinear op = explicit state table schema in Hummock |
Barriers (Chandy-Lamport) flow from sources; when an operator has a barrier from all inputs it flushes state to Hummock — a globally consistent checkpoint per epoch. Recovery = reload from S3 + replay the log since the checkpoint. Compare topic 15’s replication story: the checkpoint interval IS the replay window.
Q2. RisingWave’s degree table vs differential’s diff arithmetic: both solve “when the last matching row leaves, retract the outer-join NULL row.” One is a schema and code per operator; the other is one consolidation rule for all operators. What does RisingWave get in exchange? (Hint: per-operator state schemas are legible to S3 spill, per-key TTL, and elastic scaling of a SINGLE operator.)
3. The comparison that matters for M27
| axis | Materialize | RisingWave | M27 (FalkorDB standing queries) |
|---|---|---|---|
| delta algebra | diffs everywhere (differential) | Op enum per chunk | delta matrices (DP/DM) |
| join state | shared arrangements, RAM | per-join Hummock tables | the graph matrices themselves |
| consistency unit | timestamp + frontier | barrier/epoch | writer tick (single writer!) |
| recovery | rehydrate from persist | checkpoint + replay | topic 5’s WAL replay |
The single-writer graph engine gets the hard parts free: no barrier alignment (one clock), no distributed frontier (one writer). What M27 inherits from this guide is the shape: standing query = compiled circuit + explicit per-operator state + delta in/delta out per tick.
Q3. Both systems separate compute from durable state (persist / S3). For M27 inside FalkorDB, state lives in the same process as the graph. Name one thing that gets easier (no rehydration protocol) and one that gets harder (memory pressure from arrangements competes with the graph itself — who evicts?).
References
Code
- materialize
src/— compute (differential):src/compute/src/render/join/delta_join.rs,render/reduce.rs,src/compute/src/arrangement/; persist (durable log):src/persist-client/; plus the in-repo architecture docs (doc/developer/— skim “formalism” and “platform”) - risingwave
src/— stream executors:src/stream/src/executor/(hash_join.rs, barrier_align.rs, aggregate/, top_k/); the Op enum:common/src/array/stream_chunk.rs:45; Hummock state store
Naiad: the clock that unified batch, streaming, and iteration
Naiad’s timely dataflow is one low-level model that expresses batch, streaming, AND incremental iterative computation — and the only new mechanism it needs is a smarter clock. This chapter reads the SOSP ’13 paper’s progress-tracking protocol, then its Rust reincarnation (timely-dataflow, by the same author), which is the substrate differential dataflow builds on.
1. What problem Naiad actually solved
2013’s landscape: batch systems (MapReduce/Spark) could iterate but not stream; streaming systems (Storm) could stream but not iterate; nothing could do incremental iterative computation. Naiad’s claim: ONE low-level model — timely dataflow — expresses all three, and the only new mechanism needed is a smarter clock.
timestamp in Naiad: (epoch, loop1_counter, loop2_counter, ...)
^ input batch ^ iteration rounds, one per nested loop
partial order: pointwise ≤ — this lattice is what "differential" will
exploit for incremental iteration
2. The core protocol: could-result-in
An operator may only finalize output for time t when the system proves no message with timestamp ≤ t can ever arrive. Naiad §3.2: track, per (location, timestamp), counts of outstanding pointstamps; a pointstamp is in the frontier when no other could-result-in it. Every produced or consumed message decrements/increments counts — progress is just a distributed refcount over the lattice.
Q1. Why must loop ingress/egress/feedback nodes edit the timestamp (push a counter, pop it, increment it)? Show that without the feedback increment, could-result-in has a cycle and no frontier ever advances.
The frontier advance, mechanically — progress is count arithmetic:
#![allow(unused)]
fn main() {
fn apply(counts: &mut BTreeMap<Time, i64>, changes: &[(Time, i64)])
-> Vec<Time> { // returns times the frontier passed
let before = frontier(counts); // minimal times with count > 0
for &(t, delta) in changes { // produced: +1, consumed: -1 —
*counts.entry(t).or_insert(0) += delta; // may dip negative, sums safe
if counts[&t] == 0 { counts.remove(&t); }
}
let after = frontier(counts);
before.into_iter() // t left the frontier ⇒ PROVEN:
.filter(|t| after.iter().all(|f| !(f <= t))) // nothing ≤ t can
.collect() // ever arrive — finalize t
}
}
3. timely code anchors
| anchor | what it is |
|---|---|
progress/change_batch.rs:16 ChangeBatch | the (time, ±count) buffer — progress updates are themselves Z-set-shaped |
progress/frontier.rs:380 MutableAntichain | the frontier: minimal elements of outstanding times; update_iter :533 applies count changes and reports which minimal times appeared/vanished |
progress/reachability.rs | the static could-result-in analysis over the dataflow graph |
progress/subgraph.rs | scopes: nested dataflow whose inner timestamp adds a coordinate |
worker.rs:235 step | the whole runtime: drain channels, schedule operators, exchange progress — cooperative, no threads-per-operator |
Note what is NOT here: no state management, no retractions, no windows. Timely only moves data and proves frontiers. Everything database-shaped lives a layer up in differential.
Q2. MutableAntichain keeps counts per time and exposes only the
antichain of minimal ones. Why antichain and not the full set? (What
query do operators actually ask — and how does this echo topic 8’s
“oldest active txn” watermark for vacuum?)
Q3. Progress messages are counts that may go negative transiently (consume before the produce is heard). Why is the protocol still safe — what invariant over SUMS does Naiad §4.1 prove? (Same shape as escrow / commutative-counter arguments in topic 29’s world.)
4. The database rosetta
| timely concept | database concept |
|---|---|
| timestamp/epoch | transaction id / batch boundary |
| frontier passes t | watermark: txn t’s snapshot is complete |
| could-result-in | dependency tracking for safe truncation |
| loop counter coordinate | recursive CTE iteration depth |
step() cooperative scheduling | topic 7’s event loop, one layer up |
Q4. Kafka Streams / Flink watermarks are heuristic (“probably no events older than t-5s”); timely frontiers are proofs. What does each buy? Where does FalkorDB’s single-writer serialization make the proof trivial? (That’s why M27 can skip most of §4.)
References
Papers
- Murray, McSherry, Isaacs, Isard, Barham, Abadi — “Naiad: A Timely Dataflow System” (SOSP 2013) — read §1-3 fully (the model), §4 (distributed progress) carefully, skim eval
Code
- timely-dataflow
timely/src/—progress/change_batch.rs,progress/frontier.rs(:380MutableAntichain),progress/reachability.rs,progress/subgraph.rs,worker.rs(:235step)
Topic 27 — Notes & measurements
Machine: Apple M3 Pro, macOS. cargo run --release --bin ivm_bench
(50K nodes / 500K edges; 10 churn batches of +90/−10 edges; reach lane:
50 insert-only chunks of 10K edges). Date: 2026-07-10.
Measured baselines (provided full-recompute lanes — the enemy, priced)
| standing query | full recompute / batch | notes |
|---|---|---|
| triangle count | 97.2 ms | O(m·d̄) sorted-intersect sweep; count 1366 after last batch |
| 2-hop wedge join | 894.3 ms | full self-join, 21,063,114 wedge weight — rebuilt per batch |
| reachability (re-BFS) | 24.7 ms | Σ over batches ≈ 1.2 s for what should cost O(m) total |
Predictions BEFORE implementing the stubs
| stub lane | prediction | reasoning |
|---|---|---|
| incremental triangles | 5-30 µs/batch, ~3000-10000× | 100 changes × d̄=20 probes × ~15 ns/BTreeSet probe |
| incremental wedge join | 1-5 ms/batch, ~200-800× | delta keyed both directions = 200 rows joined vs 1M-row state via hash index… but our ZSet state is a sorted Vec — merge cost O(state) per step may dominate; watch integrate cost, not join cost |
| semi-naive reach | ~500 µs/batch early, ~ns late | each edge relaxed ≤ 2× ever; late batches mostly intra-component = free |
| relaxations | ≤ 2m ≈ 1M | frontier discipline; test bound is 4m |
Honest flag on the wedge lane: IncrementalJoin integrates by
ZSet::merge = full re-sort of a 1M-entry state per batch. If measured
speedup disappoints, the fix is an indexed/spine state (an arrangement!)
— which would be the lesson demonstrating itself: deltas are cheap,
state maintenance is where arrangements earn their keep.
Measured (stub lanes) — TODO after implementation
| lane | measured | prediction hit? |
|---|---|---|
| incremental triangles | — | — |
| incremental wedge join | — | — |
| semi-naive reach | — | — |
Questions to answer while reading (from the guides)
- Naiad Q1: why must feedback nodes increment the loop counter?
- Naiad Q3: progress counts transiently negative — why safe?
- DD Q2: which frontier does a ΔA batch join B’s trace at, and how does the wrong answer double-count ΔA⋈ΔB?
- DD Q4: build the case where incremental recursion needs the lattice, not a total order.
- DBSP Q1: derive the bilinear rule from Q^Δ = D∘Q∘I.
- DBSP Q2: where exactly does the theory need negative weights; why is distinct the troublemaker?
- Mz/RW Q2: degree tables vs diff arithmetic — what does hand-rolled state buy RisingWave?
- Kafka Q2: what must a compacted topic keep that an LSM needn’t?
- Kafka Q4: raw-log vs result-delta subscriptions for M27; the retention trade.
Cross-topic threads
- Topic 20: DP/DM delta matrices are ±Z-sets;
wait= integrate. The M27 gap is pushing Q through the deltas (Q^Δ) instead of integrating first. tri.rs is the scalar rehearsal of ΔA·A + A·ΔA + ΔA·ΔA. - Topic 4: arrangement spine = LSM;
advance= compaction horizon; consolidation = tombstone drop. Same structure, third appearance (LSM, GIN pending list, arrangements). - Topic 24: semi-naive frontier = “never re-derive settled facts” = delta-stepping’s settled buckets.
- Topic 7: differential’s join fuel (join.rs:348-395) = cooperative yielding inside an operator — the event-loop lesson at a new layer.
- Topic 15/5: Kafka offset = LSN; consumer group = replica set; log compaction = per-key checkpoint+truncate.
Capstone M-log (M27, per PLAN)
Target: standing Cypher queries — register a query, keep its result incrementally maintained under graph mutations via delta matrices, push changes to subscribers.
- Scope v1 to the auto-incrementalizable fragment: linear ops (filters,
projections) + bilinear joins (pattern edges) + count/sum aggregates.
distinct-shaped and top-k queries need per-operator state — defer. - The circuit compiler is topic 10’s planner with a new backend: plan → per-tick delta program of masked SpGEMM terms. Wedges: Δ(A²) = ΔA·A + A·ΔA + ΔA·ΔA where A is post-previous-tick state (order per DD Q2).
- Tick = writer batch (single writer ⇒ no barriers, no frontier protocol — the parts of Naiad we get to skip, per reading-naiad-timely Q4).
- Subscriber protocol: result deltas (Materialize SUBSCRIBE shape) with a bounded replay buffer; disconnect > buffer ⇒ full re-materialize (Kafka Q4’s retention trade, decided).
- Deletions in recursive/variable-length patterns: NOT in v1 — that’s differential’s lattice territory; document the cliff explicitly.
Infra notes
- Provided lanes always print: bench survives stubs via catch_unwind.
- 6 provided tests pass (zset consolidation/nonlinearity, churn set semantics, K4 oracle, BFS oracle); 9 stub tests fail as todo!() panics.
distinct_is_not_linearin zset.rs is the theory’s load-bearing test: deleting the last copy must retract, a stateless delta pass can’t know.- ChurnGen guards same-batch insert-after-delete of one edge so weights stay in {0,1} — the oracles assume set semantics (debug_assert’d).
Done when
- All 15 tests green (
cargo test --release). - ivm_bench speedup columns filled; wedge-join integrate-cost suspicion confirmed or refuted (if confirmed: write the two-sentence argument for why arrangements exist).
- Can state from memory: the linear/bilinear/nonlinear operator classification and Q^Δ = D∘Q∘I with the three join terms.
- One paragraph: why insert-only reachability is easy, why deletion is hard, and what differential stores to make it tractable.
- M27 design sketch reviewed against reading-dbsp Q4’s wedge circuit.
Topic 28 — Cloud-Native & Disaggregated Storage
The architecture every serious database is converging on: compute is stateless, the log/object store IS the database. Aurora, Socrates, Snowflake, Neon, SlateDB — and it reprices every trade-off from topics 3–6.
0. The problem, priced (measured here, sim — see notes.md)
| tier | p50 | p99 | vs local |
|---|---|---|---|
| local NVMe read | 0.10 ms | 0.12 ms | 1× |
| raw S3 GET | 14.17 ms | 112.99 ms | 140× / 940× |
Object storage is two orders of magnitude slower at the median and worse at the tail — and every serious engine moved there anyway, because:
- $: S3 ≈ $0.023/GB·month vs ~10× that for provisioned NVMe+replication; and you pay for bytes stored, not capacity provisioned.
- Durability/availability: 11 nines durability, cross-AZ by default — replication (topic 15) becomes someone else’s problem.
- Elasticity: compute scales to zero (nothing local to lose) and any node can serve any data (shared-data, not shared-nothing).
The whole topic is the engineering that claws back the 140×: caching tiers, hedged requests, batching, and putting only the right things (immutable, big, cold) on the slow tier.
1. The lineage
graph TD
S3paper["Building a DB on S3 (SIGMOD'08)<br/>prescient: pages on S3, eventual consistency pain"]
SF["Snowflake (SIGMOD'16)<br/>shared-data warehouse:<br/>immutable micro-partitions on S3,<br/>stateless virtual warehouses"]
AUR["Aurora (SIGMOD'17)<br/>THE LOG IS THE DATABASE:<br/>only redo crosses the network,<br/>6-way 4/6 quorum storage"]
SOC["Socrates (SIGMOD'19)<br/>separate durability (XLOG)<br/>from availability (page servers)"]
NEON["Neon (2021-)<br/>Aurora's idea, open source:<br/>safekeepers (WAL) + pageserver<br/>(page@LSN) + S3 + branches"]
SLATE["SlateDB / Quickwit / IOx (2020s)<br/>LSM & search built S3-first:<br/>manifest-on-CAS, zero-copy clones,<br/>hedged reads, hotcache"]
S3paper --> SF
S3paper --> AUR
AUR --> SOC
AUR --> NEON
SF --> SLATE
SOC --> NEON
2. Neon’s shape (the one to internalize — it’s Postgres, and it’s Rust)
compute (Postgres, STATELESS)
│ WAL stream ▲ GetPage@LSN
▼ │
safekeepers ×3 ──────────────► pageserver ──── layer files ────► S3
(Paxos-ish WAL quorum, (ingests WAL, serves (cold layers,
durability, ~RAM+disk) page versions, hot cache) all history)
- The WAL is durable the moment a quorum of safekeepers has it
(safekeeper.rs:292
AppendRequest) — commit latency never touches S3. - The pageserver is a big index of page versions:
LayerMap::search (key, end_lsn)(layer_map.rs:448) over delta layers (WAL records, keyed key×LSN rectangles) and image layers (materialized pages) — an LSM over (page, LSN), the topic 4 shape yet again. - Reads reconstruct: find newest image ≤ LSN, apply deltas through a sandboxed Postgres walredo process (walredo.rs:173) — REDO from topic 5, promoted to the read path.
- A branch is
(parent timeline, LSN)— created in O(1), no copy (tenant.rs:4985branch_timeline_impl); reads walk ancestors capped at the branch point (timeline.rs:4548). Our branch.rs stub is exactly this.
3. The design space in one table
| axis | Aurora | Socrates | Neon | Snowflake | SlateDB |
|---|---|---|---|---|---|
| what crosses the network | redo log only | log + pages | WAL to safekeepers | micro-partition files | SSTs + manifest |
| durability | 6-way 4/6 quorum | XLOG service | safekeeper quorum | S3 | S3 (+ optional WAL obj) |
| page/read service | storage nodes replay | page servers (RBPEX cache) | pageserver + walredo | warehouse-local cache | block cache + part cache |
| branching/clones | — | snapshots | O(1) LSN branches | zero-copy clone | checkpoint/clone (clone.rs:38) |
| single-writer fencing | epoch in quorum | — | generation numbers | — | writer_epoch CAS (manifest/mod.rs:824) |
Rosetta: WAL rule (topic 5) → architecture. Aurora ships only the log; tables/pages are caches of log prefixes materialized near the reader — the same sentence as Kafka’s thesis in topic 27’s reading-kafka-log.md, arrived at independently for OLTP.
4. Experiments (experiments/)
Simulated tiers with charged (not slept) latency — deterministic p50/p99
in milliseconds of wall time. cargo run --release --bin tier_bench.
| file | what | status |
|---|---|---|
sim.rs | latency models (NVMe / S3 lognormal+stragglers / scripted), block store, zipf, percentiles | PROVIDED |
cache.rs | LruBlockCache + TieredReader read-through (slatedb’s CachedObjectStore shape) | STUB |
hedge.rs | hedged_get — backup request at p95 deadline (quickwit’s TimeoutAndRetryStorage) | STUB |
branch.rs | BranchStore::get — CoW branch ancestry walk (Neon timelines) | STUB |
bin/tier_bench.rs | the ladder: local vs raw S3 (provided) vs cached/hedged/branched (stubs) | PROVIDED |
Contract highlights: LRU touch-protects; a Zipfian workload must clear 50% hit rate with a 1/8-size cache; hedging at p95 must halve p99 with <10% extra GETs; branch creation must copy nothing and a 100-deep chain must resolve reads correctly.
5. Reading guides
- reading-aurora.md — Aurora: only the log crosses the network
- reading-socrates.md — Socrates: durability is not availability
- reading-snowflake-s3.md — Snowflake and the 2008 S3 paper: immutability dissolves the walls
- reading-neon.md — Neon: page versions from WAL, branches for free
- reading-slatedb-quickwit.md — SlateDB & Quickwit: born on S3
Further references: “Lakehouse” (CIDR 2021) + “Delta Lake” (VLDB 2020) — the open-format counterpoint to Snowflake: keep data in Parquet on object storage, get ACID from a transaction log of file lists (the same manifest-as-truth move as SlateDB’s, at table scale); “CockroachDB” (SIGMOD 2020) — the shared-nothing rebuttal to this whole topic’s disaggregation thesis (every node stores + computes; Raft per range instead of a page server).
6. Cross-topic threads
- Topic 4: Neon’s layer map and SlateDB are LSMs; S3 just moved where the levels live. Compaction becomes a distributed, fenced actor.
- Topic 5: WAL-as-truth, generalized. Aurora = “ship only WAL”; walredo = REDO on the read path; safekeepers = archived WAL with a quorum.
- Topic 6: the buffer pool comes back as the local cache tier — same eviction questions, new miss cost (15 ms, plus a per-request bill).
- Topic 15: safekeepers are a consensus log; SlateDB replaces leader leases with CAS fencing epochs on the manifest — consensus outsourced to S3’s conditional PUT.
- Topic 27: log-is-the-database is Kafka’s thesis; Materialize’s persist crate is this topic applied to IVM state.
7. Capstone M28 (FalkorDB)
Tiered storage backend — hot data local, SSTs on object storage — plus instant graph snapshots/branches. Design notes in notes.md §M-log.
Aurora: only the log crosses the network
Aurora is where “the log is the database” became a shipping OLTP architecture: the writer sends storage nothing but redo records, and six-way-replicated storage nodes materialize pages by replaying them. This chapter extracts the quorum design, the commit path, and the recovery story — the template every later disaggregated engine (Socrates, Neon) either copies or argues with.
1. The one-sentence thesis
The log is the database. The only thing the writer sends to storage is the redo log; storage nodes materialize pages by replaying it, on demand or lazily. Everything else in the paper is consequences.
classic MySQL on EBS: Aurora:
writer ──► data pages ─┐ writer ──► redo records ONLY
──► redo log ├─►EBS ┌──────┴──────┐ 4/6 quorum
──► binlog │ AZ1 ▓▓ AZ2 ▓▓ AZ3 ▓▓ (6 copies,
──► double-write┘ storage nodes replay 2 per AZ)
(each mirrored again!) redo -> pages themselves
~35x more write traffic per page change (paper's Table 1: network IOs)
2. What to extract, section by section
- §2 quorums: 6 copies, 2 per AZ; write quorum 4/6, read quorum 3/6. Sized so an entire AZ + one more node can fail without losing writes (AZ+1 fault model). Note what the quorum is of: log records for a 10 GB protection group segment, not whole-database replicas.
- §3 the log ships alone: no checkpoints from the writer, no dirty page writeback, no double-write buffer. Storage does its own “compaction” (apply redo to pages) — the LSM shape (topic 4) hiding inside a page store.
- §4.2 commit: async — commit waits only for the 4/6 ack of the commit record’s LSN (VDL advance), not for any page write. Group commit falls out naturally (topic 5’s fsync batching, network edition).
- §4.2.1 reads: no read quorum in the common path! The writer tracks which segment has what LSN, reads from a known-complete replica. Read quorum only for crash recovery (rebuilding the VDL).
- §6 recovery: near-instant — no REDO pass at the writer (storage is always replaying); UNDO is lazy, online. Compare topic 5’s ARIES phases: Aurora made REDO continuous and distributed.
Q1. Why is 4/6 write + 3/6 read correct (W+R > N) but the paper still insists reads avoid quorums? What specifically makes quorum reads expensive here — latency, or the loss of the “which replica is complete” bookkeeping?
Q2. The paper brags about avoiding 2PC. But there IS a multi-node atomicity problem: one transaction’s redo spans multiple protection groups. How does the monotonic LSN + VDL (volume durable LSN) rule replace the prepare/commit round trips? What’s the equivalent of “presumed abort”? (Everything above VDL is truncated on recovery.)
Q3 (the trade). Storage replays redo, so pages near the writer are always warm — but replicas apply the same log to their buffer pools with ≤ 20 ms lag and must NOT serve reads above the durable LSN. Map this onto topic 15’s replication lag taxonomy: is an Aurora read replica sync, async, or something the taxonomy doesn’t name?
Q4 (M28). FalkorDB translation: the “redo record” for a graph is the delta matrix batch (topic 27’s tick). If storage nodes could apply delta matrices, compute would ship only deltas and storage would materialize adjacency. What operation must the storage tier then support that S3 doesn’t — and is that why Aurora runs its own storage fleet while Neon keeps S3 behind a pageserver?
3. Numbers worth memorizing
- 6 copies / 4-of-6 write / AZ+1 fault tolerance; 10 GB segments repaired in parallel (~10 s per segment on 10 Gbps).
- 35× network amplification eliminated vs MySQL-on-mirrored-EBS.
- Commit = log-quorum-ack only; recovery = seconds (no REDO replay at compute).
References
Papers
- Verbitski et al. — “Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases” (SIGMOD 2017) — 12 pages, read whole
- Verbitski et al. — “Amazon Aurora: On Avoiding Distributed Consensus for I/Os, Commits, and Membership Changes” (SIGMOD 2018) — optional, for the quorum subtleties
Neon: page versions from WAL, branches for free
Aurora’s idea, reimplemented for stock Postgres, in Rust, in the open — the best codebase to read for this topic. Compute streams WAL to a safekeeper quorum for durability; a pageserver indexes page versions by (key, LSN) and reconstructs any page at any LSN on demand, which is also why a branch costs O(1). This chapter walks both crates by anchor.
1. The data flow
Postgres (unmodified + smgr hook)
│ WAL (streamed, synchronous quorum) GetPage@(key, LSN)
▼ ▲
safekeepers ×3 ── consensus on WAL ──► pageserver ──────┘
(durability NOW) │ ingests WAL -> delta layers
│ compacts -> image layers
▼
S3 (all layers, all history)
2. Pageserver anchors (the read path)
| anchor | what it is |
|---|---|
pageserver/src/pgdatadir_mapping.rs:258 | get_rel_page_at_lsn — the public question: (relation, block, LSN) → page |
pageserver/src/tenant/timeline.rs:1227/:1339 | Timeline::get / get_vectored — batched key×LSN reads |
timeline.rs:4491 | get_vectored_reconstruct_data — gather image + deltas needed to rebuild each page |
timeline.rs:4548 | the ancestor walk: keys not found on this timeline are re-asked of ancestor_timeline capped at the branch LSN — our branch.rs stub verbatim |
tenant/layer_map.rs:71/:448/:596 | LayerMap::search(key, end_lsn) — which layers can contain versions of key below end_lsn; a 2-D (key × LSN) search structure |
tenant/storage_layer/delta_layer.rs:213 | DeltaLayer — sorted (key, LSN) → WAL record files |
tenant/storage_layer/image_layer.rs:148 | ImageLayer — materialized pages at one LSN |
pageserver/src/walredo.rs:55/:173/:473 | PostgresRedoManager::request_redo → apply_wal_records in a sandboxed Postgres subprocess — topic 5’s REDO on the read path |
pageserver/src/tenant.rs:4985 | branch_timeline_impl — a branch is metadata: (ancestor, ancestor_lsn). O(1). |
The mental model: an LSM over the key space (key, LSN). Delta layers = level files of WAL records; image layers = the “compacted” form; GC = dropping history below the PITR horizon (respecting branch points!). Topic 4 for the third time, after GIN pending lists and differential arrangements (topic 27 notes).
GetPage@LSN, reduced to its loop — reconstruction plus the ancestor walk:
#![allow(unused)]
fn main() {
fn get_page(tl: &Timeline, key: Key, lsn: Lsn) -> Page {
let (mut tl, mut lsn) = (tl, lsn);
let mut deltas = vec![];
loop {
match tl.layers.search(key, lsn) { // 2-D (key × LSN) search
Found::Image(img) => { // ONE image suffices...
return walredo(img, deltas); // ...replay deltas on it
} // (REDO on the READ path)
Found::Delta(rec, below) => { // collect, keep descending
deltas.push(rec); lsn = below;
}
Found::Nothing => { // not born on this timeline:
lsn = tl.ancestor_lsn.min(lsn); // ask the parent, capped at
tl = tl.ancestor(); // the branch point
}
}
}
}
}
Q1. LayerMap::search answers “newest layer that could hold (key,
≤ lsn)”. Why does reconstruction need at most ONE image layer but possibly
MANY delta layers, and what does compaction (creating new image layers)
buy — in our tier_bench vocabulary, which lane’s latency does it cap?
Q2. Branches make GC hard: a layer can be garbage for the child but
live for the parent (or vice versa). Look at how gc_info.insert_child
(tenant.rs:588-592) registers ancestor_lsn as a retain point. State the
GC rule in one sentence. (Keep everything ≥ min over children’s branch
LSNs and the PITR horizon.)
3. Safekeeper anchors (the write path)
| anchor | what it is |
|---|---|
safekeeper/src/safekeeper.rs:292 | AppendRequest — WAL push protocol messages, term-based (Raft-flavored, “Paxos-ish” per their docs) |
safekeeper/src/wal_storage.rs | segment files on safekeeper disk — the durable landing zone |
safekeeper/src/wal_backup.rs | offload of safekeeper WAL to S3 once pageserver has consumed it |
safekeeper/src/timeline_eviction.rs | evict cold timelines from safekeeper disk — even the landing zone tiers to S3 |
Socrates rosetta: safekeepers = XLOG landing zone; pageserver = page servers; S3 = XStore. Same decomposition, independent arrival.
Q3. Commit waits for a safekeeper quorum only — the pageserver may lag. What read anomaly does GetPage@LSN prevent that a lagging page service would otherwise cause, and what does compute have to send with each read request to get it? (The LSN it needs — reads wait for the pageserver to catch up to that LSN rather than returning stale pages.)
Q4 (M28). Our branch.rs stub resolves get(branch, page, lsn) by walking parents. Neon avoids unbounded walks: image layers get copied down (materialized) into child timelines by compaction over time. When would M28’s graph branches need the same trick — what query pattern makes a 64-deep ancestor walk show up, and what’s the graph equivalent of an image layer (a materialized matrix snapshot at the branch point)?
References
Code
- neon — pageserver +
safekeeper crates (Rust); read path anchors in
pageserver/src/pgdatadir_mapping.rs,tenant/timeline.rs,tenant/layer_map.rs,tenant/storage_layer/,pageserver/src/walredo.rs; write path insafekeeper/src/ - Neon architecture posts: “Architecture decisions in Neon”, “Get page
at LSN” docs in
docs/in-repo (skimdocs/pageserver-storage.md&docs/walservice.mdequivalents if present)
SlateDB & Quickwit: born on S3
Neon and Aurora retrofit object storage under an existing engine; SlateDB (an LSM whose ONLY disk is an object store) and Quickwit (search over S3) were born there — so every S3 pathology has an explicit, readable countermeasure in their code. This chapter is the menu M28’s tiered-storage stubs are ordered from: caches, hedged reads, CAS fencing, zero-copy clones.
1. SlateDB — the LSM from topic 4, re-priced for S3
put ──► WAL buffer ──► WAL SSTs on S3 ──► memtable flush ──► L0 SSTs ──► runs
(batch!) ~50-100 ms/put compactor (separate process,
AwaitDurable vs no-sync = the fsync fenced by compactor_epoch)
trade (topic 5), now costing 100 ms │
manifest on S3, updated via CAS ◄────┘
| anchor | what it is |
|---|---|
db.rs:205/:842 | get_with_options — memtable → L0 → runs, same read path as topic 4; :309 maybe_apply_backpressure |
tablestore.rs:37/:348/:797/:835 | TableStore — SSTs as objects; read_blocks_using_index fetches only needed 4 KiB blocks via ranged GETs |
cached_object_store/object_store.rs:34/:198 | local part cache: objects split into part_size_bytes parts, cached on local disk — our cache.rs stub’s production form |
db_cache/ (moka.rs, foyer.rs) | in-memory block cache layer above the part cache — a 3-level ladder: RAM → local disk → S3 |
manifest/mod.rs:824 | writer_epoch / compactor_epoch |
fence.rs:105 | fence() — bump your epoch via CAS on the manifest object; a zombie writer’s next manifest CAS fails. Single-writer safety WITHOUT a lease service — consensus outsourced to S3 conditional PUT (the 2008 paper’s missing primitive, delivered 2024) |
checkpoint.rs:30, clone.rs:38 | checkpoints pin a manifest version; create_clone = new DB whose manifest references the parent’s SSTs — zero-copy CoW clone, Neon-branch shaped |
manifest/invariants.rs:42 | the fencing invariant, stated as a doc’d invariant with a wall-clock-skew argument |
The fencing trick, whole — consensus outsourced to one conditional PUT:
#![allow(unused)]
fn main() {
fn fence(store: &ObjectStore) -> Result<Writer> {
loop {
let (m, version) = store.get_manifest()?; // versioned read
let me = m.writer_epoch + 1; // claim the next epoch
let next = m.with_writer_epoch(me);
// CAS: PUT if-match version — S3 rejects concurrent writers
match store.put_manifest_if_version(&next, version) {
Ok(_) => return Ok(Writer { epoch: me }), // fenced in; any zombie's
Err(Conflict) => continue, // next CAS sees a newer
} // epoch and MUST die
}
}
// every later state change re-CASes the manifest carrying `epoch`,
// so a paused writer can never publish after being fenced.
}
Q1. Walk the write path and find every place latency is bought back:
WAL batching (many puts per WAL SST), AwaitDurable opt-out, memtable
serving reads before flush. Then state the residual: what is the floor
on durable-commit latency for an S3-only LSM, and why do Neon/Socrates
class systems refuse to pay it (they keep a fast landing zone)?
Q2. Fencing: writer A stalls (GC pause), writer B fences with epoch+1, A wakes and tries to CAS the manifest. Trace why A’s write MUST fail and what A must do (die). Compare topic 15’s Raft leadership — what replaces the election timeout, and what’s the availability cost of having no leases (a stalled writer blocks nothing, but detection is lazy)?
Q3. Compaction runs as a separate process with its own epoch. Why is “compactor and writer race” safe when both only ever add objects and CAS the manifest — which single object is the linearization point for the entire database state?
2. Quickwit — search’s answers to the same pathologies
| anchor | what it is |
|---|---|
quickwit-storage/src/bundle_storage.rs:40/:131 | a split = ONE object bundling all index files + a hotcache footer (the file-offset map + hot bytes) — one GET bootstraps a searchable index; request-count economics drove the format |
quickwit-storage/src/timeout_and_retry_storage.rs:37/:89 | hedged/retried GETs: if a ranged read exceeds the timeout policy, retry aggressively (cites AWS’s own S3 latency guidance) — our hedge.rs stub |
quickwit-config/src/node_config/mod.rs:608 | StorageTimeoutPolicy — the hedge deadline as config |
quickwit-storage/src/split_cache/mod.rs:43/:123 | whole-split local cache with explicit admit/evict policy |
quickwit-storage/src/cache/byte_range_cache.rs | byte-range cache — quickwit caches ranges, slatedb caches parts, we cache blocks: same trick, three granularities |
Q4. The hotcache: quickwit appends the “what’s where + hottest structures” bytes at the END of the bundle so one GET (or two: tail then body) opens an index. Which topic 23 structures make it into the hotcache (term dictionary FSTs’ first layers, field offsets), and what’s the FalkorDB analogue for a graph snapshot object — what belongs in the footer so a reader can route its second GET precisely (matrix block index / offsets, label→matrix directory, node-count header)?
3. The convergence table (M28’s menu)
| pathology | slatedb answer | quickwit answer | our stub |
|---|---|---|---|
| 15 ms GETs | RAM+disk block/part caches | split cache + byte-range cache | cache.rs |
| fat tail | retries in object_store client | TimeoutAndRetryStorage hedging | hedge.rs |
| per-request $ | big SSTs, block-granular ranged GETs | one-object bundles + hotcache | (block granularity) |
| no rename/atomicity | manifest CAS + epochs | immutable splits + metastore | — |
| cheap copies | checkpoint/clone over shared SSTs | splits shared by reference | branch.rs |
References
Code
- slatedb
slatedb/src/— the anchor table above:db.rs,tablestore.rs,cached_object_store/,db_cache/,manifest/,fence.rs,checkpoint.rs,clone.rs - quickwit
quickwit/quickwit-storage/src/—bundle_storage.rs,timeout_and_retry_storage.rs,split_cache/,cache/byte_range_cache.rs; the storage tricks generalize - turso’s object-store backend is in flight upstream; the slatedb patterns are what it converges to
Snowflake and the 2008 S3 paper: immutability dissolves the walls
A pair of papers, eight years apart, that bracket the “database on object storage” question: one catalogues every pathology honestly, the other quietly ticks the whole checklist by making the data immutable and hoisting the mutable bit into a small metadata service. Between them sits this topic’s core design move — and Q1 tracks which pathologies S3 itself has since fixed.
1. Why these two together
The 2008 paper asked “can S3 be the database?” eight years early and hit every wall; Snowflake is the first system that made the answer yes at scale — by changing the question (analytics, immutable files, no fine-grained updates).
2008: pages on S3, updated in place ──► eventual consistency pain,
no atomicity, pay-per-request shock (all catalogued honestly)
2016: IMMUTABLE micro-partitions on S3 + metadata service for the
mutable bit ──► every 2008 problem dissolves except latency,
which caching + columnar scans amortize
2. Building a Database on S3 — what to extract
- The design: B-tree pages stored as S3 objects; commit = push log records to SQS queues; “checkpointing” merges them into pages. Read §3’s protocols — it’s WAL-shipping (topic 5) built from queues.
- §5’s honest accounting: no read-your-writes (S3 was eventually consistent until Dec 2020 — now strong, which retroactively fixes half the paper), no multi-object atomicity (fixed Nov 2024-ish by conditional writes / If-Match CAS — which SlateDB’s fencing now leans on), and request costs dominating at small page sizes.
Q1. List the three 2008 blockers (consistency, atomic multi-page commit, cost-per-request) and, for each, what changed: S3 strong consistency (2020), S3 conditional PUT/CAS (2024, enabling manifests as commit points — see slatedb guide), and bigger immutable objects (amortize request cost). Which blocker did systems route around rather than wait for? (All three — via immutability + a small strongly-consistent metadata tier.)
3. Snowflake — what to extract
- Three layers: object storage (data) / virtual warehouses (stateless compute, per-customer, elastically sized) / cloud services (metadata, transactions, optimization — the only stateful service).
- Micro-partitions: ~16 MB immutable columnar files (PAX layout, topic 11) with per-file min/max zone maps in the metadata layer. Updates = rewrite files; time travel = keep old file lists — a table version is a list of files, so cloning a table = copying a list. CoW branching again, the same trick as Neon branches and slatedb clones, at file granularity.
- Pruning, not indexes: no B-trees; min/max metadata prunes micro-partitions (topic 26’s BRIN-shaped one-sided filter, at cloud scale).
- Warehouse-local cache on SSD; consistent hashing assigns files to nodes so caches don’t overlap; work stealing when skewed.
Q2. Snowflake’s shared-data claim: any warehouse can read any table, scaling compute without data movement. What’s the concurrency price — where do write-write conflicts get decided, and why is “metadata service does snapshot isolation over file lists” enough for a warehouse (vs an OLTP engine, where Aurora needed per-page LSN machinery)?
Q3. Consistent-hash-with-cache vs shared-nothing partitioning (topic 15): when a Snowflake warehouse resizes, no data reshuffles — only cache assignments change. What workload property makes “cache locality is a hint, not a correctness requirement” true here but false for, say, a partitioned Raft group?
Q4 (M28). FalkorDB analytics reads (topic 22’s read replicas / BI export shape): micro-partition thinking says “publish immutable columnar snapshots of the graph + a version manifest” instead of replicating the live engine. Which graph representations tolerate immutable ~16 MB chunks well (edge lists / CSR segments, topic 2) and which don’t (in-place delta-mutated matrices)? One paragraph in notes.md.
References
Papers
- Dageville et al. — “The Snowflake Elastic Data Warehouse” (SIGMOD 2016) — read §1-4
- Brantner, Florescu, Graf, Kossmann, Kraska — “Building a Database on S3” (SIGMOD 2008) — read §1-3 + §5; it’s the prescient one
Socrates: durability is not availability
SQL Server rebuilt for Azure, with one architectural thesis: the tier that makes a write durable and the tier that serves pages back have opposite requirements, so they should be different services. This chapter reads the four-tier decomposition and how it reuses — rather than rewrites — the classic engine, the counterpoint to Aurora’s storage-layer rewrite.
1. Why read this right after Aurora
Aurora fused two jobs into its storage fleet: making writes durable and making pages available for reads. Socrates’ contribution is noticing these have opposite requirements and splitting them:
| job | requirement | Socrates tier |
|---|---|---|
| durability | tiny, fast, sequential, SSD/NVM | XLOG service (the landing zone) |
| availability | big, warm, random-read, scalable | Page servers + XStore |
compute primary ──► XLOG service (log landing zone, quorum, FAST)
│ │ fan-out (async)
▼ GetPage ▼
page servers (each owns a partition; RBPEX cache; replay log)
│ backing store
▼
XStore (Azure blob storage — cheap, slow, all versions)
- Commit latency = XLOG append only (like Aurora’s 4/6, like Neon’s safekeepers). Page servers consume the log asynchronously — they’re caches, they can lag, crash, be rebuilt from XStore.
- RBPEX (Resilient Buffer Pool Extension): the buffer pool spilled to local SSD, surviving restarts — topic 6’s buffer pool made durable-ish. Both compute and page servers run one.
- Snapshots/backup = XStore blob snapshots — nearly free, like Neon branches but coarser.
Q1. Socrates keeps the classic ARIES page-oriented redo (topic 5), Aurora rearchitected around “log only”. Yet both end with “compute ships log; page service replays”. What did Socrates get for not rewriting the engine (hint: the paper’s stated goal — reuse SQL Server code: HADR log transport, buffer pool, etc.), and what does it pay in write amplification between tiers?
Q2. The XLOG “landing zone” is small and fixed-size; the log is truncated once page servers + XStore have consumed it. Map each stage onto topic 5’s WAL lifecycle (active tail → archived → checkpointed away) and onto Neon: which Neon component is the landing zone, which is the long-term log? (safekeepers; S3 via the pageserver’s layer uploads.)
Q3. A page server is “just a cache of XStore + log replay” — so losing one costs nothing durable. What does this do to the tail latency story when a page server is cold (compare our tier_bench raw-S3 lane: p99 ~113 ms)? Where does Socrates hide the misses? (RBPEX warm-up from snapshot; requests hedged to replicas.)
Q4 (M28). FalkorDB single-writer translation: the XLOG/page-server split says “durability tier ≠ serving tier”. For a graph engine, the durability tier is the AOF/replication log (topic 5); the serving tier is materialized matrices. Does M28 need a page-server equivalent at all, or does the compute node’s own RBPEX-style local cache over object storage suffice until read replicas (M15) enter? Write the one-paragraph answer in notes.md.
2. The comparison table to carry forward
| Aurora | Socrates | Neon | |
|---|---|---|---|
| durability quorum | storage nodes (4/6) | XLOG landing zone | safekeepers (Paxos-ish) |
| page serving | same nodes | separate page servers | pageserver |
| cold tier | (internal) | XStore blobs | S3 layer files |
| engine rewrite? | storage layer yes | minimal (reuse) | none (stock Postgres + smgr hook) |
| caches | storage-side pages | RBPEX (compute AND page server) | pageserver layers + compute shared buffers |
References
Papers
- Antonopoulos et al. — “Socrates: The New SQL Server in the Cloud” (SIGMOD 2019) — read §1-2 for the argument, §3-5 for the four tiers, skim performance
Topic 28 — Notes & measurements
Machine: Apple M3 Pro, macOS. cargo run --release --bin tier_bench
(4M keys / ~23.5K 4 KiB blocks; 200K zipf(0.99) point reads; latencies are
simulated & charged, not slept — deterministic under seeds).
Date: 2026-07-10.
Measured baselines (provided lanes — the ladder, priced)
| lane | p50 | p95 | p99 | mean |
|---|---|---|---|---|
| local NVMe | 0.10 ms | 0.12 ms | 0.12 ms | 0.10 ms |
| raw S3 (no cache) | 14.17 ms | 27.18 ms | 112.99 ms | 17.07 ms |
140× at the median, 940× at p99 (2% stragglers dominate the tail). That’s the gap the whole topic exists to close.
Predictions BEFORE implementing the stubs
| stub lane | prediction | reasoning |
|---|---|---|
| S3 + LRU cache (3000 blocks ≈ 1/8) | hit rate 65-80%; mean ~3-6 ms, ~3-5× vs raw S3; p50 collapses to 2 µs, p99 stays ~S3 p95 | zipf(0.99) mass concentrates hard; adjacent hot keys share blocks (170 keys/block) boosting hits; but p99 is governed by misses, which caching can’t fix — only hedging can |
| hedged at p95 | p99 from ~113 ms to ~30-35 ms (>3×); hedge rate ≈ 5% | deadline = p95 by construction fires ~5%; a straggler’s rescue costs p95 + fresh sample (median ~14 ms) ≈ 40 ms worst-typical |
| CoW branching | build ms-scale; tip reads < 1 µs/read despite 64-hop worst case | HashMap probe per hop; most pages resolve at ROOT after ~1 hop… no wait — pages 0-999 are re-written on EVERY branch, so hot pages resolve at the tip in 1 probe; the 64-hop walk only bites pages ≥ 1000. Expect bimodal cost hidden in the mean |
Honest flag: the LRU O(n) eviction scan (3000 entries × ~50-70K misses ≈ 2×10⁸ scans) may make the cache lane’s wall time visible even though simulated latency is what’s reported. If it does, that’s the lesson: real caches (quickwit’s memory_sized_cache linked-LRU, S3-FIFO) exist because eviction is on the miss path.
Measured (stub lanes) — TODO after implementation
| lane | measured | prediction hit? |
|---|---|---|
| S3 + LRU cache | — | — |
| hedged GETs | — | — |
| CoW branching | — | — |
Questions to answer while reading (from the guides)
- Aurora Q2: how monotonic LSN + VDL replaces 2PC across protection groups.
- Aurora Q4: what storage must support to apply graph deltas (compute-in-storage vs S3-behind-pageserver).
- Socrates Q2: map XLOG landing zone → topic 5 WAL lifecycle → Neon components.
- Socrates Q4: does M28 need a page-server tier, or is local-cache-over-S3 enough pre-replicas? (write the paragraph)
- Snowflake Q2: why SI over file lists suffices for a warehouse but not OLTP.
- S3’08 Q1: the three blockers and which got fixed (strong consistency 2020, CAS 2024) vs routed around (immutability).
- Neon Q2: state the branch-aware GC retain rule in one sentence.
- Neon Q4: when M28 branches need “image layers” (materialized matrix snapshots) to cap ancestor walks.
- SlateDB Q1: the durable-commit latency floor on S3-only, and why landing zones exist.
- SlateDB Q2: CAS fencing vs Raft leases — detection laziness as the price of lease-freedom.
- Quickwit Q4: what goes in a graph snapshot object’s hotcache footer.
Cross-topic threads
- Topic 4: Neon pageserver = LSM over (page, LSN); delta layers = level files, image layers = compaction output, GC = tombstone horizon. Fourth appearance of the shape (LSM, GIN pending list, arrangements, layer map).
- Topic 5: the WAL rule promoted to architecture — Aurora ships ONLY redo; walredo runs REDO on the read path; safekeepers/XLOG = the durable tail; SlateDB’s AwaitDurable = fsync trade at 100 ms scale.
- Topic 6: local cache tier = the buffer pool reborn; same LRU-vs-scan questions, but a miss now costs 15 ms and money, so admission policy (quickwit split_cache) matters more than eviction.
- Topic 15: safekeeper quorum = Raft-shaped; SlateDB fencing epochs = CAS on S3 instead of leases — consensus outsourced to the store’s conditional PUT.
- Topic 26: Snowflake prunes with min/max zone maps = BRIN’s one-sided filter at cloud scale.
- Topic 27: log-is-the-database = Kafka’s thesis (reading-kafka-log.md); Materialize persist = this topic applied to IVM state; tables/pages as “caches of log prefixes” is the same sentence in both.
Capstone M-log (M28, per PLAN)
Target: tiered storage backend — hot data local, SSTs on object storage — plus instant graph snapshots/branches.
- Tiering: the M4 LSM’s levels ≥ L1 move to object storage as immutable SSTs; L0 + WAL stay local (the landing-zone lesson — never pay S3 latency on the commit path). Local NVMe block cache in front, slatedb part-cache shaped, sized in blocks not objects.
- Manifest: single CAS-updated object listing live SSTs + epoch numbers; writer/compactor fencing exactly as slatedb (fence.rs:105). No lease service.
- Branching: snapshot = pin a manifest version (O(1)); branch = new manifest referencing parent SSTs + private delta chain. Read path = branch.rs’s ancestry walk at SST-list granularity, NOT page granularity — graphs want whole-matrix versioning first (delta matrices already give per-tick versions, topic 27).
- The Neon trick deferred: materialize matrix snapshots into long-lived branches (“image layers”) only when ancestor walks show up in profiles.
- Hedging: wrap the object-store client with a p95 deadline policy from day one (quickwit’s citation: AWS recommends it) — it’s ~30 lines and bounds the p99 story.
- NOT in v1: page-server tier (single writer + read-locally covers it until M15 replicas want GetPage@LSN semantics); compute-applied deltas in storage (Aurora Q4) — requires custom storage fleet.
Infra notes
- Provided lanes always print; stub lanes are wrapped in catch_unwind and
print
[stub — implement …]. - 6 provided tests pass (block layout roundtrip, S3 latency shape/determinism, tier gap, zipf skew, percentile edges, branching copies nothing); 13 stub tests fail as todo!() panics (cache 5, hedge 3, branch 5).
- All latency is virtual:
LatencyModel::sample_microscharges cost;Fixedscripts latencies for exact hedge arithmetic tests (50ms primary / 1ms backup / 10ms deadline ⇒ 11ms — the contract). - Zipf sampler precomputes a CDF (32 MB for 4M keys) — fine, one-time.
BranchStoreLSNs are globally monotonic across branches, so parent writes after a branch point are invisible to children by comparison alone — no per-branch clocks needed (single writer, the M28 luxury).
Done when
- All 19 tests green (
cargo test --release). - tier_bench stub lanes filled in the table above; the p99-vs-p50 split confirmed (cache fixes the median, hedging fixes the tail — say it from memory).
- Can draw the Neon data flow (compute / safekeepers / pageserver / S3) and name what each tier is durable for.
- One paragraph: why S3 conditional PUT (CAS) made lease-free single-writer databases possible, and where FalkorDB would use it.
- M28 design sketch reviewed against Socrates Q4 and Neon Q4 answers.
Topic 29 — Distributed Transactions
The layer above topic 15’s Raft: making transactions span shards. The gap between 2PC-in-a-textbook and Spanner/FoundationDB is where the deep understanding lives.
0. The problem, priced (measured here — see notes.md)
Bank transfers, 100K accounts, batches of 8 concurrent txns, Zipfian keys:
| zipf θ | batches with a key collision |
|---|---|
| 0.5 | 0.3% |
| 0.9 | 29.9% |
| 1.1 | 86.2% |
| 1.3 | 99.6% |
Contention isn’t an edge case — at real-workload skew (θ≈0.9-1.1) most concurrent batches conflict. Every protocol below is a different answer to “who aborts, who waits, and who blocks when the coordinator dies.”
1. The design space
graph TD
TPC["textbook 2PC<br/>coordinator + prepare/commit<br/>FLAW: coordinator crash after<br/>prepare = participants BLOCKED"]
PERC["Percolator (OSDI'10)<br/>decision moved INTO the data:<br/>primary lock = txn fate,<br/>any reader can resolve"]
SPAN["Spanner (OSDI'12)<br/>2PC over Paxos groups +<br/>TrueTime ε-bounded clocks<br/>= external consistency"]
HLC["HLC (OPODIS'14) / CockroachDB<br/>no atomic clocks: hybrid<br/>logical clocks + uncertainty<br/>restarts + parallel commits"]
CALVIN["Calvin (SIGMOD'12)<br/>the counterpoint: agree on the<br/>ORDER first, then execute<br/>deterministically — no 2PC at all"]
FDB["FoundationDB (SIGMOD'21)<br/>decompose the transaction itself:<br/>sequencer / resolvers / proxies /<br/>storage — OCC at datacenter scale"]
TPC -->|"unblock via<br/>data-resident decision"| PERC
TPC -->|"replicate the<br/>coordinator"| SPAN
SPAN -->|"remove the<br/>hardware"| HLC
TPC -->|"remove the<br/>runtime agreement"| CALVIN
TPC -->|"decompose +<br/>batch"| FDB
2. The one-table summary
| system | concurrency control | clock | cross-shard atomicity | blocking window |
|---|---|---|---|---|
| Percolator/TiKV | optimistic (lock at prewrite) | TSO (central oracle) | primary-key commit point | none — readers resolve |
| Spanner | 2PL + 2PC | TrueTime (ε-bounded GPS/atomic) | 2PC over Paxos groups | Paxos-replicated coordinator |
| CockroachDB | MVCC + timestamp ordering | HLC + max-offset | parallel commits (STAGING) | none — status recoverable |
| Calvin | deterministic execution | sequencer batch order | none needed (order fixed a priori) | none — but no interactive txns |
| FoundationDB | OCC (resolver checks read/write sets) | sequencer versionstamps | proxy makes batch durable | recovery epoch bump |
3. Percolator in six lines (the one we build)
prewrite(all keys, start_ts): lock each key (one is PRIMARY), stage data;
abort on any lock or any commit > start_ts
commit_primary(commit_ts): write-record primary, drop its lock ← THE COMMIT POINT
commit_secondaries: lazy; crash here is harmless
reader hits stale lock: look at primary — lock held? roll back.
write record? roll forward. (fate is in the data)
TiKV: prewrite = txn/actions/prewrite.rs:37, commit = commit.rs:64,
resolution = check_txn_status.rs/cleanup.rs (see reading-percolator-tikv.md).
4. Experiments (experiments/)
cargo run --release --bin txn_bench
| file | what | status |
|---|---|---|
kv.rs | sharded MVCC cluster with Percolator’s data/lock/write columns, TSO, Zipf | PROVIDED |
tpc.rs | 2PC coordinator + DST crash points + recovery — the blocking window on display | STUB |
percolator.rs | prewrite / commit_primary / commit_secondaries / resolve_lock | STUB |
hlc.rs | hybrid logical clock send/recv rules | STUB |
bin/txn_bench.rs | conflict probability (provided) + abort rates vs θ + 2PC crash storm | lanes |
Contract highlights: every 2PC crash point must preserve atomicity after
recovery, and a logged decision must roll forward; Percolator readers must
roll a crashed txn forward iff the primary committed; HLC must stay
monotonic under backward-jumping physical clocks while l never escapes the
max physical time seen (the anti-Lamport-drift bound).
5. Reading guides
- reading-percolator-tikv.md — Percolator: 2PC with the coordinator erased
- reading-spanner-hlc.md — Spanner & HLC: timestamps without the oracle
- reading-calvin.md — Calvin: agree on inputs, not outcomes
- reading-foundationdb.md — FoundationDB: the unbundled transaction
6. Cross-topic threads
- Topic 15: every serious 2PC participant/coordinator here sits on a Raft/ Paxos log — Spanner replicates the coordinator, FDB replicates by epoch recovery. 2PC and consensus are orthogonal layers, not rivals.
- Topic 16: FDB’s simulation (ResolverBug.cpp ships injectable resolver bugs) is the DST harness our tpc.rs crash points imitate.
- Topic 9 (MVCC): Percolator is postgres’s MVCC snapshot rule stretched across machines — start_ts/commit_ts are xmin/xmax with a TSO instead of a local counter.
- Topic 27: TiKV’s resolved-ts / CDC is the changelog of this topic’s writes — the IVM input stream.
- Topic 24/25: the cross-shard pattern matching problem (M29’s second half) is a distributed join over partitioned adjacency — delta-join shapes from topic 27 apply.
7. Capstone M29 (FalkorDB)
Cross-shard transactions + cross-shard pattern matching over a partitioned graph. Design notes in notes.md §M-log.
Calvin: agree on inputs, not outcomes
Every other protocol in this topic coordinates on transaction outcomes at runtime. Calvin is the counterpoint: fix the input order first, execute deterministically, and the whole commit-protocol problem disappears — along with the interactive transactions everyone actually writes. There is no reference repo to read here; the lineage lives on in FaunaDB and in Abadi’s deterministic-database literature, so this chapter is paper-only.
The contrarian move
Every other system in this topic agrees on transaction outcomes at runtime (2PC, Paxos-per-commit). Calvin agrees on transaction inputs before execution, then executes deterministically — so every replica and every shard reaches the same state with zero runtime coordination about outcomes. No 2PC. No commit protocol at all.
conventional Calvin
txns arrive ──> execute ──> agree txns arrive ──> AGREE ON ORDER
(locks, 2PC, aborts, retries) (sequencer: batch + replicate log)
│ │
nondeterminism everywhere execute deterministically
=> replicas must ship outcomes => replicas re-derive outcomes
The three layers (paper §2)
- Sequencer — collects txn requests into 10ms epochs, replicates the batch (Paxos) across replicas, hands each shard the global order. This is the only consensus in the system, and it’s off the critical path of execution.
- Scheduler — deterministic locking: acquire locks in exactly the order txns appear in the log. Deadlock-free by construction (a total order over lock acquisition), and every replica makes identical grant decisions without talking.
- Executor — runs txn logic. Cross-shard txns exchange read results (push, not request) — each shard knows from the plan exactly which remote reads to expect.
The scheduler is the part worth writing down — deterministic 2PL is just 2PL with the request order pinned to the log:
#![allow(unused)]
fn main() {
fn scheduler(log: &[Txn], lm: &mut LockManager) {
for txn in log { // exactly log order, every replica
for key in txn.read_write_set() { // known up front — the Calvin price
lm.enqueue(key, txn.id); // FIFO queue per key
}
}
// grant rule: txn runs once it heads every queue it sits in.
// A total order over acquisition => no deadlock cycle can form,
// and every replica makes IDENTICAL grant decisions without talking.
}
}
A crashed shard recovers by replaying the input log from a checkpoint —
no undo, no in-doubt txns, no blocking window. Our tpc.rs crash matrix
simply cannot happen here: there is no coordinator state to lose.
The catch (why not everyone is Calvin)
- Read/write sets must be known up front to lock deterministically.
Interactive txns (
BEGIN; read; think; write; COMMIT) don’t fit. Dependent txns get the OLLP trick: run a reconnaissance read-only pass to discover the sets, then submit, then re-check and retry if they moved. - One slow txn stalls the lock queue behind it — deterministic order means no reordering around stragglers.
- Latency floor = epoch batching + log replication before any execution.
Contrast with our lane 2: Percolator aborts under contention (measured vs θ); Calvin never aborts for conflicts — contention converts to queueing at the scheduler. Same enemy (the Zipf table in README §0), opposite symptom.
Questions to answer while reading
- Calvin still uses locks (§3.2). Why does deterministic lock ordering eliminate both deadlock and the need for 2PC, when 2PL alone eliminates neither?
- Trace a node failure during a cross-shard txn: how do the other shards finish without it, and why can’t this deadlock? (Hint: any replica of the dead shard can supply the pushed reads.)
- OLLP’s reconnaissance pass is optimistic. Construct the pathological workload where it livelocks, and relate it to our θ=1.3 row (99.6% collision).
- Why is a deterministic database’s replication cheaper than shipping a physical WAL (topic 15), and what does that trade for CPU?
- Where does Calvin’s design reappear in modern systems? (FaunaDB directly; but also: FoundationDB’s sequencer fixes a global order before resolution — which half of Calvin is that?)
- M29 mapping: graph traversals are the ultimate dependent transaction — the read set IS the result. Could an M29 FalkorDB use OLLP (reconnaissance traversal, then deterministic re-execution), and what invalidation check would “did the read set move?” become on a graph?
References
Papers
- Thomson, Diamond, Weng, Ren, Shao, Abadi — “Calvin: Fast Distributed Transactions for Partitioned Database Systems” (SIGMOD 2012) — §2-3 are the architecture and the deterministic locking; §5’s OLLP is the answer to dependent transactions
Code
- No reference implementation to clone — the lineage lives on in FaunaDB and in Abadi’s deterministic-database papers
FoundationDB: the unbundled transaction
What if the transaction manager weren’t a process at all, but a pipeline? FoundationDB decomposes commit into single-purpose roles — sequencer, resolvers, proxies, logs — batches everything, and turns failure handling into wholesale recovery instead of per-transaction repair. This chapter reads the SIGMOD ’21 paper against the production tree; the code is C++ in the Flow actor dialect — read it for structure, not style.
The move: decompose the transaction itself
Percolator erased the coordinator; Spanner replicated it. FoundationDB shreds it into single-purpose roles connected by batches:
client
│ get_read_version / commit(read set, write set)
▼
┌─────────────┐ read version / ┌────────────┐
│ CommitProxy │◄──commit version───│ Sequencer │ one process: hands out
│ batches txns│ │ (master) │ monotonic versions
└─────┬───────┘ └────────────┘
│ txn batch + versions
▼
┌────────────┐ key-range sharded; checks each txn's READ set
│ Resolvers │ against recent WRITES (OCC): conflict => abort
└─────┬──────┘
▼
┌────────────┐ make the batch durable (log first, storage async)
│ TLogs │──► storage servers apply lazily; reads served at version
└────────────┘
- OCC, lock-free: a txn commits iff no key in its read set was written between its read version and commit version. Resolvers keep a ~5s in-memory window of write ranges in a skip list.
- Failure handling = recovery, not repair: any role dies → bump the
epoch, recruit a fresh generation of roles, recover the tail of the
TLogs. There is no per-txn in-doubt state (contrast
tpc.rs): in-flight txns at recovery simply abort (clients see commit_unknown and retry idempotently). - The 5s window: a txn older than the resolvers’ memory can’t be
checked, so it’s rejected —
transaction_too_oldis the protocol showing through the API.
The resolver’s entire job, in one screen — SI conflict checking over a short window of in-memory write history:
#![allow(unused)]
fn main() {
fn resolve(batch: &[Txn], commit_v: Version, writes: &mut VersionedRanges) -> Vec<bool> {
batch.iter().map(|txn| {
let ok = txn.read_ranges.iter() // did anyone write what I read,
.all(|r| writes.newest_write_in(r) <= txn.read_version); // after I read it?
if ok {
writes.insert(&txn.write_ranges, commit_v); // haunt later txns for ~5s
}
ok // false => abort: cheap, because nothing was written anywhere yet
}).collect()
}
}
Code walk
fdbserver/resolver/ConflictSet.cpp:947—ConflictBatch::detectConflicts: the heart.:996checkReadConflictRangesprobes each txn’s read ranges against the version-annotated skip list (SkipListat:224);addConflictRanges(:432,:1004) inserts the batch’s write ranges for future txns. The whole SI-conflict check is ~a hundred lines over one data structure.fdbserver/commitproxy/CommitProxyServer.cpp:504—CommitBatchContext: one batch of client txns is the unit of sequencing, resolution, and TLog durability. Batching is why one sequencer process scales: it stamps batches, not txns.fdbserver/sequencer/masterserver.cpp— the sequencer: barely more than an atomic counter plus epoch bookkeeping. The lesson: after decomposition, the ordering role is trivial; the checking role (resolvers) is where the work went.fdbserver/resolver/ResolverBug.cpp— injectable resolver bugs: the simulator can be told to corrupt conflict detection on purpose to prove the tests catch it. This is the DST culture (topic 16) applied to the exact component our lane 3 crash-storms — they fault-inject correctness itself, not just crashes.
Two design reads
- vs Calvin: both fix a global order via a sequencer, but FDB orders versions and still checks conflicts at runtime (OCC), so interactive txns work — the reconnaissance problem never arises. Calvin’s determinism removed aborts; FDB kept aborts and removed blocking.
- vs Percolator: both are optimistic. Percolator’s conflict check is distributed in the data (locks in the lock CF, checked key-by-key at prewrite); FDB’s is centralized in memory (resolvers), which makes aborts cheap (nothing was written) but adds the 5s window and the false-conflict cost of range-sharded resolvers (a txn is checked by every resolver its ranges touch; any one can abort it).
Questions to answer while reading
- Why is it safe for storage servers to apply writes lazily after the
TLog fsync — what exactly is the durability point, and what do reads
at version
vwait on? - Resolvers are sharded by key range and don’t talk to each other. Show how this yields false aborts that a single resolver wouldn’t, and why FDB accepts that instead of running resolver-2PC.
- Recovery aborts all in-flight txns by construction. Why does this
eliminate
tpc.rs’s AfterAllPrepares limbo without a decision log — and what did FDB pay for it that Spanner didn’t? - A read-only txn in FDB never contacts the resolvers. Why is it still serializable (not just SI), given reads happen at a single version?
- ResolverBug.cpp ships in the production tree. Argue why “the fault injector can break conflict detection” is a stronger test than our lane 3 (which only crashes at protocol steps) — what class of bug does each catch?
- M29 mapping: FalkorDB could unbundle too — a resolver checking read/write sets of graph elements (nodes, edges, adjacency ranges). What is the graph analogue of a range conflict, and does a 2-hop traversal’s read set even fit in a resolver’s memory window?
References
Papers
- Zhou et al. — “FoundationDB: A Distributed Unbundled Transactional Key Value Store” (SIGMOD 2021) — §2-4 for the architecture and recovery; §5’s simulation section pairs with topic 16
Code
- foundationdb
fdbserver/resolver/ConflictSet.cpp,fdbserver/commitproxy/CommitProxyServer.cpp,fdbserver/sequencer/masterserver.cpp,fdbserver/resolver/ResolverBug.cpp— C++ with the Flow actor dialect; read for structure, not style
Percolator: 2PC with the coordinator erased
Textbook 2PC blocks when the coordinator dies holding everyone’s locks.
Percolator’s answer is to make the coordinator unnecessary: transaction
fate lives in the data itself, at one primary key, where any reader can
resolve it. This chapter walks the paper’s protocol and TiKV’s Rust
reimplementation together — the protocol our percolator.rs stub
implements.
Why this pairing
Percolator is 2PC with the coordinator erased: the decision lives in the data itself (the primary key’s lock/write record), so there is no process whose death can block anyone. TiKV is the highest-fidelity production reimplementation — same three column families, same primary-key commit point, plus a decade of hardening (pessimistic locks, async commit, a txn status cache) that shows where the paper’s optimism hurts.
The three column families (the whole protocol is a state machine over these)
data CF lock CF write CF
(key, start_ts) -> value key -> {primary, (key, commit_ts) -> start_ts
start_ts, ttl}
staged versions "in flight" markers the COMMIT INDEX:
invisible until a readers must not a version exists iff
write record points skip these a row here points at it
at them
A read at snapshot ts = newest write entry with commit_ts <= ts, then
fetch data[(key, its start_ts)]. Our kv.rs mirrors this exactly
(Shard::latest_write_before, Cluster::read_committed).
Transaction lifecycle
sequenceDiagram
participant C as Client
participant P as Primary key's shard
participant S as Secondary key's shard
participant T as TSO
C->>T: start_ts
C->>P: prewrite(primary): lock + stage data
C->>S: prewrite(secondary): lock points at primary
C->>T: commit_ts
C->>P: commit primary: write record + drop lock
Note over P: THE COMMIT POINT — one atomic write
C--)S: commit secondaries (async, crash-safe)
Note over S: a reader who finds this lock<br/>checks the PRIMARY to decide fate
The two phases, end to end — note where the single atomic commit point is:
#![allow(unused)]
fn main() {
fn commit_txn(c: &mut Cluster, writes: &[(Key, Val)]) -> Result<()> {
let start_ts = c.tso.next();
let primary = &writes[0].0;
for (k, v) in writes { // PHASE 1: prewrite everything —
c.shard(k).prewrite(k, v, primary, start_ts)?; // fails on ANY lock or
} // any commit_ts > start_ts
let commit_ts = c.tso.next();
c.shard(primary) // THE COMMIT POINT: write record
.commit(primary, start_ts, commit_ts)?; // + drop lock, one atomic write
for (k, _) in &writes[1..] { // secondaries are lazy; a crash
let _ = c.shard(k).commit(k, start_ts, commit_ts); // here is harmless —
} // readers roll them forward
Ok(())
}
}
Failure rules (paper §2.2, our resolve_lock recipe):
| reader finds on primary | verdict | action |
|---|---|---|
| lock still held (TTL expired) | txn never committed | roll BACK everywhere |
| write record at some commit_ts | txn committed | roll FORWARD secondaries |
| neither | already rolled back | clean up stray lock |
TiKV code walk (in reading order)
src/storage/txn/actions/prewrite.rs:37—pub fn prewrite: one mutation = lock + staged value. Note the arguments the paper never had:pessimistic_action(TiKV grew pessimistic locks because pure OCC dies at high contention — exactly what our txn_bench lane 2 measures) andsecondary_keys(async commit: the primary’s lock records all secondaries so the commit point can be computed without the client).src/storage/txn/actions/commit.rs:64—pub fn commit: verify the lock is ours, convert lock → write record. Just above (:57) is the idempotency arm: a duplicate commit finds a write record and returnsOk(None)— commit must be replayable because the client retries.src/storage/txn/actions/check_txn_status.rs:92(check_txn_status_lock_exists) and:241(check_txn_status_missing_lock) — the production version of ourresolve_lock: a reader blocked on a lock asks the primary’s shard “did this txn commit?”, withMissingLockAction(:458) encoding the roll-back-vs-error choice when no lock is found.src/storage/txn/actions/cleanup.rs:24—pub fn cleanup: the roll-back arm (write a Rollback record so a late prewrite can’t resurrect the txn — a wrinkle our simulation skips).src/storage/txn/latch.rs+scheduler.rs— before any of the above runs, per-key in-memory latches serialize commands on the same key within one TiKV node. The Percolator protocol handles distributed conflicts; latches handle local ones cheaply.src/storage/txn/txn_status_cache.rs— cache of recently-committed txn statuses, so resolvers don’t hammer the primary. Optimization layered on the same fate-lives-at-the-primary rule.
Questions to answer while reading
- Why must
prewritefail on any lock, even one withstart_tsnewer than ours? (Hint: what does the lock’s presence say about the write CF’s future?) - The commit point is “write record + remove primary lock” as one atomic
op. TiKV runs on RocksDB + Raft — what makes that pair atomic there,
and what makes it atomic in our
kv.rs? - Percolator reads wait on locks (paper: TTL + cleanup); our
getreturnsLockedimmediately. What livelock does the TTL prevent that our simulation can’t exhibit? - Why does a rolled-back txn need a durable Rollback record in the write
CF (
cleanup.rs), when our simulation just deletes the lock? What reordering breaks without it? - First-locker-wins OCC aborts the second arrival. At θ=1.1 (86% of batches collide) what abort rate do you predict for lane 2, and why is it lower than the collision rate?
- M29 mapping: FalkorDB shards a graph by node id. A 2-hop traversal
reads nodes on shards it never prewrites. Does Percolator’s snapshot
getsuffice for consistent multi-shard reads, and what does the TSO become in that design?
References
Papers
- Peng & Dabek — “Large-scale Incremental Processing Using Distributed Transactions and Notifications” (OSDI 2010) — §2 is the protocol; the observer/notification half is skippable for this topic
Code
- tikv
src/storage/txn/andsrc/storage/mvcc/— start attxn/actions/prewrite.rsandcommit.rs; the extra arguments over the paper are the decade of hardening
Spanner & HLC: timestamps without the oracle
Snapshot timestamps that respect real-time order are easy with a central
oracle — and the oracle is a SPOF and a WAN round trip. This chapter reads
the two production escapes side by side: Spanner buys a tiny clock-error
bound ε with GPS and atomic clocks and then sleeps it out at commit,
while CockroachDB accepts NTP-grade skew and pays with hybrid logical
clocks plus uncertainty restarts at read time. The code walk is
CockroachDB’s pkg/util/hlc — the exact rules our hlc.rs stub
implements.
The problem both solve
Snapshot isolation needs timestamps that respect real-world order: if txn T1 commits and then (in wall-clock reality) T2 starts on another machine, T2’s snapshot must see T1. A central TSO (Percolator) gives this trivially but is a SPOF and a WAN round trip. Spanner and CockroachDB are two answers to “timestamps without the oracle.”
external consistency without a TSO
/ \
Spanner: bound the clock ERROR CRDB: bound the clock SKEW
TrueTime ε (GPS+atomic, ~1-7ms) max-offset (NTP, ~250-500ms)
commit-wait: sleep out ε uncertainty INTERVAL: restart
=> reads never doubt reads that land inside it
Spanner in four ideas
- TrueTime:
TT.now()returns an interval[earliest, latest]guaranteed to contain true time. Hardware (GPS + atomic clocks per DC) keeps ε small. - Commit wait: assign
commit_ts = TT.now().latest, then wait untilTT.now().earliest > commit_tsbefore acknowledging. After the wait, every machine’s clock has passed commit_ts — so any later txn anywhere gets a higher timestamp. External consistency by sleeping ~2ε. - 2PC over Paxos groups: each shard is a Paxos group; the 2PC
coordinator is itself a Paxos group, so the blocking window of our
tpc.rs(coordinator dies holding everyone’s locks) is closed by replication rather than removed. - Lock-free snapshot reads: any replica can serve a read at
tonce its Paxos log is caught up pastt— timestamps replace read locks.
Commit-wait is the whole trick, and it fits in eight lines:
#![allow(unused)]
fn main() {
fn commit(txn: &mut Txn, tt: &TrueTime) -> Timestamp {
let s = tt.now().latest; // commit_ts: an upper bound on true time
txn.paxos_apply_at(s); // replicate the writes (locks still held)
while tt.now().earliest <= s { // COMMIT WAIT: sleep out the uncertainty
sleep(s - tt.now().earliest); // ~2ε on average
}
txn.release_locks_and_ack(s); // now every clock on earth has passed s,
s // so any later txn anywhere gets ts > s
}
}
HLC: the software substitute
No atomic clocks ⇒ ε is hundreds of ms ⇒ commit-wait is unaffordable. HLC
instead makes timestamps causally consistent (Lamport) while staying
within max clock skew of physical time — the rules our hlc.rs stubs
implement:
send: l' = max(l, pt) recv: l' = max(l, m.l, pt)
c' = (l'==l) ? c+1 : 0 c' = matches which max won (see stub)
key bound: l never exceeds the largest pt seen anywhere
=> |l - true time| <= skew (a Lamport clock has no such bound)
The price: HLC alone gives causal order, not external consistency. CRDB
patches the gap at read time with the uncertainty interval
[read_ts, read_ts + max_offset]: a value with a timestamp inside it
might have committed first in real time, so the read restarts above it.
CockroachDB code walk
pkg/util/hlc/hlc.go:38—type Clock: wall + logical, exactly ourHlc { l, c }. Read the comment at:42-47on howmaxOffsetis a promise the deployment makes, not a measurement.hlc.go:411—Now(): the send rule.hlc.go:471—Update(): the receive rule (every RPC response carries a timestamp; clocks gossip ambiently).:517—UpdateAndCheckMaxOffset: a remote timestamp too far ahead crashes the node rather than silently breaking the promise.pkg/kv/kvclient/kvcoord/txn_coord_sender.go:113—TxnCoordSender: the client-side coordinator, structured as a stack of interceptors.txn_interceptor_committer.go:128(txnCommitter, background at:55-83) — parallel commits: instead of prewrite-everything then commit (two sequential consensus rounds), CRDB writes a txn record inSTAGINGstate listing all in-flight writes and issues them in parallel. The txn is implicitly committed the instant all writes succeed; any observer can verify this and promote STAGING→COMMITTED (:195-205). This is Percolator’s any-reader-can-resolve idea applied to shave a latency round.txn_interceptor_pipeliner.go:311(SendLocked) — pipelining: don’t wait for a write’s consensus before issuing the next; track “in-flight” writes and prove them at commit. Parallel commits (:89-168comments) is the natural endpoint.
Questions to answer while reading
- Commit-wait sleeps ~2ε per read-write txn. Why does that not cap throughput (only latency)? What does it do to contended workloads, given locks are held through the wait?
- Derive why HLC’s
l <= max pt seenbound holds by induction over the send/recv rules — then find which rule breaks it if you replacemax(l, pt)withl+1(Lamport). - A CRDB read at ts=100 with max_offset=500 finds a value at ts=300. Walk through why ignoring it can violate real-time order, and why a value at ts=700 is safe to ignore.
- Parallel commits: a coordinator dies leaving a STAGING record. How does a reader decide commit vs abort, and what plays the role of Percolator’s “primary lock still held” test?
- Our
hlc.rstest asserts two silent nodes at the sameptproduce equal timestamps. Where does CRDB inject the tiebreak, and why is it fine for MVCC that two different keys’ writes tie? - M29 mapping: FalkorDB won’t have TrueTime. Between (a) a TSO à la TiKV’s PD and (b) HLC + uncertainty restarts, which fits a single-region graph store, and what changes if we go multi-region?
References
Papers
- Corbett et al. — “Spanner: Google’s Globally-Distributed Database” (OSDI 2012) — §1-4 carry the TrueTime and commit-wait ideas; the schema/evaluation sections are skimmable
- Kulkarni et al. — “Logical Physical Clocks” (OPODIS 2014) — the HLC paper; the send/recv rules and the bounded-drift theorem
Code
- cockroach
pkg/util/hlc/hlc.go,pkg/kv/kvclient/kvcoord/— the comment athlc.go:42-47on maxOffset-as-a-promise is the key design note
Topic 29 — Notes
Measured: the workload’s conflict probability (lane 1, provided)
100K transfers over 100K accounts, batches of 8, cargo run --release --bin txn_bench:
| zipf θ | batches containing a key collision |
|---|---|
| 0.5 | 0.3% |
| 0.9 | 29.9% |
| 1.1 | 86.2% |
| 1.3 | 99.6% |
The jump from θ=0.9 to 1.1 is the whole story: real workloads sit exactly where contention goes from “occasionally” to “usually”. Any protocol evaluated only at uniform keys is being evaluated on the easy case.
Predictions (before implementing the stubs)
| lane | prediction | reasoning |
|---|---|---|
| 2: Percolator abort rate θ=0.5 | < 0.5% | collisions are rare and txns are 2 keys |
| 2: abort rate θ=0.9 | ~5-10% | 29.9% of batches collide, but a batch is 8 txns and only pairwise overlaps abort; within-batch sequential execution here means only lock/conflict windows that persist (committed newer writes) count |
| 2: abort rate θ=1.3 | 30-60% | nearly every batch collides, often on the same hot head keys; each committed hot-key write Conflicts every later same-batch reader that took an older snapshot |
| 2: throughput | ~1M txn/s order, dropping mildly with θ | in-process HashMaps; aborts are cheap (locks cleaned eagerly) |
| 3: 2PC storm | committed ≈ 19.4K, crashed = 200, invariant holds | 1 crash per 100 txns × 20K; recovery every 4th crash leaves lock wreckage windows |
| 3: blocked_aborts | few hundred | between a crash and its recovery, θ=0.9 traffic keeps landing on the 2 locked keys |
Record actuals next to these after implementing percolator.rs / tpc.rs.
Things that surprised me while designing the experiments
- The blocking window is quantifiable: lane 3’s
blocked_abortscounts txns that aborted specifically because a dead coordinator’s locks were still staged. That number is the empirical cost of “participants cannot decide locally” — the sentence every textbook writes and no textbook measures. - Percolator needs no separate recovery procedure at all —
resolve_lockrun by any inconvenienced reader IS recovery. The tests for “crash after primary commit” and “crash before primary commit” are just reads. - HLC’s subtlety is not the max() rules but the bound:
lnever exceeds the largest physical time seen anywhere, so it can’t drift like a Lamport clock under message bursts. Thel_is_bounded_by_max_physical_time_seentest hammers 1000 messages through a node whose clock reads ~10 to prove it. - A duplicate commit in TiKV returns
Okon purpose (commit.rs:57’s match arm) — every step of Percolator must be idempotent because the client is the coordinator and clients retry.
Guide questions (work through per reading guide)
- reading-percolator-tikv.md — 6 questions (prewrite-fails-on-any-lock; atomicity substrate; TTL vs immediate Locked; Rollback records; abort-rate prediction; M29 snapshot reads)
- reading-spanner-hlc.md — 6 questions (commit-wait throughput; HLC bound induction; uncertainty interval; STAGING resolution; tiebreak; M29 TSO-vs-HLC)
- reading-calvin.md — 6 questions (deterministic locking; mid-txn node death; OLLP livelock; log-vs-WAL replication; sequencer lineage; M29 reconnaissance traversal)
- reading-foundationdb.md — 6 questions (durability point; false aborts; recovery-vs-decision-log; read-only serializability; ResolverBug vs crash points; M29 graph resolver)
Cross-topic threads
- Topic 15 (consensus): 2PC and Raft/Paxos are orthogonal — 2PC makes different shards atomic, consensus makes copies of one shard agree. Spanner’s fix for the blocking window is literally “run the coordinator on topic 15.”
- Topic 16 (DST): lane 3’s
CrashPointenum is a baby FDB simulator; ResolverBug.cpp shows the grown-up version injects wrong answers, not just crashes. - Topic 9 (MVCC): the write CF’s
(key, commit_ts) -> start_tsis xmin/xmax vocabulary — Percolator is Postgres snapshot rules with the counter moved to a TSO. - Topic 27 (IVM/CDC): TiKV resolved-ts (the min start_ts of open txns) is what makes a consistent changefeed cut possible — this topic’s locks are exactly what CDC must wait out.
- Topics 24/25: cross-shard pattern matching = distributed join over partitioned adjacency; the delta-join shapes apply once M29 shards the graph.
Capstone M29 log
- Shard by node id (hash). Node properties + outgoing adjacency co-located ⇒ single-shard for 1-hop writes; edges (u,v) with u,v on different shards are the 2PC/Percolator case — edge insert = prewrite {u’s adjacency, v’s in-adjacency}, primary = u’s side.
- Reads: traversals want a snapshot, not locks — Percolator-style
get-at-start_tsper shard suffices if all shards share a timestamp domain ⇒ start with a TSO (single-region assumption, à la PD); HLC + uncertainty is the multi-region upgrade path (reading-spanner-hlc.md Q6). - Contention profile for graphs: supernodes are the Zipf head — a hot node’s adjacency key will serialize all its edge inserts. Mitigation to explore: split adjacency into per-shard segments (write to your local segment; readers union) — turns a WW hotspot into a scatter-gather read.
- Crash matrix from lane 3 must become a test suite: every M29 protocol step gets a kill point, invariant = no dangling half-edges (the graph version of “money conserved”).
Infra notes
- Crate:
distributed-txn-experiments; kv.rs provided (3 column families, TSO, Zipf, 2-shard cluster), tpc.rs / percolator.rs / hlc.rs stubs. 4 provided tests pass; 14 stub tests fail astodo!()panics (grep -c "not yet implemented"= 14). - txn_bench lanes 2 and 3 are
catch_unwind-wrapped: they print[stub — implement …]until the stubs are done, then the bank invariant + atomicity asserts arm themselves.
Done when
-
tpc.rs: all 4 tests green — every crash point preserves atomicity, logged decisions roll forward, blocking window demonstrated. -
percolator.rs: all 5 tests green — snapshot reads repeatable, cross-shard atomic, no lock leaks, roll-forward/roll-back via primary. -
hlc.rs: all 5 tests green — monotonic under backward clocks,lbounded by max pt, causal chains ordered. - txn_bench full run: abort-rate table filled in above next to predictions; 2PC storm invariant holds; blocked_aborts recorded.
- Can explain, without notes: why the primary key’s commit is a commit point, what 2PC’s blocking window is and the three distinct escapes (replicate it / move it into data / delete runtime agreement), and why HLC needs an uncertainty interval where TrueTime needs a sleep.
Topic 30 — Time-Series Engines
Metrics workloads are the most regular data any database sees — and TSDBs are what you get when every design decision exploits that regularity: append-mostly, time-ordered, compress-by-predicting, partition-by-time, delete-by-dropping-partitions.
0. The shape of the problem
write path read path
~1M samples/s, tiny (ts, f64) points "cpu > 90 for job=api over 6h"
99.9% arrive in time order always a TIME RANGE
per-series arrival ~10s apart always a LABEL SELECTOR
usually an AGGREGATION
│ │
▼ ▼
amortize: batch into per-series index labels, not values:
compressed chunks (Gorilla), inverted index (name,value) -> series,
flush to immutable time-blocks then min/max-prune time blocks
Baseline measured here (see notes.md): the obvious codec — delta+varint timestamps, raw f64 values — lands at 11.00 B/sample regardless of value shape, because the 8-byte values dominate. The entire point of Gorilla’s XOR trick is attacking those 8 bytes.
1. The design space
graph TD
G["Gorilla (VLDB '15)<br/>in-memory cache: dod timestamps<br/>+ XOR floats = 1.37 B/sample"]
P["Prometheus TSDB<br/>Gorilla chunks + head/WAL +<br/>2h immutable blocks +<br/>MemPostings label index"]
VM["VictoriaMetrics<br/>same shapes, LSM-ier:<br/>nearest-delta2 + partitions +<br/>tagFilters cache"]
IOX["InfluxDB 3 / IOx<br/>TSDB rebuilt on topic 28:<br/>WAL -> Arrow buffer -> Parquet<br/>on object storage, DataFusion SQL"]
M["Monarch (VLDB '20)<br/>planet-scale: in-memory,<br/>push-based, query pushdown"]
B["BtrDB (FAST '16)<br/>nanosecond telemetry: tree of<br/>time-partitioned AGGREGATES,<br/>queries hit precomputed summaries"]
G -->|"chunk format"| P
G -->|"chunk format"| VM
P -->|"columnar + object store"| IOX
G -->|"scale out"| M
G -.->|"different regime:<br/>100M Hz telemetry"| B
2. The one-table summary
| system | value codec | time organization | label index | out-of-order |
|---|---|---|---|---|
| Gorilla | XOR floats, dod ts | 2h in-memory blocks | (delegated to HBase layer) | rejected |
| Prometheus | same, xor.go | head + 2h blocks, exponential compaction | MemPostings inverted index | bounded OOO window, separate buffer |
| VictoriaMetrics | nearest-delta2 + optional lossy precisionBits | monthly partitions of LSM parts | index_db + tagFilters cache | buffered in raw-rows shards |
| InfluxDB 3 | Parquet encodings | WAL → buffer → Parquet files by time | catalog + last/distinct caches | absorbed by sort at persist |
| BtrDB | delta tree | time-partitioned octree-ish tree | (few, fat streams) | version-annotated inserts |
3. Experiments (experiments/)
cargo run --release --bin tsdb_bench
| file | what | status |
|---|---|---|
gen.rs | scrape timestamps + gauge/counter/constant/random shapes, OOO arrivals, label sets | PROVIDED |
bits.rs | MSB-first BitWriter/BitReader + sign_extend | PROVIDED |
baseline.rs | zigzag varint delta codec — the thing to beat, measured at 11.00 B/sample | PROVIDED |
gorilla.rs | dod timestamp buckets + XOR-float codec | STUB |
head.rs | in-order fast path + bounded OOO window + LWW merge flush | STUB |
index.rs | MemPostings-style (name,value)→series inverted index + k-way intersect | STUB |
bin/tsdb_bench.rs | baselines (provided) + gorilla ratios + OOO tax sweep + selector latency | lanes |
Contract highlights: gorilla roundtrip must be bit-exact for every f64; constant series ≤ ~2 bits/sample while full-entropy values must fail to compress (>8 B/sample — the codec wins on regularity, not magic); head rejects samples older than the OOO window and flush output is sorted so it feeds the in-order-only encoder; index intersection matches brute force and the unique-per-series label demonstrably explodes the postings map (the cardinality bomb, counted).
4. Reading guides
- reading-gorilla.md — Gorilla: compress by predicting
- reading-prometheus-tsdb.md — Prometheus TSDB: an LSM with time as the key
- reading-victoriametrics-influx.md — VictoriaMetrics & InfluxDB 3: two rebuttals to Prometheus
- reading-monarch-btrdb.md — Monarch & BtrDB: the extremes that bracket the middle
5. Cross-topic threads
- Topic 4 (LSM): a TSDB is an LSM where the key is time — head=memtable, blocks=SSTs, compaction merges by time range, retention = drop the oldest level. VictoriaMetrics says “parts” and means it literally.
- Topic 12 (columnar): IOx is the thesis that a TSDB is just a columnar store with a time-partitioned catalog — Parquet + DataFusion replace the custom chunk format.
- Topic 23 (FTS): MemPostings IS an inverted index; label selectors are boolean term queries; high cardinality = unbounded vocabulary.
- Topic 28 (cloud-native): InfluxDB 3’s WAL→object-store pipeline is topic 28’s landing-zone lesson applied to metrics.
- Topic 29: the OOO window is a watermark — the same bounded-disorder-then-seal move as streaming watermarks (topic 27).
6. Capstone M30 (FalkorDB)
Temporal graph: edge/property history as (entity, attribute, ts) series;
MATCH ... AT TIME t = snapshot read at t over history chunks. Design
notes in notes.md §M-log.
Gorilla: compress by predicting
The 8-byte f64 value dominates every naive metrics codec — Gorilla’s XOR
trick is the attack on those 8 bytes, and it’s the chunk format inside
essentially every modern TSDB. This chapter reads the VLDB ’15 paper’s
§4.1 against prometheus’s tsdb/chunkenc/xor.go, the most-deployed
reimplementation, and is the spec for our gorilla.rs stub.
Why it worked
Facebook’s observation: 96% of their timestamps arrive at a fixed interval, and consecutive metric values usually share sign, exponent, and most of the mantissa. Both facts turn into prediction: encode only the error against a trivial predictor.
timestamps: predictor = "same delta as last time"
t=1000, 1010, 1020, 1030, 1029, 1040 (10s scrape, 1ms jitter)
deltas: 10, 10, 10, 9, 11
delta-of-delta: 0, 0, -1, 2 <- mostly ZERO -> mostly 1 bit
values: predictor = "same value as last time"
v XOR prev: 0x0000000000000000 (unchanged -> 1 bit)
0x0000000FE1000000 (close -> short run of
^^^^^^^ ^^^^^^ meaningful bits in the
leading trailing middle -> store just those)
zeros zeros
Result on Facebook’s production data: 1.37 bytes/sample vs 16 raw. Our bench measures the honest version per workload shape — including the full-entropy series where XOR must fail (>8 B/sample), because the codec exploits regularity, not information theory.
The bit format (what your gorilla.rs stub implements)
| dod range | prefix | payload |
|---|---|---|
| 0 | 0 | — |
| [-63, 64] | 10 | 7 bits |
| [-255, 256] | 110 | 9 bits |
| [-2047, 2048] | 1110 | 12 bits |
| else | 1111 | 32 bits (paper; we use 64 for ms robustness) |
Values: 0 = identical; 10 = meaningful bits fit the previous
(leading, trailing) window, store only the middle; 11 = new window:
5-bit leading-zero count + 6-bit length + the bits. The 6-bit length
stores 64 as 0 — the classic off-by-one everyone reimplements.
Both halves of the append path are “encode the prediction error”:
#![allow(unused)]
fn main() {
fn append(&mut self, t: i64, v: f64) {
let dod = (t - self.t_prev) - self.delta_prev; // error vs "same delta as last time"
match dod { // smaller error => fewer bits
0 => self.w.bits(0b0, 1),
-63..=64 => { self.w.bits(0b10, 2); self.w.bits(dod as u64, 7); }
-255..=256 => { self.w.bits(0b110, 3); self.w.bits(dod as u64, 9); }
-2047..=2048 => { self.w.bits(0b1110, 4); self.w.bits(dod as u64, 12); }
_ => { self.w.bits(0b1111, 4); self.w.bits(dod as u64, 64); }
}
let xor = v.to_bits() ^ self.v_prev.to_bits(); // error vs "same value as last time"
if xor == 0 { self.w.bits(0b0, 1); }
else { self.write_vdelta(xor); } // '10': reuse prev (leading,trailing) window;
// '11': 5-bit leading + 6-bit len + middle bits
self.delta_prev = t - self.t_prev;
self.t_prev = t; self.v_prev = v;
}
}
prometheus xor.go, line by line
xorAppender.Append(xor.go:161) — the whole timestamp path. Note prometheus’s buckets differ: 14/17/20/64 bits (:195-208) because scrape intervals up to minutes with ms timestamps produce bigger dods than Gorilla’s 60s-max regime. Same idea, retuned constants — bucket boundaries are a workload parameter, not a law.writeVDelta(:226) — the XOR path, with the leading/trailing window reuse.- The iterator (
:357-396) — decode is a mirror-image state machine;it.tDelta = uint64(int64(it.tDelta) + dod)(:396) is the entire “prediction + error” model in one line. - Note what’s absent: no random access. A Gorilla chunk decodes front-to-back only — fine, because queries always scan time ranges, and chunks are capped (~120 samples) so seeking costs one chunk.
Questions to answer while reading
- Why does the timestamp scheme store delta-of-delta but the value scheme store plain XOR (delta-of-value, in a sense) — what property of each stream makes second-order prediction pay for one but not the other?
- The
10value branch reuses the previous (leading, trailing) window even when the current XOR would fit a tighter one. What does that trade, and why does the encoder still emit11sometimes on purpose? - Chunks are capped at ~120 samples in prometheus. Derive the two pressures that set that number (decode-on-read cost vs per-chunk header amortization).
- Counters are monotone integers stored as f64. Why does XOR do worse on a fast counter than on a noisy gauge of similar magnitude — and what do delta-encoding-the-value schemes (VictoriaMetrics nearest_delta2) exploit that XOR can’t?
- Your
random_values_hit_the_entropy_floortest demands >8 B/sample. Where exactly do the extra ~1.6 bytes over raw come from? Count the control bits. - M30 mapping: property history in FalkorDB is (entity, property, ts) → value where values are often strings/ids, not floats. Which half of Gorilla survives (dod timestamps) and what replaces XOR for non-numeric payloads?
References
Papers
- Pelkonen et al. — “Gorilla: A Fast, Scalable, In-Memory Time Series Database” (VLDB 2015) — §4.1 is the codec and the reason to read it; §3 and §5 are the ops war stories
Code
- prometheus
tsdb/chunkenc/xor.go— the most-deployed reimplementation of §4.1; note the retuned dod buckets (14/17/20/64 bits) vs the paper’s
Monarch & BtrDB: the extremes that bracket the middle
Two design points far outside the Gorilla/Prometheus mainstream, read for what they prove is possible: Monarch shows what monitoring looks like when it must not depend on anything it monitors (planet-scale, memory-first, push-based), and BtrDB shows what happens when the index is the downsampler (query cost proportional to pixels, not samples). Paper-only chapter — there are no repo clones here.
Monarch: what breaks at planetary scale
Monarch monitors Google — including the storage systems a durable TSDB would depend on. That circularity forces the defining choice: memory first, durability traded down (logged lazily, queries don’t wait for it). A monitoring system that’s down when Bigtable is down is worthless.
global query layer (query pushdown, hierarchical)
┌────────────┬────────────┐
zone A │ zone B │ zone C │ <- autonomous per zone:
leaves │ leaves │ leaves │ ingest keeps working
(RAM) │ (RAM) │ (RAM) │ through partitions
The ideas worth stealing at any scale:
- Push, not pull: targets stream to leaves; a scraper (prometheus) owns the timestamp regularity Gorilla needs, a push system must cope with what arrives. (Note prometheus is pull for exactly this reason.)
- Typed schemas over string labels: Monarch series have typed fields and distribution values (histograms as first-class values) — the cure for the label-cardinality bomb is schema, not more index.
- Query pushdown: aggregation executes at the leaves; the hierarchy ships partial aggregates, not samples. topic 13’s push-the-computation-to-the-data at monitoring scale.
BtrDB: the aggregate tree (a genuinely different idea)
Regime: power-grid synchrophasors — 100M+ samples/s/stream, nanosecond timestamps, queries like “plot 3 years at screen resolution” that touch every sample if evaluated naively.
root: [t0, t0+2^62) ns
┌ min/mean/max/count ┐ each node: 64 children,
child │ min/mean/max/count │ child each holding STATISTICAL
└ ... 64-way fanout ─┘ SUMMARIES of its subtree
...
leaves: the raw samples
-
A time range at resolution r needs only the tree level whose node span ≈ r: query cost ∝ pixels, not samples. Downsampling isn’t a batch job (prometheus recording rules, VM downsampling) — it’s the index structure itself, always current:
#![allow(unused)] fn main() { // Descend only until a node's span fits under the requested resolution. fn query(node: &Node, range: TimeRange, res_ns: u64, out: &mut Vec<Stats>) { for child in node.children_overlapping(range) { if child.span_ns() <= res_ns { out.push(child.stats); // precomputed min/mean/max/count — } else { // never touch the raw samples query(child, range, res_ns, out); // one of 64 ways, O(log₆₄ depth) } } } } -
Copy-on-write versioning: every insert creates a new root (topic 3’s CoW B-tree); out-of-order and corrections are just versions, and changed-ranges between versions are computable — IVM-friendly (topic 27).
-
The “obviously wasteful” 1.5× space for summaries buys O(log n) any-resolution reads. Compare: Gorilla optimizes bytes/sample, BtrDB optimizes bytes read per query — different objective, different tree.
Questions to answer while reading
- Monarch chose RAM + lazy logs; Gorilla chose RAM + HBase behind. Both are “monitoring must not depend on what it monitors.” What queries does Monarch give up that a durable TSDB answers (hint: long-range historical joins)?
- Distribution-typed values change the cardinality equation: a latency histogram is ONE series in Monarch but ~10 (le buckets) in prometheus. What does each choice cost at query time (quantile computation)?
- Derive BtrDB’s query cost for “mean over [a,b] at 1000 points” — show it’s O(1000 · log₆₄(range/resolution)) and independent of sample count.
- BtrDB’s CoW versions make OOO inserts cheap-ish. Why does the same trick NOT rescue prometheus-shaped workloads (hint: series count — one tree per stream at 10M streams)?
- Both papers reject the label-selector data model (Monarch: schemas; BtrDB: few fat streams + external metadata). Argue which parts of the prometheus model are essential vs incidental for infrastructure monitoring.
- M30 mapping:
MATCH ... AT TIME tneeds point-in-time; but “how did this subgraph evolve” wants BtrDB-style multi-resolution over edge churn (edges-added-per-hour rollups). Sketch where an aggregate tree over the M27 changelog would live in FalkorDB.
References
Papers
- Adams et al. — “Monarch: Google’s Planet-Scale In-Memory Time Series Database” (VLDB 2020) — §1-3 for the memory-first/push/schema choices; the query pushdown section pairs with topic 13
- Andersen & Culler — “BTrDB: Optimizing Storage System Design for Timeseries Processing” (FAST 2016) — short and dense; the aggregate tree and CoW versioning are the whole paper
Code
- No repo clones — read both papers for the design points that bracket the Gorilla/Prometheus middle
Prometheus TSDB: an LSM with time as the key
Every TSDB concept — head, WAL, immutable time blocks, label index,
bounded out-of-order — exists in prometheus/tsdb/ in a form you can
read in an afternoon, which makes it the best single codebase for this
topic. Its design-doc lineage (Fabian Reinartz’s “Writing a Time Series
Database from Scratch”) explains every choice. Read it as topic 4’s LSM
wearing a metrics costume, and as the reference for our head.rs and
index.rs stubs.
The architecture
scrape ──► Head (in-memory, ~3h) disk
┌──────────────────────┐ ┌─────────────────────────┐
│ memSeries per series │ cut │ 2h Block (immutable) │
│ └ Gorilla chunks │ ────► │ ├ chunks/ (the data) │
│ WAL (crash recovery) │ │ ├ index (postings + │
│ MemPostings │ │ │ series) │
│ OOO buffer (window) │ │ └ meta.json min/max t │
└──────────────────────┘ └─────────────────────────┘
compaction: 2h -> 6h -> 18h…
retention: DELETE = rm -r block
It’s topic 4’s LSM with time as the key: head = memtable, WAL = WAL, blocks = SSTs sorted/partitioned by time, compaction merges adjacent time ranges, and retention is dropping the oldest “level” — the cheapest delete in databases.
Code walk
head.go:71—type Head: the memtable. Series are keyed by a hash of the label set; eachmemSeriesowns its chunk chain. Chunks cut at ~120 samples (see reading-gorilla.md).head_append.go:436—Append: the hot path. In-order → straight into the series’ open chunk.:481returnsstorage.ErrOutOfOrderSample; the decision ladder at:688-693distinguishesErrTooOldSample(outside the OOO window — refused) from in-window OOO. This is exactly yourhead.rscontract.head.go:168—OutOfOrderTimeWindow: OOO support is opt-in and bounded.ooo_head.gokeeps OOO samples in separate chunks merged at query/compaction time — disorder is quarantined so the in-order path never pays for it.index/postings.go:60—MemPostings:map[label name]map[value][]seriesID, sorted ids.Add(:403) appends under lock. Selector evaluation = sorted-list intersection — yourindex.rs, and topic 23’s inverted index with labels as terms.db.go:56—DefaultBlockDuration = 2h, andcompact.go:41ExponentialBlockRanges: blocks merge into exponentially larger time ranges. Compare topic 4’s size-tiered compaction — same math, time units instead of bytes.wal.go/head_wal.go— WAL records are (series, samples) batches; crash recovery replays into the head. Checkpointing truncates the WAL once a block is cut — topic 5’s story verbatim.
The ingestion contract, condensed from head_append.go’s decision
ladder (this is exactly what head.rs implements):
#![allow(unused)]
fn main() {
fn append(&mut self, series: SeriesId, t: i64, v: f64) -> Result<()> {
let s = self.series.get_mut(series);
if t >= s.max_time() {
self.wal.log(series, t, v); // durability first
return s.open_chunk().push(t, v); // in-order fast path: the 99.9%
}
if t < s.max_time() - self.ooo_window {
return Err(TooOldSample); // beyond the watermark: refused
}
self.wal.log(series, t, v);
s.ooo_chunks.insert(t, v) // disorder is QUARANTINED — merged
} // at query/compaction time, so the
// in-order path never pays for it
}
Where it hurts (the famous failure modes)
- High cardinality: every unique label set is a new series — a new
memSeries, new postings entries, new index rows in every block. A
user_idlabel turns 1 metric into 10M series. Yourcardinality_bomb_is_visibletest counts this directly. - Churn: rolling deployments replace
podlabel values; old series linger in the head + index until truncation. Cardinality over time hurts even when instantaneous cardinality is fine.
Questions to answer while reading
- Why can prometheus get away with one WAL for all series (no per-series ordering issue), while the chunks must be strictly per-series?
- The head holds ~3h but blocks are 2h. Walk through why the overlap exists (what happens to samples arriving during a block cut?).
- MemPostings intersects sorted id lists. Prometheus also keeps a
special all-postings key. Derive when
job=~".+"(match-everything) is served by that key vs when a regex forces value-by-value expansion — and what that costs at 10M series. - OOO chunks are merged at read time before compaction folds them in.
What does a query over the OOO window pay, and why is that acceptable?
(Compare our
flush-time merge — we pay at flush instead.) - Retention deletes whole blocks. What query-visible anomaly can that create near the retention boundary, and why is it tolerated?
- M30 mapping: FalkorDB property history needs per-entity chunks like memSeries. What is the analogue of the label index — and does graph topology (adjacency) belong in the “labels” (indexed dimensions) or in the “values” (payload)?
References
Papers
- Fabian Reinartz — “Writing a Time Series Database from Scratch” (design doc / blog, 2017) — the rationale behind every structure in the code walk; read it first if the layout feels arbitrary
Code
- prometheus
tsdb/— start athead.go,head_append.go,index/postings.go,compact.go; the whole engine is an afternoon of Go
VictoriaMetrics & InfluxDB 3: two rebuttals to Prometheus
Same problem as prometheus, two opposite bets. VictoriaMetrics doubles down on a custom LSM — tighter codecs, explicit parts and merges, its own format end to end. InfluxDB 3 (the productized IOx, in Rust) deletes the custom engine entirely and rebuilds on Parquet + object storage — topic 28’s stack wearing a TSDB hat. Reading them together shows which parts of a TSDB are essential and which are just a storage engine.
VictoriaMetrics: the LSM said out loud
ingest ──► rawRows shards (per-CPU) ──convert──► parts (immutable)
partition.go:75, :72 merge workers compact
8MB in-memory buffers parts within a PARTITION
partitions are MONTHLY directories
retention = drop old partitions (table.go:131)
-
lib/storage/partition.go:75—type partition: rawRows buffered per CPU (:46), converted to sorted immutable parts in the background. Explicitly the LSM vocabulary prometheus hides: parts, merges, levels. -
lib/encoding/nearest_delta2.go:15— the value codec: delta-of-delta as int64s + varint batches (values are first scaled to integers via decimal.go). Contrast Gorilla: byte-aligned varints, batch-friendly, SIMD-able — andprecisionBitsmakes it optionally lossy (drop mantissa bits below the precision you care about). Gorilla is exact; VM lets you buy ratio with honesty about float noise. The shape:#![allow(unused)] fn main() { // floats already scaled to i64 via decimal encoding fn nearest_delta2(vals: &[i64], precision_bits: u8, out: &mut Vec<u8>) { let (mut prev, mut prev_delta) = (vals[0], 0i64); for &v in &vals[1..] { let delta = v - prev; let dod = delta - prev_delta; // same predictor as Gorilla… let dod = trim_precision(dod, precision_bits); // …but LOSSY on purpose: out.extend(zigzag_varint(dod)); // drop bits below the noise floor prev_delta = delta; prev = v; // byte-aligned varints, not a } // bitstream => batch/SIMD friendly } } -
lib/storage/index_db.go:124— tagFilters→metricIDs cache in front of the label index: selector evaluation is expensive enough at VM’s cardinality targets to warrant a query-shaped cache, invalidated on new-series registration. -
lib/storage/dedup.go— dedup at scrape-interval granularity during merges: OOO and duplicate handling folded into compaction, not the hot path — same quarantine philosophy as prometheus, different location.
InfluxDB 3 / IOx: the TSDB dissolves into topic 28
write ──► WAL (object store) ──snapshot──► Parquet files (object store)
influxdb3_wal/src/lib.rs:75-98 sorted, time-partitioned
│ catalog tracks file min/max t
▼
QueryableBuffer (Arrow, in-memory)
influxdb3_write/src/write_buffer/queryable_buffer.rs:41
serves recent data; DataFusion executes SQL over buffer+Parquet
influxdb3_wal/src/lib.rs:75-98— the WAL flushes on a period; theSnapshotTrackerdecides when accumulated WAL periods become a Parquet snapshot. The landing-zone pattern from topic 28: durable-fast first, columnar-later.queryable_buffer.rs:41—QueryableBuffer: the head block, but it’s Arrow record batches, and “flush” means write Parquet + update catalog, with an optionalParquetCacheOracle(:49) prewarming the read cache — topic 28’s cache-fixes-the-median.- Out-of-order: absorbed by sorting at snapshot time — the buffer accepts disorder, Parquet files come out time-sorted. Late data past a snapshot lands in new files that overlap old time ranges; the query layer merges (and compaction later rewrites).
- The bet: Parquet’s general-purpose encodings (delta, dictionary, zstd)
- pruning-by-min/max-stats are close enough to Gorilla, and in exchange every SQL engine on earth can read your history directly.
The trade, in one table
| VictoriaMetrics | InfluxDB 3 | |
|---|---|---|
| codec | custom, tighter, optionally lossy | Parquet, standard, good enough |
| storage | local disks it manages | object store (topic 28 economics) |
| query | PromQL-compatible engine | SQL via DataFusion |
| ecosystem | its own format, its own tools | anything that reads Parquet |
| bet | vertical integration wins on cost | commodity formats win on leverage |
Questions to answer while reading
- VM scales floats to int64 via decimal encoding before delta2. What
float values break that (hint: mixed magnitudes in one block), and how
does
precisionBitspaper over it? - Monthly partitions (VM) vs 2h blocks (prometheus): derive how each choice follows from the retention story each system sells.
- The tagFilters cache is invalidated by new series. Why is that invalidation the high-churn failure mode, and what does it share with topic 8’s plan-cache invalidation?
- IOx: a query for the last 5 minutes must see WAL-buffered data not yet in Parquet. Trace which component serves it and what the consistency story is between buffer and files during a snapshot.
- Parquet delta + dictionary + zstd vs Gorilla on a gauge: predict the ratio gap, then reconcile with the fact that IOx sorts by (series, time) before writing — how much of Gorilla’s win was really sorting?
- M30 mapping: FalkorDB’s history could be custom chunks (VM-style) or
Parquet-on-object-store (IOx-style, M28 already built the substrate).
Which do you pick for
MATCH ... AT TIME tand why does the answer differ for hot recent history vs year-old history?
References
Papers
- None — both systems are documented in code and blog posts rather than papers; the IOx design discussions on the InfluxData blog are the closest thing to a paper for the Parquet bet
Code
- VictoriaMetrics
(Go) —
lib/storage/partition.go,lib/encoding/nearest_delta2.go,lib/storage/index_db.go,lib/storage/dedup.go - influxdb (Rust — the repo
is InfluxDB 3, the productized IOx) —
influxdb3_wal/src/lib.rs,influxdb3_write/src/write_buffer/queryable_buffer.rs
Topic 30 — Notes
Measured: the baseline to beat (lane 1, provided)
1M samples, 10s scrape interval ±100ms jitter, cargo run --release --bin tsdb_bench:
| shape | delta+varint B/sample | decode Msamples/s |
|---|---|---|
| constant | 11.00 | 292 |
| gauge | 11.00 | 268 |
| counter | 11.00 | 303 |
| random | 11.00 | 333 |
The flat 11.00 is the finding: timestamps compress to ~3 B (varint of jittered deltas) but the raw 8-byte values dominate and are shape-blind. Whatever the codec does about timestamps is a rounding error until it attacks the value bytes — hence XOR floats.
Predictions (before implementing the stubs)
| lane | prediction | reasoning |
|---|---|---|
| gorilla constant | ~0.3 B/sample | steady state 1+1 bits, jitter pushes some ts to 9-bit bucket |
| gorilla gauge | 3-5 B/sample | random walk: XOR shares exponent/sign, mantissa noise costs ~30-45 meaningful bits |
| gorilla counter | 4-6 B/sample | value changes every sample by a varying integer — XOR of shifting mantissas is wide |
| gorilla random | 9-10 B/sample | entropy floor + control-bit overhead (test demands >8) |
| gorilla decode | 100-200 Msamples/s | bit-at-a-time reader; slower than the byte-aligned varint baseline’s ~300 |
| ooo tax 0%→50% | ingest barely moves; flush grows ~k log k | append is O(1) either path; sort of the OOO fraction dominates flush |
| index intersect hot∧rare | <1 µs | shortest-list-first makes the unique instance label do all the work |
| index build 100K series | tens of ms | 400K postings pushes into a HashMap |
Record actuals next to these after implementing.
Things that surprised me while designing the experiments
- The delta+varint baseline decoding at ~300 Msamples/s is fast — byte-aligned codecs have a real throughput edge over bit-packed Gorilla. Production systems know this: VM chose varint batches (nearest_delta2), and Parquet’s encodings are byte/word-aligned. The ratio-vs-decode-speed trade is the actual design axis, not just ratio.
- Prometheus’s dod buckets (14/17/20 bits) differ from the paper’s (7/9/12) — bucket boundaries are workload parameters. Encoding the paper’s table verbatim into a test would have been wrong; the tests pin roundtrip + ratio bounds instead.
- The OOO design is the same watermark idea as topic 27’s streaming: bounded disorder, quarantined buffer, merge at seal time, refuse-too-late. TSDBs and stream processors converged independently.
ErrTooOldSample— a database that refuses writes by policy is rare; the alternative (resort history forever) is worse. Good API honesty.
Guide questions (work through per reading guide)
- reading-gorilla.md — 6 questions (dod-vs-xor asymmetry; window reuse trade; 120-sample chunks; counters vs gauges; entropy-floor bit accounting; M30 non-numeric payloads)
- reading-prometheus-tsdb.md — 6 questions (one-WAL-many-series; head/block overlap; regex postings; OOO read cost; retention anomaly; M30 label-vs-payload for adjacency)
- reading-victoriametrics-influx.md — 6 questions (decimal scaling breakage; partition sizing; tagFilters invalidation; buffer/Parquet consistency; how-much-was-sorting; M30 custom-chunks vs Parquet)
- reading-monarch-btrdb.md — 6 questions (durability trade; distribution values; BtrDB cost derivation; CoW at 10M streams; essential-vs-incidental labels; M30 aggregate tree over changelog)
Cross-topic threads
- Topic 4: TSDB = LSM keyed by time; retention = drop the oldest level — the cheapest delete in databases.
- Topic 5: prometheus head WAL + checkpoint-on-block-cut is the WAL lifecycle verbatim.
- Topic 12/28: InfluxDB 3 dissolves the TSDB into Parquet + object store + DataFusion — a columnar store with a time-partitioned catalog.
- Topic 23: MemPostings is an inverted index; high cardinality = unbounded vocabulary; selector = boolean term query.
- Topic 27: OOO window = watermark; BtrDB changed-ranges = IVM input.
Capstone M30 log
- Temporal graph = history chunks per (entity, attribute): edge existence intervals [added_ts, removed_ts) + property value series. Entity id is the series key; the M23/M26 index infrastructure serves the “label selector” role over entity properties.
MATCH ... AT TIME t: snapshot = for each touched entity, latest history record ≤ t — exactlylatest_write_beforefrom topic 29’s kv.rs, so M29’s MVCC read path generalizes to time-travel if commit_ts is wall-clock-ish (HLC helps here).- Storage split by age (the VM-vs-IOx question resolved per tier): hot recent history in delta-matrix-adjacent chunks (custom, fast), cold history as Parquet on object store via M28 — the same data ages through formats.
- Gorilla dod survives for timestamp columns of the changelog; property values need dictionary + RLE instead of XOR (mostly non-float).
- Rollups: edges-added-per-interval aggregate tree (BtrDB-shaped) over the M27 changelog enables “graph evolution” dashboards without scanning history.
Infra notes
- Crate:
timeseries-experiments; gen/bits/baseline PROVIDED (7 tests pass), gorilla.rs / head.rs / index.rs stubs — 15 tests fail astodo!()panics. - tsdb_bench lane 1 always prints (numbers above); lanes 2-4 armed behind catch_unwind until the stubs are implemented.
Done when
-
gorilla.rs: all 6 tests green — bit-exact roundtrip incl. NaN patterns and bucket edges, constant ≤ ~2 bits/sample, gauge beats raw 3×, random fails to compress (>8 B/sample). -
head.rs: all 4 tests green — window boundaries exact, TooOld never stored, flush sorted + LWW, output feeds the encoder. -
index.rs: all 5 tests green — brute-force match, sorted results, hot∧rare narrows to one, cardinality bomb counted. - tsdb_bench full run: prediction table above filled with actuals; the ratio-vs-decode-speed trade quantified against the baseline.
- Can explain, without notes: why 11.00 B/sample is shape-blind and what XOR does about it; why OOO gets a bounded window instead of either extreme (reject all / absorb all); why high cardinality is an index problem, not a data-volume problem.
Topic 31 — CRDTs & Multi-Master Replication
The last topic, and the mirror image of topic 15 (Raft). Consensus says: agree on an order, then apply. CRDTs say: design the data so order doesn’t matter, then never coordinate. Both give you replicas that agree; they pay for it in opposite currencies — latency vs. semantics.
The shape of the problem
consensus (topic 15) CRDTs (this topic)
──────────────────── ──────────────────
write ──► leader ──► quorum ──► ok write ──► local apply ──► ok
│ 1 RTT minimum │ 0 RTT
▼ ▼
one total order, one truth gossip later; merge() must make
unavailable in minority partition ANY delivery order converge
available under ANY partition
Strong Eventual Consistency (SEC): replicas that have received the same set of updates are in the same state — no matter the order received. That’s a theorem you get for free if state forms a join semilattice (merge = least upper bound: associative, commutative, idempotent).
graph TD
subgraph "join semilattice: merge is the join"
A["A: {x:5}"] --> AB["A⊔B: {x:5, y:7}"]
B["B: {y:7}"] --> AB
AB --> ABC["A⊔B⊔C — same no matter the path"]
C["C: {x:2}"] --> AC["A⊔C: {x:5}"]
AC --> ABC
end
Two delivery models
| state-based (CvRDT) | op-based (CmRDT) | |
|---|---|---|
| ship | whole state (or delta) | individual operations |
| network needs | nothing — any gossip, any dupes | causal delivery, exactly-once (or idempotent ops) |
| merge | join of semilattice | apply op; concurrent ops must commute |
| in this crate | counter.rs, orset.rs, lww.rs, graph.rs | rga.rs (Insert/Delete ops) |
| in the wild | Riak, Redis Enterprise CRDTs | Yjs, automerge, loro updates |
The zoo you build (src/)
| file | CRDT | the one idea | status |
|---|---|---|---|
clock.rs | VClock + Dot | (replica, counter) names every event; pointwise-max join; partial_cmp → None defines “concurrent” | PROVIDED |
lww.rs | LWW register/map | total order by (ts, replica) — converges by discarding concurrent writes | PROVIDED |
counter.rs | G/PN-Counter | per-replica slots + pointwise max; PN = two G-Counters because signed max isn’t a join | stub |
orset.rs | add-wins OR-Set | adds tag fresh dots; remove kills only observed dots → concurrent add survives | stub |
rga.rs | RGA sequence | identity (Dot) not index; insert-after-parent; skip larger-id siblings; tombstones | stub |
graph.rs | graph CRDT | OR-Set nodes + OR-Set edges + LWW props; dangling edges hidden, not deleted | stub |
LWW’s lie, measured (bench lane 1, provided — runs today)
Two replicas, 20K writes each, LWW map, varying keyspace and sync interval. “Lost” = a write some user made that no replica remembers after merge:
keys sync_every lost%
10 1 94.98% ← hot keys + constant sync: LWW is a coin flip
1000 100 88.34%
100000 10000 12.45% ← even huge keyspace + rare sync loses 1 in 8
This is why “we’re eventually consistent” without saying how conflicts resolve is not a semantics. The OR-Set and counters exist to make these writes survive instead.
Sequence CRDTs: where the real engineering is
insert 'X' after 'a' (parent = a's dot):
a ──► c a ──► X ──► c concurrent 'Y' same parent:
integrate: tombstone ok: a ──► Y ──► X ──► c
walk after a, deleted elems (larger (counter,replica)
skip larger-id still anchor sits closer to parent —
siblings children both replicas agree)
Interleaving is the dragon: naive RGA can interleave two users’ typed
words character-by-character. Fugue (Loro’s algorithm) fixes this with
left+right origins — see reading-sequence-crdts.md.
Code reading (all cloned under ~/repos)
| repo | anchor | what to see |
|---|---|---|
| automerge | rust/automerge/src/clock.rs:109, :145 | our clock.rs, industrial: covers(), the partial order |
| automerge | rust/automerge/src/op_set2/op.rs:52 | succ — deletion as successor ops, not flags |
| yrs (Yjs) | yrs/src/block.rs:160, :1302, :1415 | ID = our Dot; Item = our Element; integrate() = the rule you implement in rga.rs |
| diamond-types | src/listmerge/merge.rs:142, yjsspan.rs:29 | same integrate, but ops in a run-length time DAG; NOT_INSERTED_YET spans |
| loro | crates/loro-internal/src/{dag,diff_calc,handler} | Fugue + fractional_index + generic-btree crates |
| cr-sqlite | core/rs/core/src/local_writes/mod.rs:83-133 | LWW-per-column over SQLite; db_version bookkeeping — multi-master as an extension |
Reading guides
- reading-shapiro-crdts.md — CRDT foundations: convergence without coordination.
- reading-kleppmann-json-crdts.md — JSON CRDTs & the move op: identity beats paths.
- reading-sequence-crdts.md — Sequence CRDTs: what a decade of engineering does to RGA.
- reading-cr-sqlite.md — cr-sqlite: a real database goes multi-master.
Experiments
cd experiments
cargo test # 6 provided tests pass; 18 fix the contract for your stubs
cargo run --release --bin crdt_bench
Bench lanes: 1 = LWW’s lie (provided). 2 = OR-Set convergence storm + tombstone census. 3 = RGA editing trace (throughput, tombstone bloat). 4 = graph dangling storm (hidden edges, resurrection).
Exercises
- Implement the four stubs until all tests pass and lanes 2-4 print.
- automerge vs loro bench (from PLAN; needs deps beyond this crate’s
convention, so it’s an exercise): load both crates in a scratch project,
replay an editing trace (diamond-types repo ships some under
benchmark_data/), compare apply time + memory. - Delta-CRDTs: lane 1’s
sync_every=1cost is O(writes × map size) because state-based sync ships everything. Sketch the delta-mutator version of your OR-Set. - Garbage: your OR-Set keeps tombstones forever. What’s the causal stability condition that lets you drop one? Who tracks it?
- Alternative dangling-edge policies: cascade-delete (remove observed edge-dots when a node dies) vs. our hide-not-delete. Which breaks add-wins symmetry? Which would FalkorDB users expect?
- Props keyed by node id survive remove/re-add (our choice). Automerge keys object state by creation op, so re-add = fresh object. When is each right?
Cross-topic threads
- Topic 15 (Raft): same problem, opposite trade. M31 asks you to run the same workload against both and compare write latency + conflict semantics.
- Topic 29 (HLC): LWW needs timestamps that respect causality —
that’s the HLC from
29-distributed-txn/reading-spanner-hlc.md. cr-sqlite’s db_version is a Lamport clock in the same spirit. - Topic 5 (MVCC): tombstones-as-versions; RGA’s deleted elements are MVCC’s dead versions, and both need a GC horizon (causal stability ↔ oldest active snapshot).
- Topic 26 (probabilistic structures): vector clocks are exact causality; version vectors trimmed by dotted version vectors are the space-conscious cousin.
Capstone M31 — active-active FalkorDB
Two masters, both accepting writes, no leader:
- nodes/edges = add-wins OR-Sets (
orset.rssemantics,graph.rscomposition) — concurrentCREATE/DELETEof the same node resolves add-wins; re-add resurrects hidden edges. - properties = LWW maps with HLC timestamps (topic 29) — and now you can quantify the lost-write rate you’re signing up for (lane 1).
- anti-entropy: periodic state merge (or dotted-version-vector deltas).
- Contrast with M15: same graph workload through Raft — measure the write-latency gap, then write down which conflicts Raft prevented that active-active resolved (and whether users would agree with the resolutions).
cr-sqlite: a real database goes multi-master
The other guides in this topic are about documents. cr-sqlite is the one that answers the database question: what does it take to bolt CRDT semantics onto a relational engine as a loadable extension — no fork, no new storage engine. This is the closest published prior art to M31’s “active-active FalkorDB.”
The one picture
CREATE TABLE post(id PRIMARY KEY, title, likes);
SELECT crsql_as_crr('post'); -- "conflict-free replicated relation"
┌────────────┐ every column write bumps ┌─────────────────┐
│ post (real │──► post__crsql_clock: │ crsql_changes │
│ table) │ (pk, col_name, col_version, │ (virtual table) │
└────────────┘ db_version, site_id, seq) └─────────────────┘
one clock ROW per CELL │
replication = SELECT * FROM crsql_changes WHERE db_version > ?
on peer: INSERT INTO crsql_changes ... (that's it)
merge rule per cell: larger col_version wins;
tie → value comparison (deterministic, not wall clock!)
- Rows are LWW maps (one register per column — your
lww.rs::LwwMapis exactly this): concurrent writes to different columns of a row both survive; same column → one loses. Bench lane 1 priced that. - Deletes are tombstoned via a sentinel clock row; delete wins over concurrent column updates (a remove-wins choice — opposite of your OR-Set! worth pausing on).
The per-cell merge rule, entire:
#![allow(unused)]
fn main() {
// One clock row per CELL: (pk, col) -> (col_version, db_version, site_id)
fn merge_cell(local: &mut Cell, remote: &Cell) {
if remote.col_version > local.col_version {
*local = remote.clone(); // larger Lamport version wins
} else if remote.col_version == local.col_version
&& sqlite_cmp(&remote.value, &local.value) == Ordering::Greater
{
*local = remote.clone(); // tie → compare the VALUES, not
} // clocks or site ids: deterministic
} // convergence with zero clock trust
}
Code walk (core/rs/core/src/)
| anchor | what to see |
|---|---|
local_writes/mod.rs:83-133 | after_update bookkeeping: bump db_version, write one clock row per changed column — the Lamport-clock spine of the whole design |
db_version.rs | db_version = per-database Lamport clock; next_db_version peeks/bumps. Compare topic 29’s HLC: no wall-clock component at all here |
compare_values.rs | the tiebreak when col_versions are equal: compare the VALUES by SQLite type ordering. Deterministic convergence with zero clock trust |
changes_vtab.rs | the genius move: replication endpoint as a virtual table — sync = SQL |
create_crr.rs | what crsql_as_crr() actually creates (clock table, triggers) |
Reading + background
- cr-sqlite README + docs (vlcn.io) — the deceptively short merge rules.
- James Long’s “CRDTs for Mortals” talk (actual-budget lineage) — same per-cell LWW idea with hybrid logical clocks instead of db_version.
Questions
- Why one clock row per cell instead of per row? What anomaly appears with row-granularity LWW that lane 1’s per-key numbers understate?
compare_values.rsbreaks version ties by comparing values, not site_id. Yourlww.rsuses (ts, replica). Both converge — which gives saner semantics when two sites write the same value, and which when they write different values?- db_version is a pure Lamport clock (no physical component). What user-visible LWW behavior does this change vs an HLC (topic 29) when one site is offline for a week, then syncs?
- cr-sqlite chose delete-wins for rows; your orset.rs/graph.rs chose add-wins. Reconstruct why relational rows push toward remove-wins (hint: foreign keys, uniqueness) while graph nodes push add-wins.
- Primary keys are the merge identity. What goes wrong if an app uses
auto-increment integer PKs across two masters, and what does cr-sqlite
tell you to use instead? (Same question M31 must answer for node ids —
compare your
Dot-based identity.) - M31 mapping: design FalkorDB’s
crsql_changesequivalent: what’s the minimal change-row schema for (node adds/removes, edge adds/removes, property LWW sets), what plays the role of db_version, and how does a peer apply a batch idempotently mid-crash? Sketch it against yourgraph.rsmerge.
References
Papers
- None — the design lives in the cr-sqlite README and the vlcn.io docs; James Long’s “CRDTs for Mortals” talk is the closest lineage write-up (same per-cell LWW with hybrid logical clocks instead of db_version)
Code
- cr-sqlite
core/rs/core/src/— start atlocal_writes/mod.rsandchanges_vtab.rs; the merge rules are deceptively short
JSON CRDTs & the move op: identity beats paths
Three papers by the Kleppmann line, one arc: (1) generalize CRDTs from flat sets/lists to arbitrary nested JSON; (2) discover that moving things is the hard op the 2017 paper punted on; (3) the manifesto for why any of this matters. Automerge is the running implementation of the first two.
The one picture — why JSON is harder than a list
doc = { "todo": [ {"title": "buy milk", "done": false} ] }
replica A: todo[0].done = true replica B: delete todo[0]
└── mutates INSIDE an element └── removes the element
after merge, what wins? three composable sub-problems:
┌─────────────────────────────────────────────────────────────┐
│ map keys → per-key registers (concurrent set = MV or LWW) │
│ list order → sequence CRDT (topic's rga.rs) │
│ nesting → every value has an identity (op id = our Dot); │
│ mutations address identities, not paths; │
│ delete hides subtree, concurrent edit revives │
└─────────────────────────────────────────────────────────────┘
(automerge: rust/automerge/src/op_set2/op.rs:52 — `succ` lists the
ops that overwrote/deleted this op; visibility = "has no succ")
JSON CRDT (2017) — reading map
| section | extract |
|---|---|
| §2 | the two editors / shopping-list examples — run them mentally against your orset.rs + lww.rs semantics |
| §3.1-3.2 | ops address identifiers (Lamport timestamps ≈ Dots), never indices or paths |
| §4 | the formal semantics: presence sets, the clear trick for assigning over a subtree |
| §5 | the interleaving anomaly figure — the flaw Fugue later fixes (see reading-sequence-crdts.md) |
The move op (2021, “A highly-available move operation for replicated trees”)
The 2017 paper has insert/delete/assign — no move. Naive move = delete+re-insert, and two concurrent moves of the same node duplicate it (or cycle the tree: move A under B ∥ move B under A).
fix: moves form a TOTAL order (Lamport ts). apply = log op.
to add op O out of order: UNDO all ops after O, apply O, REDO them.
── each redo re-checks "would this create a cycle? then skip" ──
safety from the total order; availability kept because undo/redo
is local replay, not coordination.
That undo/redo replay is the same shape as diamond-types’ retreat/advance over its time DAG — one mechanism, two papers. In code:
#![allow(unused)]
fn main() {
// Moves live in a TOTAL order (Lamport ts). Integrating an op that
// arrives out of order = undo everything newer, apply, redo.
fn integrate_move(log: &mut Vec<MoveOp>, tree: &mut Tree, op: MoveOp) {
let pos = log.partition_point(|o| o.ts < op.ts);
for o in log[pos..].iter().rev() { tree.undo(o); } // roll back newer ops
tree.apply_unless_cycle(&op); // "would this create a
for o in &log[pos..] { // cycle? then skip" —
tree.apply_unless_cycle(o); // re-checked at every redo,
} // identically on all replicas
log.insert(pos, op);
// safety from the total order; availability because replay is LOCAL
}
}
Local-First (Onward! 2019)
The “why”: seven ideals (no spinners, multi-device, offline, collab, longevity, privacy, ownership). Read §3’s assessment table — every sync architecture graded against them; CRDTs are the only column that clears offline + collab + ownership simultaneously. This is M31’s product spec: active-active FalkorDB is “local-first for graphs.”
Questions
- In the 2017 semantics, why must ops reference identifiers instead of JSON paths? Construct the concurrent-edit anomaly a path-based op causes (hint: two inserts shift indices).
- Concurrent assignment of
{"a":1}and[1,2]to the same map key: what does the paper’s MV-semantics keep, and what does automerge’s LWW-flavored choice keep? Which lane-1 number says how often you’d care? - Why does delete-as-hide (presence sets) fall out necessarily from wanting “concurrent edit into deleted subtree revives it”? Relate to your graph.rs hide-not-delete edges.
- Two concurrent moves of the same tree node: show how delete+reinsert duplicates it, then walk the 2021 undo/redo algorithm on that exact interleaving.
- The move paper’s cycle check happens at redo time on every replica identically. Why does this give convergence without coordination, and what’s the cost as the op log grows (what bounds the replay window)?
- M31 mapping: FalkorDB graphs have no tree constraint, but “move” ≈ re-parenting via edge delete+add. Does the duplicate/cycle problem survive? Design the graph analogue: which concurrent edge rewirings need move-op-style total ordering, and which are safe under plain OR-Set semantics?
References
Papers
- Kleppmann & Beresford — “A Conflict-Free Replicated JSON Datatype” (IEEE TPDS 2017, arXiv:1608.03960) — §2-4; §5’s interleaving figure is the flaw Fugue later fixes
- Kleppmann, Mulligan, Gomes, Beresford — “A Highly-Available Move Operation for Replicated Trees” (IEEE TPDS 2021) — the undo/redo algorithm and the cycle check
- Kleppmann, Wiggins, van Hardenberg, McGranaghan — “Local-First Software: You Own Your Data, in Spite of the Cloud” (Onward! 2019) — read §3’s assessment table
Code
- automerge
rust/automerge/src/op_set2/op.rs— thesuccfield is deletion-as-successor-ops; visibility = “has no succ”
Sequence CRDTs: what a decade of engineering does to RGA
Your rga.rs is the textbook version. The three production codebases
here — yrs, diamond-types, Loro — all share its integration rule and
disagree about everything else: storage layout, when the CRDT machinery
runs at all, and how to stop two users’ words interleaving. Read in this
order: yrs (the canonical Item/integrate design), diamond-types (same
rule, radically different storage), Loro blogs + Fugue paper (fixing
interleaving, plus the b-tree/rle machinery).
The one picture — three storage strategies, one integration rule
rga.rs Vec<Element>, one entry per char O(n) everything, honest
─────────────────────────────────────────────────────────────────────────
yrs doubly-linked Items, RUN-COALESCED: typing "hello" = ONE
Item{id, left, right, origin, Item spanning 5 chars
right_origin, content} (split on edit inside)
─────────────────────────────────────────────────────────────────────────
diamond-types ops in a TIME DAG, run-length replay/merge engine:
encoded; document rebuilt by retreat/advance marks
retreat/advance over spans spans INSERTED /
NOT_INSERTED_YET
─────────────────────────────────────────────────────────────────────────
loro Fugue semantics on a generic-btree, tree beats linked list
rle runs, fractional_index for for random access;
(non-text) ordered containers same origin-pair idea
The shared rule, at rga.rs granularity — everything else is storage:
#![allow(unused)]
fn main() {
// Insert after the parent, skipping concurrent siblings with a
// larger id — the same deterministic scan on every replica.
fn integrate(&mut self, el: Element) {
let mut pos = self.index_of(el.parent) + 1;
while let Some(sib) = self.elems.get(pos) {
if sib.parent != el.parent { break; } // left the sibling block
if sib.dot > el.dot { // larger (counter, replica)
pos += 1; // sits closer to the parent —
} else { break; } // skip it (and its subtree,
} // the detail rga.rs handles)
self.elems.insert(pos, el); // tombstones stay: deleted
} // elements still anchor children
}
yrs walk (~/repos/y-crdt)
| anchor | what to see |
|---|---|
yrs/src/block.rs:160 | ID { client, clock } — literally your Dot |
yrs/src/block.rs:439 | ItemPtr — pointer-heavy linked structure, the cost of O(1) local edits |
yrs/src/block.rs:1302 | Item — note origin AND right_origin: Yjs (YATA) uses both neighbors at insert time, not just RGA’s single parent |
yrs/src/block.rs:984, :995 | integrate/integrate_item dispatch |
yrs/src/block.rs:1415 | Item::integrate — the conflict-resolution loop. Map each branch onto your rga.rs apply: the scan for the insert position, the (client-id) tiebreak, splitting a run when the insert lands mid-Item |
diamond-types walk (~/repos/diamond-types)
| anchor | what to see |
|---|---|
src/listmerge/merge.rs:142 | integrate() — “This is a bastardization of the sequence CRDT algorithm” per its own comment; same skip-larger-siblings loop over a range tree |
src/listmerge/yjsspan.rs:29 | INSERTED / NOT_INSERTED_YET — spans have a current state relative to the merge frontier; retreat/advance flips them as the engine walks the time DAG. Kleppmann’s move-op undo/redo, industrialized |
The headline: diamond-types doesn’t store a CRDT structure at rest — it stores the op log and runs the CRDT only when branches actually merge. Sequential editing (the 99% case) never pays CRDT overhead.
Loro & Fugue
- Fugue paper (“The Art of the Fugue”, Weidner & Kleppmann): defines maximal non-interleaving. RGA interleaves backward typing; Yjs interleaves forward in corner cases. Fugue’s fix is the left+right origin pair with a tree-order rule.
- Loro blog “Introduction to Loro’s Rich Text Format” + “Movable Tree”
posts: crates to skim —
crates/loro-internal/src/{dag, diff_calc, handler, encoding}, plus standalonefractional_index,generic-btree,rle.
interleaving anomaly (why Fugue exists):
A types "milk eggs", B types "bread jam" at the SAME cursor, offline.
bad merge: m b i r l e k a ... (RGA worst case: letter soup)
fugue: milk eggs bread jam (runs stay contiguous, order by tiebreak)
The PLAN’s automerge-vs-loro bench
This crate’s deps convention (rand only) can’t host automerge/loro, so
run it as a scratch project (README exercise 2): replay
diamond-types/benchmark_data/ traces through both, record apply time +
peak memory + serialized size. Loro’s claims to verify: order-of-magnitude
faster load via its “shallow snapshot” encoding.
Questions
- Yjs Items carry
origin+right_origin; your rga.rs carries onlyparent. Construct the concurrent scenario where the single-parent rule produces a different (worse) order than YATA’s pair rule. - In
Item::integrate(block.rs:1415), when does an insert split an existing Item? What invariant aboutID.clockcontiguity makes run coalescing sound in the first place? - Why can diamond-types skip CRDT overhead entirely for a lone writer, and what specifically forces it to “become” a CRDT again (which function have you read that does the becoming)?
NOT_INSERTED_YET(yjsspan.rs:29): why does merging branch B into the frontier require marking some already-typed spans as not-yet-inserted? Connect to the move-op paper’s undo/redo.- Define maximal non-interleaving. Show a two-user trace where RGA interleaves but Fugue doesn’t, using (counter, replica) tiebreaks explicitly.
- M31 mapping: FalkorDB properties can hold long strings. When is a sequence CRDT per string property worth it vs LWW-whole-string? Propose the cutover heuristic and what the write path stores in each mode (think: Loro’s rle runs vs one register).
References
Papers
- Weidner & Kleppmann — “The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing” (arXiv:2305.00583, 2023) — the definition of maximal non-interleaving and the left+right origin rule
Code
- y-crdt
yrs/src/block.rs— ID, Item, andItem::integrateat :1415 are the canonical design - diamond-types
src/listmerge/merge.rs,src/listmerge/yjsspan.rs— the op-log-at- rest, CRDT-only-on-merge architecture - loro
crates/loro-internal/src/{dag, diff_calc, handler, encoding}plus the standalonefractional_index,generic-btree,rlecrates — skim alongside the Loro blog posts (“Introduction to Loro’s Rich Text Format”, “Movable Tree”)
CRDT foundations: convergence without coordination
Consensus agrees on an order, then applies; CRDTs design the data so
order doesn’t matter, then never coordinate. This chapter distills the
two founding documents — Shapiro et al.’s 14-page SSS’11 theory and the
50-page INRIA catalog (RR-7506) you’ll keep coming back to. Read SSS’11
§1-3 first, then treat the report as a reference for each structure you
implement in experiments/src/.
The one picture
Strong Eventual Consistency (SEC)
┌──────────────────────────────────────────────────────┐
│ eventual delivery + termination + CONFLUENCE: │
│ same set of updates received ⇒ same state, │
│ regardless of order │
└──────────────────────────────────────────────────────┘
▲ guaranteed by either of two sufficient conditions ▲
│ │
CvRDT (state-based) CmRDT (op-based)
states form a join semilattice: concurrent ops commute;
merge = LUB (assoc, comm, idem); delivery is causal +
updates are inflations (s ⊑ update(s)) exactly-once/idempotent
│ │
ship state, tolerate any gossip ship ops, need a smarter
(counter.rs, orset.rs, lww.rs) network layer (rga.rs)
────────────── §3 of SSS'11 proves these EQUIVALENT ──────────────
(a CvRDT can emulate a CmRDT and vice versa — the choice
is an engineering trade, not an expressiveness one)
Reading map
| section | what to extract |
|---|---|
| SSS’11 §2.1 | the system model: no rollback, no consensus, updates applied locally first |
| SSS’11 §2.3 Def. 2.3 | SEC stated precisely — memorize the three clauses |
| SSS’11 §3.1-3.2 | the two sufficient conditions (semilattice / commutativity) and the equivalence proof |
| Report §3.1 | counters: G, PN — why PN needs two G-Counters (your counter.rs doc comment) |
| Report §3.2 | registers: LWW and MV-register (multi-value: keep both concurrent writes — the honest register LWW isn’t) |
| Report §3.3 | sets: G-Set, 2P-Set (remove is forever!), OR-Set (§3.3.5 — your orset.rs) |
| Report §4 | graphs! 2P2P-Graph and the remark that concurrent addEdge/removeVertex has no universally right answer — the dangling-edge problem M31 inherits |
| Report §5 | garbage collection needs “stability” (Wuu & Bernstein) — ties to exercise 4 |
The catalog’s flagship (Report §3.3.5, our orset.rs) in one screen —
every property SEC needs falls out of set union:
#![allow(unused)]
fn main() {
struct OrSet<T> { adds: HashMap<T, HashSet<Dot>>, removed: HashSet<Dot> }
fn add(&mut self, x: T, dot: Dot) { self.adds.entry(x).or_default().insert(dot); }
fn remove(&mut self, x: &T) { // kill only dots we have OBSERVED —
self.removed.extend(&self.adds[x]); // a concurrent add's fresh dot
} // survives: add-wins
fn contains(&self, x: &T) -> bool {
self.adds.get(x).is_some_and(|ds| ds.iter().any(|d| !self.removed.contains(d)))
}
fn merge(&mut self, other: &Self) { // join = union of everything:
for (x, ds) in &other.adds { self.adds.entry(x.clone()).or_default().extend(ds); }
self.removed.extend(&other.removed); // assoc + comm + idem ⇒ SEC for free
}
}
Questions
- State the three clauses of SEC. Which clause does a Raft-replicated register satisfy trivially, and which does it not need because there’s a total order?
- Why is
max()over a single signed counter not a valid CvRDT merge, while per-replica-slot pointwise max is? (Prove non-inflation breaks; then check yourcounter.rsPN design against Report §3.1.) - The 2P-Set forbids re-adding a removed element; the OR-Set allows it.
What metadata does OR-Set pay for this (look at your
orset.rstombstones after bench lane 2), and what lets you ever reclaim it? - MV-register vs LWW-register: after bench lane 1’s ~95% lost-writes row, argue when each is right. What does the MV-register push onto the application?
- CvRDT and CmRDT are equivalent in theory (§3). Give two engineering reasons Yjs/automerge ship ops while Riak shipped state.
- M31 mapping: Report §4’s graph CRDTs stop at “concurrent
addEdge(u,v) ∥ removeVertex(u) is application-specific.” Write the
FalkorDB answer: which of hide/cascade/resurrect did
graph.rschoose, and what would a Cypher user observe in each case?
References
Papers
- Shapiro, Preguiça, Baquero, Zawirski — “Conflict-free Replicated Data Types” (SSS 2011) — the 14-page theory; read §1-3 first
- Shapiro, Preguiça, Baquero, Zawirski — “A comprehensive study of Convergent and Commutative Replicated Data Types” (INRIA RR-7506, 2011) — the 50-page catalog; use as a reference per structure, not a cover-to-cover read
Code
- Paper-only chapter — the catalog’s structures map one-to-one onto this
topic’s
experiments/src/stubs
Topic 31 — working notes
Predict before you measure
Fill the predictions BEFORE implementing the stubs / running lanes 2-4.
| lane | metric | prediction | measured |
|---|---|---|---|
| 1 | lost% — 10 keys, sync_every=1 | — | 94.98% |
| 1 | lost% — 1000 keys, sync_every=100 | — | 88.34% |
| 1 | lost% — 100K keys, sync_every=10000 | — | 12.45% |
| 2 | tombstones per live dot after storm | ||
| 2 | rounds of random gossip to converge (8 replicas) | ||
| 3 | sequential inserts/s (Vec-backed RGA, 50K chars) | ||
| 3 | tombstone bloat after deleting half | ||
| 4 | dangling (hidden) edges after 100 node-removes ∥ 500 edge-adds | ||
| 4 | edges resurrected after re-adding the 100 nodes |
Lane 1 measured notes (2026-07-11, M-series MBP, release):
- Hot keyspace + constant sync ≈ every write races → LWW keeps one of each pair: ~95% loss is the floor of the birthday collision, not a bug.
- Subtle: lane 1 counts merge-time discards only. With rare sync + tiny keyspace (10 keys / sync_every=10000 → 0.05%) most shadowed writes were overwritten locally before ever syncing, so they don’t show up as “lost to concurrency” — the divergence loss is a lower bound.
- Full 100K-writes × sync_every=1 config was quadratic (state-based sync ships the whole map): shrunk to 20K writes and wrote the cost into the bench comment. Delta-CRDTs exist for exactly this (README exercise 3).
Stub order that worked on paper
counter → orset → graph (composes orset+lww) → rga. Graph before RGA: graph reuses semantics you just built; RGA is the genuinely new mechanism.
Guide-question checklist
- reading-shapiro-crdts.md 1-6
- reading-kleppmann-json-crdts.md 1-6
- reading-sequence-crdts.md 1-6
- reading-cr-sqlite.md 1-6
Cross-topic threads
- Raft (15) vs CRDT is where coordination happens: before the write (consensus) vs never (merge must absorb it). M31 = run both, same workload.
- LWW timestamps want the HLC from topic 29; cr-sqlite shows the pure- Lamport alternative (db_version) and pays with “offline week still wins nothing” semantics — good interview question for M31 design review.
- OR-Set tombstones ↔ MVCC dead versions (topic 5): both need a horizon (causal stability ↔ oldest snapshot) before GC is safe.
- diamond-types’ “only be a CRDT at merge time” rhymes with topic 27’s incremental view maintenance: store the log, derive the state.
Capstone M31 log
- Node/edge identity must be a Dot (replica, counter), NOT user-visible ids — cr-sqlite question 5 is the same trap as auto-increment PKs.
- Dangling-edge policy locked: hide-not-delete, edge visible iff both endpoints visible, re-add resurrects (graph.rs tests pin this).
- Properties: LwwMap keyed by node id (survive remove/re-add). Automerge would key by creation-op — README exercise 6 argues the difference.
- Anti-entropy v1: whole-state merge like lane 1; v2: db_version-style watermark deltas (cr-sqlite question 6 sketches the change feed).
- Deliverable comparison vs M15: write latency histogram + a table of concrete conflicts (same node created twice, edge to deleted node, property race) and what each mode did about them.
Infra notes
- rand 0.8 + rand_chacha 0.3 only, per repo convention; seeded ChaCha permutation shuffles stand in for proptest in all convergence tests.
- 6 provided tests green (clock 3, lww 3); 18 stub tests todo-panic until implemented; lanes 2-4 wrapped in catch_unwind print their stub banner.
- automerge-vs-loro bench deliberately NOT in this crate (deps convention) — README exercise 2, precedent topic 14 (helix-db).
Done when
- all 24 tests pass
- lanes 2-4 print real numbers; predictions table filled + surprises noted
- all 24 guide questions answered
- M31 design sketch reviewed against cr-sqlite question 6
Topic 32 — HTAP Architectures
Topic 12 gave you the columnar layout, topic 27 the changelog, topic 15 the replicated log. HTAP is where they collide: one system that answers point-writes at OLTP latency and analytical scans at columnar speed, without the nightly ETL that made “the dashboard is a day old” normal.
The problem, measured (bench lane 1, provided — runs today)
One store, one coarse lock, 1M rows. A writer hammers point-updates while an analytical scanner free-runs full scans on the same copy. Fixed 2-second window per mode:
mode p50 ns p99 ns p99.9 ns writes/2s scans
writes alone 125 333 541 11438647 0
writes + full scans 125 7490728791 7490728791 69 3261
Read that again: throughput collapsed from 11.4 million writes to 69, and p99 write latency went from 333 ns to 7.49 seconds — the scanner starved the writer almost completely (unfair lock + ~2 ms scans back to back). The lock is deliberately coarse, but the shape survives every mitigation short of separation: scans and writes on one copy fight over something (locks, cache lines, buffer pool, MVCC GC). HTAP architectures are the catalog of ways to stop the fight.
The trilemma
freshness
(how stale may analytics be?)
▲
╱ ╲ pick a point, not a corner:
╱ ╲
╱ × ╲ × = TiFlash: fresh (learner wait),
╱ ╲ isolated (separate nodes),
▼─────────▼ pays in replica cost + read wait
isolation cost
(does OLAP hurt (extra copies,
OLTP p99?) extra nodes)
The architecture menu
| architecture | one copy? | freshness | isolation | example |
|---|---|---|---|---|
| single engine, dual format | yes (delta+main) | perfect | poor–ok | SAP HANA |
| fork() snapshot | virtual copy (CoW pages) | snapshot age | good | HyPer |
| columnar replica in-consensus | no — Raft learner | bounded (learner wait) | strong | TiDB + TiFlash |
| CDC-fed separate system | no — changelog | seconds | total | F1 Lightning |
| query offload to attached OLAP | no — shared files | snapshot | strong | pg_duckdb-style |
graph LR
W[OLTP writes] --> P[(row primary)]
P -- "Raft log (learner)" --> R[(columnar replica)]
P -- "CDC / changelog<br/>(topic 27)" --> L[(Lightning-style<br/>separate OLAP)]
Q[query] --> PL{planner<br/>find_best_task}
PL -- "point / index" --> P
PL -- "big scan" --> R
The deep thread: the changelog is the glue in every split design — topic 27’s thesis, now load-bearing. And the replica’s storage is delta+main: TiFlash’s DeltaTree ≈ HANA’s delta merge ≈ topic 4’s LSM ≈ FalkorDB’s delta matrices. Writes land append-friendly, reads merge, compaction folds.
Code reading (all cloned under ~/repos)
| repo | anchor | what to see |
|---|---|---|
| tiflash | dbms/src/Storages/KVStore/Read/LearnerRead.cpp:35, :61 | doLearnerRead — freshness as a wait, with a timeout |
| tiflash | dbms/src/Storages/DeltaMerge/DeltaMergeStore.h:107, :668 | the store; segmentMergeDelta = your merge_delta() |
| tiflash | dbms/src/Storages/DeltaMerge/Segment.h:84, :715 | Segment = delta over stable; placeUpsert |
| tiflash | dbms/src/Storages/DeltaMerge/Delta/{MemTableSet,DeltaValueSpace}.h, Delta/MinorCompaction.h | the delta layer’s own little LSM |
| tiflash | dbms/src/Storages/DeltaMerge/DeltaIndex/DeltaIndex.h:27 | index that makes delta+stable merge reads cheap |
| tidb | pkg/planner/core/find_best_task.go:535, :1841, :1878 | one optimizer, two engines: TiKV vs TiFlash paths priced together |
Reading guides
- reading-tidb-htap.md — TiDB HTAP: the columnar replica is a Raft learner.
- reading-tiflash-deltatree.md — DeltaTree: columnar storage built for writes.
- reading-hyper-hana.md — HyPer & HANA: one copy serves both.
- reading-f1-lightning.md — F1 Lightning: HTAP without touching OLTP.
Experiments
cd experiments
cargo test # 5 provided tests pass; 7 fix the contract for your stubs
cargo run --release --bin htap_bench
row.rs(PROVIDED) — the OLTP primary: row store + every write appended to a changelog (log). Also the scan oracle.replica.rs(stub) — columnar replica with delta+main:applythe log,scan_sum_amerging delta over main,merge_deltacompaction.learner.rs(stub) —read_wait: how long a consistent read blocks on an apply schedule. Freshness priced as a wait distribution.
Bench lanes: 1 = interference (provided, above). 2 = scan speedup row vs delta-heavy vs merged replica + freshness lag vs batch size. 3 = learner-read wait distribution vs apply interval.
Exercises
- Implement the stubs until all tests pass and lanes 2-3 print.
- Lane 1 starves the writer with an unfair lock. Swap in a readers-writer lock — does p99 recover? What did full scans still cost?
- Lane 2’s merged scan beats the delta-heavy scan. Find the delta size where scanning delta+main crosses over merged+fresh-delta — that crossover is TiFlash’s delta-merge trigger heuristic.
- Lane 3 demands lsn == now (freshest). Re-run demanding
now - 100(bounded staleness) — how much wait does 100 lsns of slack buy? That’s the follower-read / stale-read knob. - Your replica applies the log single-threaded. Which parts of
applyandmerge_deltaparallelize safely (topic 20’s rayon), and what does the answer share with LSM compaction scheduling (topic 4)? - Sketch M32’s router: which FalkorDB queries go to delta-matrix “main”
vs need the freshest delta — and what’s the analogue of
read_wait?
Cross-topic threads
- Topic 4 (LSM): delta+main IS an LSM with exactly two levels;
merge_deltais minor compaction; the delta index is the memtable’s answer to read amplification. - Topic 12 (columnar): the replica’s
main_a/main_bare topic 12’s column vectors; lane 2 re-measures that scan gap, now with freshness attached. - Topic 15 (Raft): TiFlash is a learner — replicates, never votes. Learner reads are read-index reads (topic 15’s ReadIndex) done by the follower.
- Topic 27 (streaming/IVM): the changelog feeding the replica is the same changelog; F1 Lightning is CDC-as-architecture. M27’s changelog becomes M32’s replication stream.
- Topic 20 (GraphBLAS): FalkorDB’s delta matrices are delta+main for adjacency — pending-block writes folded into the stable matrix. M32 is this topic wearing graph clothes.
Capstone M32 — changelog-fed analytical replica for FalkorDB
- Feed M27’s changelog into a columnar/matrix replica (delta matrices as
the delta layer, stable matrices as main);
merge_delta= pending flush. - Learner-read rule: analytical queries carry a freshness bound; router
waits (
read_wait) or routes to primary if the bound is tight. - Measure the M32 version of lane 1: OLTP p99 on the primary with and without analytics offloaded — the 69-writes number is the before shot.
F1 Lightning: HTAP without touching OLTP
This chapter closes the topic’s design space with two documents: F1 Lightning, where analytics is bolted onto an untouchable OLTP system entirely from the outside, and the Özcan survey, which organizes every architecture you’ve met along one axis — how many copies, how coupled. Between them sits the trilemma the README opened with, now with every corner priced.
Lightning: HTAP without touching the OLTP system
Google’s constraint: the OLTP databases (Spanner, F1 DB) already exist and cannot be modified or slowed. So the analytical side is bolted on entirely from the outside, fed by CDC:
Spanner/F1 (OLTP, untouched)
│ change data capture (changelog — topic 27)
▼
Changepump ──► Lightning servers: apply changes into columnar
│ delta+main (LSM-ish; deltas merged in background —
│ the same fold as reading-tiflash-deltatree.md)
▼
F1 Query ──► routes analytical plans to Lightning replicas,
each read pinned to a *safe timestamp* — the max
commit ts the replica has fully applied
Two ideas to steal:
- The safe timestamp is
applied_lsn. Lightning serves reads only at-or-below the timestamp it has completely applied — yourfreshness_is_visibletest, productionized. Reads never wait (contrastdoLearnerRead); instead they’re served stale but consistent, and the query layer picks a timestamp all touched replicas can serve. - Decoupling as a feature. No OLTP code changes, no learner in the quorum, works over multiple OLTP systems. Payment: freshness is seconds (CDC lag), not a bounded Raft wait — the opposite corner of the trilemma from HANA.
The safe-timestamp routing rule, which replaces doLearnerRead’s wait:
#![allow(unused)]
fn main() {
// Lightning never waits: reads are stale-but-consistent at a SAFE TIMESTAMP
fn route_analytical(q: &Query, replicas: &[Replica]) -> Result<Plan, Refuse> {
let safe_ts = q.touched_shards(replicas)
.map(|r| r.applied_ts()) // max commit ts each has FULLY applied
.min() // all shards must serve ONE snapshot
.ok_or(Refuse::NoReplica)?;
if let Some(bound) = q.freshness_bound {
if safe_ts < bound {
return Err(Refuse::TooStale); // refuse rather than lie —
} // the router's honesty contract
}
Ok(Plan::scan_at(safe_ts)) // consistent, zero wait: the opposite
} // trade from TiFlash's learner read
}
The survey: one axis to organize everything
Özcan et al. classify by how many copies, how coupled:
| single copy | separate copies | |
|---|---|---|
| single engine | HANA delta+main | HyPer fork (logical single) |
| separate engines | pg_duckdb-style offload (same files) | TiFlash (learner), Lightning (CDC) |
Every cell trades the same three currencies — freshness, isolation, cost (README trilemma). Lane 1 measured why the top-left cell is hard; lanes 2–3 price the right column’s two currencies (scan speedup vs lsn lag, wait distribution).
Questions
- Lightning reads never block on freshness; TiFlash learner reads do.
Rewrite
read_wait’s contract for the Lightning model: what does it return instead of a wait, and which test of yours becomes the important one? - CDC lag is seconds; learner apply lag is the lane-2 gap table. What failure behaviors differ — what happens to each design’s analytics when the OLTP leader fails over?
- Lightning must reconstruct transactional consistency from a change stream (changes arrive per-shard). What ordering guarantee must Changepump enforce, and which topic 27 concept is that? Which topic 29 concept gives Spanner the timestamps that make it possible?
- “HTAP as a service” supports multiple OLTP engines behind one translation layer. What does that force the delta schema to look like, and what does it rule out (hint: can Lightning use the OLTP engine’s own MVCC versions)?
- Place pg_duckdb-style offload (OLAP engine reading the OLTP engine’s files/snapshots in-process) on the trilemma. Which corner does it nail, which does it give up, and for what budget is it the right answer?
- M32 mapping: M32 feeds a replica from M27’s changelog — that’s Lightning’s shape, not TiFlash’s. Adopt the safe-timestamp idea: what exactly does the M32 router advertise per replica, and when does it refuse a query instead of serving stale?
References
Papers
- Yang et al. — “F1 Lightning: HTAP as a Service” (VLDB 2020) — §3-4 for Changepump and the safe timestamp
- Özcan, Tian, Tözün — “Hybrid Transactional/Analytical Processing: A Survey” (SIGMOD 2017, tutorial) — the copies-vs-coupling classification; skim for the map, not the details
Code
- Paper-only chapter — Lightning is not open source; the closest readable relative is the CDC pipeline of topic 27 and TiFlash’s learner in reading-tidb-htap.md
HyPer & HANA: one copy serves both
Before “ship a columnar replica” (TiDB) there was “make one copy serve
both”. This chapter reads the two classic tricks: HyPer, which lets the
OS page table be its MVCC, and HANA, which keeps every table columnar
twice and folds delta into main in the background. Both are still
load-bearing today — and one of them is replica.rs.
HyPer: let the OS be your MVCC
OLTP process (writes) fork()
┌────────────────────┐ │
│ heap pages │ ──────────────►│ OLAP child process
│ [A][B][C][D] │ child shares │ ┌────────────────────┐
└────────────────────┘ ALL pages, │ │ [A][B][C][D] │
│ write to B copy-on-write │ │ (frozen view) │
▼ │ └────────────────────┘
┌────────────────────┐ │ scans see the
│ [A][B'][C][D] │ only B copied │ snapshot at fork
└────────────────────┘ (page fault → │ time, forever
OS duplicates) │
fork() gives a transaction-consistent snapshot of the whole database
in ~microseconds: the child shares every page; the MMU copies a page only
when the parent writes it. Snapshot cost = pages actually dirtied, not
database size. It’s MVCC (topic 5) where the version chain is the page
table and GC is exit().
#![allow(unused)]
fn main() {
// HyPer's entire snapshot machinery — the OS does the versioning
fn olap_query(db: &Database, q: Query) -> Answer {
match unsafe { fork() } {
0 => { // child: shares EVERY page, copy-on-write —
let a = execute(db, q); // a transaction-consistent snapshot in ~µs;
send_to_parent(&a); // long scans see the fork-time state forever
process::exit(0); // snapshot GC = process exit
}
_pid => continue_oltp(), // parent: writes fault + copy only
} // the pages they actually dirty
}
}
The costs: snapshot ages until you re-fork (freshness = fork interval — lane 3’s apply interval in OS clothing); hot write pages get copied every epoch; and it only works single-node, in-memory, with cooperative process layout.
HANA: delta+main inside one engine
HANA keeps every table columnar, twice: a read-optimized main (dictionary-compressed, sorted) and a write-optimized delta (append-friendly dictionary, unsorted). Reads merge both; a background delta merge rebuilds main with the delta folded in — O(table) per merge, done in shadow copies so readers/writers barely notice.
You know this diagram — it is replica.rs, and it is TiFlash’s DeltaTree
(reading-tiflash-deltatree.md) minus the segmenting: HANA merges whole
table (columns) at once, DeltaTree merges per-Segment key ranges, your
merge_delta() merges everything. Same fold, different granularity.
The difference from TiDB: no second copy, no freshness gap — every query sees delta+main, perfectly fresh. The payment: isolation. Scans and writes share the node, the cache, the merge CPU. Lane 1’s interference is mitigated (delta absorbs writes, main serves scans) but not eliminated — the trilemma corner HANA gives up is exactly the one lane 1 measures.
Questions
- HyPer’s snapshot cost is proportional to dirtied pages. Which lane-1 workload property (skewed_key’s u² skew) makes fork() snapshots cheap, and which workload makes them pathological?
- fork() gives snapshot isolation for free — but which anomaly class
does the OLAP child never see, and why can’t it ever be made fresher
without re-forking? Compare to
read_wait: what’s HyPer’s equivalent of demanding lsn == now? - HANA’s delta merge is O(table). DeltaTree segments to make merges
O(segment). What query pattern punishes whole-table merges most, and
why does your lane-2
merge_costmeasurement understate the problem at scale? - Both designs keep writes append-friendly and reads merge-y. State the
invariant both merges must preserve, in the vocabulary of your
merge_preserves_scans_and_sorts_maintest. - Neither HyPer nor HANA helps when OLAP needs more compute than one node has. Where does each hit the wall, and which architecture from the README menu is the escape hatch?
- M32 mapping: FalkorDB is single-node and in-memory — HyPer’s natural habitat. Would fork()-snapshots beat a delta-matrix replica for M32’s analytical reads? Name the FalkorDB-specific write pattern that decides it (hint: matrix flush dirties how many pages?).
References
Papers
- Kemper & Neumann — “HyPer: A Hybrid OLTP&OLAP Main Memory Database System Based on Virtual Memory Snapshots” (ICDE 2011) — §2-3 for the fork() mechanism and its costs
- Färber et al. — “The SAP HANA Database — An Architecture Overview” (IEEE Data Eng. Bull. / SIGMOD Record 2012) — the delta+main and delta-merge sections
Code
- Paper-only chapter — the delta+main mechanics live in
reading-tiflash-deltatree.md’s code
walk and in our
replica.rs
TiDB HTAP: the columnar replica is a Raft learner
TiDB’s fix for the interference you measured in bench lane 1 is separation inside the consensus group: a columnar copy that receives the Raft log but never votes. This chapter pairs the VLDB ’20 paper with the two code paths that carry the design — TiFlash’s learner read (freshness as a wait) and TiDB’s planner (one optimizer pricing two engines).
The one move
The columnar copy is a Raft learner — it receives the log like any follower but never votes, so adding it costs no write-quorum latency and its scans touch OLTP nodes zero times.
client writes analytical query
│ │
▼ ▼
TiKV leader ──log──► TiKV follower "what's the commit index?" ──► leader
│ (votes) │
└───────log───► TiFlash learner ◄── wait until applied ≥ index ◄──┘
(never votes, LearnerRead.cpp:35
columnar) doLearnerRead
Freshness is not a config flag — it’s a wait. doLearnerRead
(dbms/src/Storages/KVStore/Read/LearnerRead.cpp:35) asks the leader for
the current commit index, then blocks until the local region has applied
that far, with waitIndexTimeout at :61 (and the wait-index timestamps
at :66-68). Your learner.rs::read_wait is this function reduced to
arithmetic; bench lane 3 is its wait distribution.
#![allow(unused)]
fn main() {
// doLearnerRead, reduced: freshness = read-index + wait-for-apply
fn learner_read(region: &Region, leader: &Leader, timeout: Duration) -> Option<Snapshot> {
let commit_idx = leader.read_index(); // "how far is committed, right now?"
let deadline = Instant::now() + timeout;
while region.applied_index() < commit_idx { // block until local apply catches up
if Instant::now() > deadline {
return None; // caller falls back to the leader:
} // safe but expensive
wait_for_apply_progress();
}
Some(region.snapshot_at(commit_idx)) // now as fresh as any leader read
}
}
One planner, two engines
The second half of the trick: the same cost-based optimizer prices row
and columnar paths together. In pkg/planner/core/find_best_task.go:
:535— building cop tasks, distinguishing TiKV vs TiFlash targets.:1841,:1878— candidate-path retention keeps TiFlash paths alive alongside index paths so cost, not topology, decides.
So a point lookup goes to TiKV (row, indexed), a SUM ... GROUP BY over
50M rows goes to TiFlash (columnar, learner-read first) — and a query can
mix both. That’s the planner deciding the trilemma point per query.
Questions
- Why does the learner not voting matter for OLTP write latency? What would happen to commit p99 if TiFlash were a voting follower doing columnar apply?
read_waitreturnsNoneon timeout. What does TiDB do then, and why is falling back to the leader safe but expensive? (LearnerRead.cpp:61.)- The paper claims fresh analytics, but lane 3 shows waits grow with apply-batch size. What pressure pushes TiFlash toward larger batches anyway? (Think lane 2’s freshness-vs-batch table.)
- In
find_best_task.go:1841, why must TiFlash paths be retained as candidates rather than chosen by a rule like “big table → TiFlash”? Give a query where the rule guesses wrong. - Raft learners get the log, CDC (see
reading-f1-lightning.md) gets a changelog. Both are “replay the writes” — what does being inside the consensus group buy, and what does it cost? - M32 mapping: FalkorDB has no Raft group (until M15). Which piece
substitutes for the commit index in M32’s
read_wait— and what is the “leader” the router must ask?
References
Papers
- Huang et al. — “TiDB: A Raft-based HTAP Database” (VLDB 2020) — the learner architecture and the freshness argument; the DeltaTree storage appendix pairs with reading-tiflash-deltatree.md
Code
- tidb
pkg/planner/core/find_best_task.go— one optimizer pricing TiKV vs TiFlash paths together - tiflash
dbms/src/Storages/KVStore/Read/LearnerRead.cpp—doLearnerRead, freshness as a wait with a timeout
DeltaTree: columnar storage built for writes
Columnar formats hate point-writes (topic 12: rewrite the column or eat
fragmentation), yet TiFlash must apply an OLTP write stream
continuously to columnar data. DeltaTree — the engine under
dbms/src/Storages/DeltaMerge/ in the TiFlash tree — is the answer, and
you already know its shape:
Raft log records
│ apply
▼
┌─ Segment (a key range) ── Segment.h:84 ──────────────┐
│ │
│ delta layer stable layer │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ MemTableSet │ │ sorted column files │ │
│ │ (in-mem column │ read: │ one version per key │ │
│ │ files, recent) │ merge │ scan-friendly │ │
│ │ persisted CFs │ ────► │ │ │
│ │ DeltaValueSpace │ │ │ │
│ │ .h:65 │ └──────────────────────┘ │
│ └──────────────────┘ ▲ │
│ │ MinorCompaction.h │ │
│ └── segmentMergeDelta ────────┘ │
│ DeltaMergeStore.h:668 │
└───────────────────────────────────────────────────────┘
This is the fourth time you’ve met this diagram: topic 4’s LSM
(memtable/SSTables/compaction), HANA’s delta+main
(reading-hyper-hana.md), FalkorDB’s delta matrices (pending blocks over
stable matrices), and now replica.rs — your delta: Vec<LogRec> is the
MemTableSet, main_* columns are the stable layer, merge_delta() is
segmentMergeDelta.
The read path is a two-way merge, delta shadowing stable per key:
#![allow(unused)]
fn main() {
// One Segment: a delta over a stable, both covering one key range.
fn scan(seg: &Segment, out: &mut ColumnBatch) {
let mut stable = seg.stable.iter().peekable(); // sorted, one version per key
let mut delta = seg.delta.iter_sorted().peekable(); // sorted via the DeltaIndex —
loop { // without it, every scan
match (stable.peek(), delta.peek()) { // re-sorts the delta
(Some(s), Some(d)) if d.key <= s.key => {
if d.key == s.key { stable.next(); } // delta version shadows stable
out.push(delta.next().unwrap());
}
(Some(_), _) => out.push(stable.next().unwrap()),
(None, Some(_)) => out.push(delta.next().unwrap()),
(None, None) => return,
}
}
}
}
Anchors, in reading order
DeltaMergeStore.h:107— the store: a map of key-range → Segment, plus the background merge machinery.Segment.h:84— one Segment = one delta + one stable, both covering the same key range.:715 placeUpsert— where an incoming write lands in the delta.Delta/MemTableSet.h,Delta/DeltaValueSpace.h:65— the delta layer is itself tiered: in-memory column files, then persisted ones. A little LSM inside the delta of the big two-level LSM.Delta/MinorCompaction.h— compaction within the delta (fold small column files together) before the big fold into stable.DeltaIndex/DeltaIndex.h:27— the trick yourscan_sum_alacks: a persistent index mapping delta rows into stable’s sort order, so merge reads don’t re-sort the delta every scan.DeltaMergeStore.h:668 segmentMergeDelta— the fold. Yourmerge_delta()contract (scans identical before/after, delta emptied) is exactly its correctness condition.
Questions
- Why does the delta store column files rather than rows, when it’s the write-optimized side? What read would rows in the delta ruin?
- The DeltaIndex makes delta+stable reads cheap without merging. What does it have to be rebuilt/patched on, and what’s the topic 4 analogue (hint: what does an LSM do instead — bloom filters? merge iterators?)?
merge_deltamust not change scan results. Your test pins this with an oracle; how would you check it in TiFlash where there’s no oracle? (Look at what invariants Segment can assert.)- MinorCompaction inside the delta: why compact the delta at all if segmentMergeDelta will fold everything anyway? What workload makes delta-internal compaction pay?
- MVCC: TiFlash keeps versions (topic 5) in both layers. What does “one entry per key in stable” become when snapshots must still read old versions — and what bounds GC (compare: causal stability in topic 31’s tombstone question)?
- M32 mapping: FalkorDB’s delta matrix flush is
segmentMergeDeltafor adjacency. What is the delta index analogue — what structure would let algebraic scans consume stable+pending without materializing the merge?
References
Papers
- None dedicated — the design is described in the storage section of Huang et al., “TiDB: A Raft-based HTAP Database” (VLDB 2020); the rest lives in code comments
Code
- tiflash
dbms/src/Storages/DeltaMerge/— start atDeltaMergeStore.handSegment.h; the delta layer (Delta/) andDeltaIndex/are the parts yourreplica.rsdeliberately lacks
Topic 32 notes — HTAP architectures
Predictions vs measurements
| question | predicted | measured |
|---|---|---|
| lane 1: write p99, scans on vs off | 10–100x worse | 333 ns → 7.49 s (2.2e7x); not slowdown — starvation |
| lane 1: write throughput hit | ~2–5x fewer writes | 11,438,647 → 69 writes in 2 s |
| lane 1: scans completed in 2 s | ~100 | 3261 (~0.6 ms/scan; scanner re-grabs the unfair lock back-to-back) |
| lane 2: merged vs delta-heavy scan | 5–10x | (stub — measure after implementing replica.rs) |
| lane 2: max lsn gap vs batch | ≈ batch size | (stub) |
| lane 3: wait p50 at interval 100 | ~50 ticks | (stub) |
The lane-1 surprise: I expected degradation, got writer starvation. std::sync::Mutex is unfair; the scanner finishes a ~0.6 ms scan and wins the lock again before the parked writer wakes. p50 stayed 125 ns because the handful of writes that got through ran uncontended. Interference at its worst isn’t slower writes — it’s no writes. (Exercise 2: RwLock changes fairness, not the fight.)
Methodology note: lane 1 originally did a fixed 200K writes; with the scanner holding ~0.6 ms locks that serializes into minutes. Switched to a fixed 2 s window per mode — the writes-completed collapse became the headline number instead of the runtime.
Guide-question checklist
- reading-tidb-htap.md Q1–Q6 (Q6: what plays commit index in M32)
- reading-tiflash-deltatree.md Q1–Q6 (Q6: delta index for matrices)
- reading-hyper-hana.md Q1–Q6 (Q6: fork() vs delta-matrix replica)
- reading-f1-lightning.md Q1–Q6 (Q6: safe timestamp in M32 router)
Cross-topic threads (worked)
- The same fold, four costumes: topic 4 LSM minor compaction, HANA delta
merge, TiFlash segmentMergeDelta, FalkorDB delta-matrix flush. All pin
the identical invariant: scans unchanged, write side emptied — which is
literally
merge_preserves_scans_and_sorts_main. - Freshness has exactly two prices: a wait (TiFlash
doLearnerRead, lane 3) or staleness (Lightning safe timestamp, lane 2’s gap table). Every HTAP design picks one; there is no third option. - Topic 27’s changelog is the load-bearing wall:
RowStore.loghere, Changepump at Google, Raft log at PingCAP. “The log is the database,” third appearance.
Capstone M32 log
- Architecture choice: Lightning-shaped (CDC from M27’s changelog into a matrix replica), not TiFlash-shaped — FalkorDB has no consensus group until M15 lands, and decoupling means zero primary changes.
- Router contract sketched in README exercise 6 + f1-lightning Q6:
replicas advertise
applied_lsn(safe timestamp); queries carry a freshness bound; router serves stale-but-consistent, waits, or falls back to primary. - Before-shot recorded: 69 writes/2 s when analytics shares the copy. M32’s success metric is restoring the 11.4M while scans run elsewhere.
Infra notes
- Cloned this topic: ~/repos/tiflash, ~/repos/tidb.
- Anchors verified: LearnerRead.cpp:35/:61, DeltaMergeStore.h:107/:668, Segment.h:84/:715, DeltaValueSpace.h:65, DeltaIndex.h:27, find_best_task.go:535/:1841/:1878.
- Crate: 5 provided tests green (row.rs oracle + bench helpers), 7 stub
tests fix contracts for replica.rs (4) and learner.rs (3). Lanes 2-3
print
[stub …]banners via catch_unwind until implemented.
Done when
- All 12 tests pass; lanes 2-3 print real numbers.
- Lane 2 crossover found (exercise 3) and compared to TiFlash’s delta-merge trigger.
- Lane 3 re-run with bounded staleness (exercise 4).
- All 24 guide questions answered in writing.
- M32 router sketch upgraded to a design note with
read_waitanalogue signed off.
Capstone — falkordb-rs-next-gen, from scratch
The capstone is a clean-room rebuild of falkordb-rs-next-gen:
a Cypher property-graph database in Rust, built one milestone per curriculum topic.
Working name: falkordb-scratch (rename at will).
Why rebuild something you already work on? Because on the real project you inherit
decisions; here you make every one — and benchmark it against the real thing. The
reference implementation lives at ~/repos/falkordb-rs-next-gen; every milestone ends
by comparing your design and numbers against the corresponding module there.
Target architecture (mirrors the reference)
flowchart TD
CLIENT["FalkorDB clients<br/>(falkordb-py, redis-cli, ...)"]
CLIENT --> RESP["RESP server — GRAPH.QUERY / GRAPH.RO_QUERY<br/>wire-compatible · M7"]
RESP --> PARSE["Cypher parser → binder<br/>M10"]
PARSE --> PLAN["planner / optimizer<br/>M10 · egg rewrites M21"]
PLAN --> RT["vectorized runtime — batches, operators, expression eval<br/>M11 · SIMD M17 · JIT M19 · GPU M18"]
RT --> CORE["graph core: sparse/delta matrices — own GraphBLAS-subset kernels<br/>M13 naive → M20 sparse · + attribute store, string pool, datablocks M2"]
CORE --> TXN["MVCC copy-on-write graph M8 · constraints ·<br/>indexes: range M3/M26, vector M14, full-text M23"]
TXN --> PERSIST["persistence: WAL + recovery M5 · B+tree M3 / LSM M4 backends ·<br/>buffer pool M6 · tiered object storage M28"]
PERSIST --> DIST["replication: WAL-shipping → Raft M15 ·<br/>cross-shard txns M29 · active-active CRDT M31"]
QA["correctness & perf spine:<br/>DST + fuzzing + openCypher TCK M16 ·<br/>TLA+/Lean M21 · LDBC benches M22"]
QA -.->|guards| RT
QA -.-> CORE
QA -.-> PERSIST
Ground rules
- Cargo workspace; crates added as milestones demand, not upfront.
- No peeking first: design and build from the topic’s concepts, then read the reference module and compare — the diff is where the learning is.
- Every milestone lands with: tests + a criterion benchmark + a
notes.mdentry comparing your approach vs the reference (design and numbers). - Correctness bar grows over time: openCypher TCK subset (M16 onward) is the oracle.
- Unsafe allowed where the lesson requires it — with Miri runs.
Milestone map
Milestones M0–M31 map 1:1 to curriculum topics 0–31 in PLAN.md; each topic’s
“Capstone milestone” line defines the scope. Status lives in PROGRESS.md.
Rough dependency spine: M0 → M2 → M13 (naive adjacency graph) → M10/M11 (query engine) → M20 (sparse-matrix core replaces M13). Everything else attaches to that spine — persistence (M3–M6), server (M7), MVCC/concurrency (M8/M9), indexes (M12/M14/M23), distribution (M15), correctness (M16/M21), performance (M17/M18/M19/M22).
flowchart LR
M0["M0<br/>workspace +<br/>bench harness"] --> M2["M2<br/>attribute store +<br/>datablocks"]
M2 --> M13["M13<br/>naive adjacency<br/>graph core"]
M13 --> M10["M10<br/>parser +<br/>planner"]
M10 --> M11["M11<br/>vectorized<br/>runtime"]
M11 --> M20["M20<br/>sparse-matrix core<br/>(the heart)"]
P["persistence<br/>M3–M6"] -.-> M13
S["server<br/>M7"] -.-> M10
C["MVCC + concurrency<br/>M8 / M9"] -.-> M13
I["indexes<br/>M12 / M14 / M23 / M26"] -.-> M20
D["distribution<br/>M15 / M28 / M29 / M31"] -.-> M20
Q["correctness<br/>M16 / M21"] -.-> M11
PF["performance<br/>M17 / M18 / M19 / M22"] -.-> M20
Workspace is created at M0 (topic 0). Nothing lives here until then.
Reference baselines — falkordb-rs-next-gen
Numbers to chase. Recorded at M0; re-measure at every milestone that claims a win.
Provenance (Fair Benchmarking §3.1 — reproducibility)
- Reference:
~/repos/falkordb-rs-next-gen@e8a44d25(2026-07-02), release buildtarget/release/libfalkordb.dylib— note: working tree had uncommitted changes tograph/src/graph/graphblas/*at build time, so treat these as “e8a44d25-ish”; rebuild from a clean commit before quoting anywhere serious. - Harness: the reference’s own
tests/test_bench.py(pytest-benchmark), local redis-server loading the module, Pythonfalkordbclient. - Machine: Apple Silicon macOS (same box as all topic benchmarks), idle.
- Run: 2026-07-10,
venv/bin/pytest tests/test_bench.py::<id> --benchmark-json=...(subset: n ∈ {1000, 100000}; add scales by extending the id list).
Methodology caveats (topic 0 lens — read before comparing)
- Closed loop (pytest-benchmark): these are service times through a Python client, not latency under load. No coordinated-omission story here because there is no target rate — fine for engine-throughput comparisons, useless for tail claims.
test_return(~100 µs median) is the client + RESP + dispatch floor: every other number includes it. Engine-side cost ≈ measured − floor for small queries.match_*results stream all rows back through the Python client — at 100K rows the client-side parse dominates. Compare capstone numbers with the same client, or not at all (apples vs oranges, §3.3).- create/delete benches: 5 rounds, fresh graph per round (
benchmark.pedantic).
Results (median, per full query)
| Benchmark | n=1,000 | n=100,000 | derived (100K) |
|---|---|---|---|
RETURN 1 | 100 µs | — | round-trip floor |
| unwind range | 1.79 ms | 177 ms | 565 K rows/s produced+returned |
| create_node | 555 µs | 34.1 ms | 2.9 M nodes/s |
| create_relationship (2 nodes + 1 edge each) | 1.01 ms | 94.4 ms | 1.06 M edges/s |
| match_node (return all) | 5.73 ms | 667 ms | 150 K rows/s end-to-end |
| match_relationship (return n,r,m) | 14.8 ms | 1.78 s | 56 K rows/s end-to-end |
| delete_node | 518 µs | 28.1 ms | 3.6 M deletes/s |
| delete_relationship | 861 µs | 46.3 ms | 2.2 M deletes/s |
What M-milestones should chase
- M13 (naive graph core): create_node / create_relationship / delete_* engine-side throughput — beat the numbers above minus the RESP+Python floor.
- M7 (RESP server): reproduce
test_return’s ~100 µs floor with falkordb-py against the capstone server; then measure properly (open-loop + HdrHistogram). - M20 (sparse core): match_* traversal-side; use LDBC (M22), not these micro matches, for the headline comparison.
Papers, Articles, Books, Courses
Per-topic papers are listed in PLAN.md. This file is the consolidated library plus
foundations that span topics.
Read-first foundations
- “Architecture of a Database System” — Hellerstein, Stonebraker, Hamilton (2007). The map of the territory. Read before topic 1.
- Designing Data-Intensive Applications — Kleppmann. Best breadth book; ch. 3 (storage), 5, 7–9 are core.
- Database Internals — Alex Petrov. The companion book to this whole plan (part I = topics 1–6, part II = topic 15).
- CMU 15-445 (intro, Pavlo) and 15-721 (advanced) — lectures free on YouTube. 15-721 readings overlap heavily with PLAN.md.
- The Redbook (Readings in Database Systems, 5th ed) — redbook.io.
Classics (by area)
- Storage: O’Neil “LSM-Tree” ’96 · Comer “Ubiquitous B-Tree” ’79 · Graefe “Modern B-Tree Techniques” · “RUM Conjecture” ’16
- Recovery: Mohan “ARIES” ’92 · “Aether: Scalable WAL” VLDB’10
- Buffer/memory: “Are You Sure You Want to Use MMAP?” CIDR’22 · “LeanStore” ICDE’18 · “vmcache” SIGMOD’23
- Transactions: Berenson “Critique of ANSI Isolation” ’95 · Kung/Robinson “Optimistic Methods for Concurrency Control” TODS’81 · “SSI in PostgreSQL” VLDB’12 · “Hekaton” SIGMOD’13 · Wu/Pavlo “In-Memory MVCC Evaluation” VLDB’17
- Indexing: Leis “ART” ICDE’13 · “Bw-Tree” ICDE’13 + “More Than Buzz Words” SIGMOD’18
- LSM tuning: “Monkey” SIGMOD’17 · “Dostoevsky” SIGMOD’18 · RocksDB TODS’21 · “LSM Compaction Design Space” VLDB’21
- Query optimization: Selinger “Access Path Selection” ’79 · “How Good Are Query Optimizers, Really?” VLDB’15 · Graefe “Cascades” ’95
- Execution: “MonetDB/X100” CIDR’05 · “Compiled vs Vectorized” VLDB’18 · “Morsel-Driven Parallelism” SIGMOD’14 · Neumann “HyPer compilation” VLDB’11
- Columnar: “C-Store” VLDB’05 · “Compression + Execution in Column Stores” SIGMOD’06 · “BtrBlocks” SIGMOD’23 · “FSST” VLDB’20
- Graph: Davis “SuiteSparse:GraphBLAS” TOMS · “Kùzu” CIDR’23 · “EmptyHeaded” · Ngo et al. worst-case optimal joins · LDBC SNB spec
- Vector/ANN: “HNSW” arXiv:1603.09320 · Jégou “Product Quantization” PAMI’11 · “DiskANN” NeurIPS’19
- Distributed: “Raft” ATC’14 · “Viewstamped Replication Revisited” · “Spanner” OSDI’12 · “Percolator” OSDI’10 · “Calvin” SIGMOD’12
- Testing: “SQLancer/PQS” OSDI’20 · “TLP” OOPSLA’20 · Jepsen analyses (jepsen.io/analyses)
- Perspective: “OLTP Through the Looking Glass” SIGMOD’08 · “What’s Really New with NewSQL?” ’16
Modern systems & directions (post-2015, dbscholar-audited)
Cross-checked 2026-07-12 against Ryan Marcus’s citation-PageRank ranking (https://rmarcus.info/dbscholar — SIGMOD/VLDB/CIDR/PODS citation graph; the old blog post is https://rmarcus.info/blog/2023/07/25/papers.html). Everything below is either linked from a topic README or listed here:
- Query optimization, learned: Kipf “Learned Cardinalities” CIDR’19 · “Neo” VLDB’19 · Marcus “Bao” SIGMOD’21 (→ topic 10)
- Planner/executor as libraries: “Apache Calcite” SIGMOD’18 · “Velox” VLDB’22 (→ topics 10, 11)
- Engines: “Photon” SIGMOD’22 · “Umbra” CIDR’20 · “DuckDB” SIGMOD’19 demo + CIDR’20 (→ topics 6, 11, 19)
- Cloud: “Aurora” SIGMOD’17 · “Snowflake” SIGMOD’16 · “Socrates” SIGMOD’19 · “Lakehouse” CIDR’21 + “Delta Lake” VLDB’20 · “CockroachDB” SIGMOD’20 (→ topics 28, 29)
- Columnar/nested: “Dremel” VLDB’10 (→ topic 12) · “ClickHouse” VLDB’24
- Streaming: “MillWheel” VLDB’13 · “DBSP” VLDB’23 (→ topic 27)
- Time-series: “Gorilla” VLDB’15 · “Monarch” VLDB’20 (→ topic 30)
- HTAP: “TiDB” VLDB’20 · “F1 Lightning” VLDB’20 (→ topic 32)
- Learned indexes: Kraska “The Case for Learned Index Structures” SIGMOD’18 → PGM → ALEX (→ topic 26)
- Graph query languages: “Graph Pattern Matching in GQL and SQL/PGQ” SIGMOD’22 · “G-CORE” SIGMOD’18 (→ topic 13)
arXiv monitoring
Interesting recent arXiv finds go here with a one-line why. Search these when starting a topic:
- cs.DB new submissions: https://arxiv.org/list/cs.DB/recent
- Queries that pay off: “learned index”, “LSM compaction”, “vector search filtering”, “cardinality estimation deep learning”, “worst-case optimal join”
Blogs & talks worth following
- Andy Pavlo (databases yearly review) · CedarDB blog · DuckDB blog · turso blog (DST posts)
- Justin Jaffray (query engines) · Phil Eaton (eatonphil.com — builds DBs from scratch)
- Marc Brooker (AWS, distributed systems) · antithesis.com blog (DST)
- Jepsen analyses · valkey engineering blog · qdrant tech blog
Reference Codebases
The user-provided list plus additions, annotated with what each is best for studying.
Clone the ones in active use to ~/repos/.
Your codebases (baseline)
| Repo | Best for |
|---|---|
| FalkorDB/FalkorDB | Sparse-matrix graph engine, redis module architecture |
| FalkorDB/falkordb-rs-next-gen | Rust graph engine rewrite |
From your list
| Repo | Lang | Best for |
|---|---|---|
| redis/redis | C | dict incremental rehash, skiplists, event loop, RESP, AOF/RDB, rax |
| valkey-io/valkey | C | io-threads/multithreading evolution vs redis — diff the two! |
| qdrant/qdrant | Rust | HNSW, filtered ANN, quantization, raft consensus |
| surrealdb/surrealdb | Rust | multi-model design, transaction layer over pluggable KV |
| facebook/rocksdb | C++ | LSM at industrial scale: compaction, block cache, txn utilities |
| tursodatabase/turso | Rust | SQLite rewrite: B-tree, pager, WAL, io_uring, DST |
| neo4j/neo4j | Java | record-store graph layout, Cypher planner |
| HelixDB/helix-db | Rust | graph+vector combined engine (young codebase, easy to read) |
| memgraph/memgraph | C++ | in-memory graph, skip-list storage, MVCC |
| ravendb/ravendb | C# | Voron storage engine (COW B+tree), document DB design |
| fjall-rs/fjall | Rust | the readable Rust LSM — small enough to read fully |
| tidesdb/tidesdb | C | compact C LSM, easy first read |
| duckdb/duckdb | C++ | vectorized execution, optimizer, columnar compression — very readable |
| postgres/postgres | C | MVCC, WAL, buffer manager, planner — the canon |
Suggested additions
| Repo | Lang | Best for |
|---|---|---|
| sqlite/sqlite | C | btree.c, pager, VDBE — most-deployed DB on earth |
| LMDB (openldap/mdb) | C | copy-on-write B+tree, single-file mmap design |
| skyzh/mini-lsm | Rust | guided course — build an LSM step by step (use in topic 4) |
| cmu-db/bustub | C++ | CMU 15-445 teaching DB: buffer pool, B+tree, txn labs |
| erikgrinaker/toydb | Rust | reference for the capstone: raft + MVCC + SQL, written to teach |
| apache/datafusion | Rust | Arrow-native query engine — planner + vectorized exec in Rust |
| pola-rs/polars | Rust | vectorized columnar engine: lazy optimizer, streaming exec, SIMD kernels — DuckDB’s Rust rival |
| kuzudb/kuzu | C++ | columnar graph storage, worst-case optimal joins (topic 13) |
| cberner/redb | Rust | clean embedded COW B-tree in Rust |
| spacejam/sled | Rust | Bw-tree-inspired engine; read its post-mortems too |
| tikv/tikv | Rust | raft-rs, distributed txn (Percolator) — topic 15 |
| apple/foundationdb | C++ | deterministic simulation testing gold standard — topic 16 |
| ClickHouse/ClickHouse | C++ | columnar OLAP at the extreme; read specific MergeTree parts only |
| unum-cloud/usearch | C++ | compact single-header HNSW — topic 14 |
| DrTimothyAldenDavis/GraphBLAS | C | SuiteSparse internals — go deeper than the API you already use |
| GraphBLAS/LAGraph | C | graph algorithms as linear algebra — the reference library over GraphBLAS (topics 20, 24) |
| Z3Prover/z3 | C++ | SMT solver: query-equivalence proving, invariant checking (topic 16); also a perf-engineering masterclass |
| quickwit-oss/tantivy | Rust | inverted index / full-text engine — the readable Lucene (topic 23) |
| apache/lucene | Java | the canon of search: codecs, FSTs, segment merging (topic 23) |
| elastic/elasticsearch | Java | distributed search architecture over Lucene: shards, scatter-gather (topic 23) |
| RediSearch/RediSearch | C | search as a redis module — your ecosystem’s approach (topic 23) |
| leanprover/lean4 | C++/Lean | theorem proving + Perceus RC runtime (topic 21) |
| modular/mojo | Mojo | SIMD-first language on MLIR, CPU+GPU kernels (topics 17, 18) |
| TimelyDataflow/differential-dataflow | Rust | incremental computation — the real thing (topic 27) |
| neondatabase/neon | Rust | disaggregated postgres: pageserver, safekeepers, branching (topic 28) |
| slatedb/slatedb | Rust | LSM directly on object storage — small, modern (topic 28) |
| prometheus/prometheus | Go | tsdb/: readable time-series storage (topic 30) |
| influxdata/influxdb | Rust | IOx: DataFusion+Parquet+object storage combined (topics 11/12/28/30) |
| RoaringBitmap/CRoaring | C | compressed bitmaps: container switching, SIMD set ops (topics 23, 26) |
| automerge/automerge | Rust | CRDT engine — state/op-based, columnar op storage (topic 31) |
| loro-dev/loro | Rust | fast modern CRDT engine, great perf blog posts (topic 31) |
Tools
Benchmarking
| Tool | Use |
|---|---|
| criterion.rs | Rust microbenchmarks (statistical, fights noise) |
| divan | faster-iteration Rust benches |
| hyperfine | CLI-level benchmarks |
| redis-benchmark / memtier_benchmark | RESP server load testing |
| YCSB (or rust port) | standard KV workloads A–F |
| BenchBase (CMU) | TPC-C, TPC-H, and 20+ workloads against SQL DBs |
| ClickBench | analytics benchmark (topic 12) |
| ann-benchmarks | recall/QPS curves for vector indexes (topic 14) |
| LDBC SNB | graph benchmark standard (topic 13) |
| pgbench | postgres load gen |
Profiling & observation
| Tool | Use |
|---|---|
| cargo flamegraph / samply | CPU flamegraphs on macOS/Linux |
perf (Linux) + perf stat -d | hardware counters: cache misses, branch misses, IPC |
| perf c2c | false sharing detection (topic 9) |
| Instruments (macOS) | time profiler, allocations, syscalls |
| heaptrack / dhat-rs | allocation profiling |
| bpftrace / eBPF | fsync latency, block IO tracing (topic 5) |
| iostat / fio | raw disk characterization — know your hardware baseline |
| tokio-console | async runtime introspection (topic 7) |
Correctness
| Tool | Use |
|---|---|
| proptest / quickcheck | property-based testing vs model oracle |
| cargo-fuzz (libFuzzer) | fuzz parsers, SST/page decoders |
| Miri | UB detection in unsafe Rust |
| loom | exhaustive interleaving checks for lock-free code (topic 9) |
| ThreadSanitizer / ASan | C/C++ and FFI sanitizing |
| SQLancer | logic-bug finding in SQL engines |
| Jepsen + elle | distributed consistency checking (topic 15) |
| TLA+ / PlusCal | model checking protocols (optional, topic 15/16) |
Z3 (via z3 crate) | SMT solving: prove query rewrites equivalent, check invariants (topic 16) |
| strace / dtruss | verify what syscalls actually happen (fsync lies) |
Rust crates that recur
tokio, crossbeam (epoch, skiplist), hashbrown, parking_lot, memmap2,
io-uring, arrow/parquet, sqlparser, openraft, rand/zipf (workload gen)