indexplanprogresstopics-00-performance-toolbox-indextopics-00-performance-toolbox-reading-criteriontopics-00-performance-toolbox-reading-dreppertopics-00-performance-toolbox-reading-fair-benchmarkingtopics-00-performance-toolbox-reading-redis-benchmarktopics-00-performance-toolbox-reading-rocksdb-db-benchtopics-00-performance-toolbox-notestopics-01-storage-engine-landscape-indextopics-01-storage-engine-landscape-reading-architecture-of-a-dbmstopics-01-storage-engine-landscape-reading-comer-btreetopics-01-storage-engine-landscape-reading-fjalltopics-01-storage-engine-landscape-reading-lsm-papertopics-01-storage-engine-landscape-reading-rocksdb-layouttopics-01-storage-engine-landscape-reading-rum-conjecturetopics-01-storage-engine-landscape-reading-tidesdbtopics-01-storage-engine-landscape-reading-turso-btreetopics-01-storage-engine-landscape-notestopics-02-in-memory-structures-indextopics-02-in-memory-structures-reading-art-papertopics-02-in-memory-structures-reading-hashbrowntopics-02-in-memory-structures-reading-redis-dicttopics-02-in-memory-structures-reading-redis-raxtopics-02-in-memory-structures-reading-redis-skiplisttopics-02-in-memory-structures-reading-rocksdb-memtabletopics-02-in-memory-structures-reading-swisstable-talktopics-02-in-memory-structures-notestopics-03-btree-internals-indextopics-03-btree-internals-reading-graefe-surveytopics-03-btree-internals-reading-lmdbtopics-03-btree-internals-reading-sqlite-btreetopics-03-btree-internals-reading-sqlite-file-formattopics-03-btree-internals-reading-turso-btree-deeptopics-03-btree-internals-notestopics-04-lsm-deep-dive-indextopics-04-lsm-deep-dive-reading-compaction-design-spacetopics-04-lsm-deep-dive-reading-dostoevskytopics-04-lsm-deep-dive-reading-lsm-treetopics-04-lsm-deep-dive-reading-monkeytopics-04-lsm-deep-dive-reading-rocksdb-compactiontopics-04-lsm-deep-dive-reading-rocksdb-todstopics-04-lsm-deep-dive-notestopics-05-durability-wal-indextopics-05-durability-wal-reading-aethertopics-05-durability-wal-reading-ariestopics-05-durability-wal-reading-postgres-xlogtopics-05-durability-wal-reading-redis-aof-rdbtopics-05-durability-wal-reading-turso-waltopics-05-durability-wal-notestopics-06-buffer-pool-indextopics-06-buffer-pool-reading-duckdb-buffertopics-06-buffer-pool-reading-leanstore-papertopics-06-buffer-pool-reading-leanstoretopics-06-buffer-pool-reading-mmap-papertopics-06-buffer-pool-reading-postgres-bufmgrtopics-06-buffer-pool-reading-redis-zmalloctopics-06-buffer-pool-notestopics-07-networking-protocols-indextopics-07-networking-protocols-reading-bolt-packstreamtopics-07-networking-protocols-reading-c10k-thread-per-coretopics-07-networking-protocols-reading-pgwire-qdranttopics-07-networking-protocols-reading-redis-ae-networkingtopics-07-networking-protocols-reading-valkey-iothreadstopics-07-networking-protocols-notestopics-08-transactions-mvcc-indextopics-08-transactions-mvcc-reading-ansi-critiquetopics-08-transactions-mvcc-reading-inmemory-mvcctopics-08-transactions-mvcc-reading-postgres-heapamtopics-08-transactions-mvcc-reading-rocksdb-transactionstopics-08-transactions-mvcc-reading-ssi-postgrestopics-08-transactions-mvcc-reading-surrealdb-txtopics-08-transactions-mvcc-notestopics-09-concurrency-indextopics-09-concurrency-reading-bwtreetopics-09-concurrency-reading-concurrent-skipliststopics-09-concurrency-reading-crossbeam-epochtopics-09-concurrency-reading-postgres-lwlocktopics-09-concurrency-notestopics-10-query-planning-indextopics-10-query-planning-reading-duckdb-optimizertopics-10-query-planning-reading-how-good-optimizerstopics-10-query-planning-reading-postgres-optimizertopics-10-query-planning-reading-rust-planner-stacktopics-10-query-planning-reading-selinger-cascadestopics-10-query-planning-notestopics-11-execution-models-indextopics-11-execution-models-reading-compiled-vs-vectorizedtopics-11-execution-models-reading-duckdb-executiontopics-11-execution-models-reading-morsel-parallelismtopics-11-execution-models-reading-postgres-executortopics-11-execution-models-reading-rust-execution-stacktopics-11-execution-models-reading-x100topics-11-execution-models-notestopics-12-columnar-analytics-indextopics-12-columnar-analytics-reading-arrow-parquettopics-12-columnar-analytics-reading-btrblocks-fssttopics-12-columnar-analytics-reading-clickhouse-mergetreetopics-12-columnar-analytics-reading-clickhouse-papertopics-12-columnar-analytics-reading-cstore-compressiontopics-12-columnar-analytics-reading-duckdb-compressiontopics-12-columnar-analytics-notestopics-13-graph-engines-indextopics-13-graph-engines-reading-graphblas-internalstopics-13-graph-engines-reading-kuzutopics-13-graph-engines-reading-ldbc-snbtopics-13-graph-engines-reading-memgraph-storagetopics-13-graph-engines-reading-neo4j-record-storetopics-13-graph-engines-reading-query-languagestopics-13-graph-engines-reading-wcojtopics-13-graph-engines-notestopics-14-vector-search-indextopics-14-vector-search-reading-diskanntopics-14-vector-search-reading-hnsw-papertopics-14-vector-search-reading-pqtopics-14-vector-search-reading-qdrant-hnswtopics-14-vector-search-reading-qdrant-quantizationtopics-14-vector-search-reading-usearchtopics-14-vector-search-notestopics-15-replication-consensus-indextopics-15-replication-consensus-reading-ddia-repltopics-15-replication-consensus-reading-qdrant-consensustopics-15-replication-consensus-reading-raft-papertopics-15-replication-consensus-reading-raft-rstopics-15-replication-consensus-reading-valkey-replicationtopics-15-replication-consensus-reading-vsrtopics-15-replication-consensus-notestopics-16-testing-correctness-indextopics-16-testing-correctness-reading-fdb-simulationtopics-16-testing-correctness-reading-jepsentopics-16-testing-correctness-reading-pqs-tlp-paperstopics-16-testing-correctness-reading-sqlancertopics-16-testing-correctness-reading-turso-simulatortopics-16-testing-correctness-reading-z3topics-16-testing-correctness-notestopics-17-simd-indextopics-17-simd-reading-fastlanestopics-17-simd-reading-hashbrown-simdtopics-17-simd-reading-mojo-simdtopics-17-simd-reading-polars-computetopics-17-simd-reading-sigmod15-vectorizationtopics-17-simd-reading-simdjsontopics-17-simd-reading-simsimdtopics-17-simd-notestopics-18-gpu-indextopics-18-gpu-reading-cagratopics-18-gpu-reading-crystal-sigmod20topics-18-gpu-reading-faiss-gputopics-18-gpu-reading-gunrocktopics-18-gpu-reading-libcudftopics-18-gpu-reading-wgpu-computetopics-18-gpu-notestopics-19-jit-indextopics-19-jit-reading-cranelift-jit-demotopics-19-jit-reading-graphblas-jittopics-19-jit-reading-neumann-vldb11topics-19-jit-reading-postgres-jittopics-19-jit-reading-sqlite-vdbetopics-19-jit-reading-umbra-tidy-tuplestopics-19-jit-notestopics-20-graphblas-indextopics-20-graphblas-reading-beamer-sc12topics-20-graphblas-reading-davis-toms19topics-20-graphblas-reading-falkordb-delta-matrixtopics-20-graphblas-reading-gustavson-spgemmtopics-20-graphblas-reading-lagraphtopics-20-graphblas-reading-openmp-vs-rayontopics-20-graphblas-reading-suitesparse-internalstopics-20-graphblas-notestopics-21-formal-indextopics-21-formal-reading-aws-cacm15topics-21-formal-reading-egg-popl21topics-21-formal-reading-lean-perceustopics-21-formal-reading-tlaplus-rafttopics-21-formal-reading-z3-tacas08topics-21-formal-notestopics-22-benchmarks-indextopics-22-benchmarks-reading-boncz-tpchtopics-22-benchmarks-reading-duckdb-tpchtopics-22-benchmarks-reading-oltpbench-tpcctopics-22-benchmarks-reading-ycsbtopics-22-benchmarks-notestopics-23-fulltext-indextopics-23-fulltext-reading-blockmax-wandtopics-23-fulltext-reading-bm25topics-23-fulltext-reading-redisearchtopics-23-fulltext-reading-roaringtopics-23-fulltext-reading-tantivytopics-23-fulltext-reading-zobel-moffattopics-23-fulltext-notestopics-24-graph-algorithms-indextopics-24-graph-algorithms-reading-brandestopics-24-graph-algorithms-reading-delta-steppingtopics-24-graph-algorithms-reading-gaptopics-24-graph-algorithms-reading-lagraph-algostopics-24-graph-algorithms-reading-ligratopics-24-graph-algorithms-reading-louvain-leidentopics-24-graph-algorithms-notestopics-25-graph-ml-indextopics-25-graph-ml-reading-gattopics-25-graph-ml-reading-gcntopics-25-graph-ml-reading-graphrag-sdktopics-25-graph-ml-reading-graphsagetopics-25-graph-ml-reading-node2vectopics-25-graph-ml-reading-pyg-message-passingtopics-25-graph-ml-reading-transetopics-25-graph-ml-notestopics-26-probabilistic-indextopics-26-probabilistic-reading-bloom-to-ribbontopics-26-probabilistic-reading-cuckoo-xortopics-26-probabilistic-reading-geo-indexestopics-26-probabilistic-reading-hyperloglogtopics-26-probabilistic-reading-learned-indexestopics-26-probabilistic-reading-postgres-indexamtopics-26-probabilistic-reading-roaring-internalstopics-26-probabilistic-notestopics-27-streaming-indextopics-27-streaming-reading-dbsptopics-27-streaming-reading-differential-dataflowtopics-27-streaming-reading-kafka-logtopics-27-streaming-reading-materialize-risingwavetopics-27-streaming-reading-naiad-timelytopics-27-streaming-notestopics-28-cloud-native-indextopics-28-cloud-native-reading-auroratopics-28-cloud-native-reading-neontopics-28-cloud-native-reading-slatedb-quickwittopics-28-cloud-native-reading-snowflake-s3topics-28-cloud-native-reading-socratestopics-28-cloud-native-notestopics-29-distributed-txn-indextopics-29-distributed-txn-reading-calvintopics-29-distributed-txn-reading-foundationdbtopics-29-distributed-txn-reading-percolator-tikvtopics-29-distributed-txn-reading-spanner-hlctopics-29-distributed-txn-notestopics-30-timeseries-indextopics-30-timeseries-reading-gorillatopics-30-timeseries-reading-monarch-btrdbtopics-30-timeseries-reading-prometheus-tsdbtopics-30-timeseries-reading-victoriametrics-influxtopics-30-timeseries-notestopics-31-crdts-indextopics-31-crdts-reading-cr-sqlitetopics-31-crdts-reading-kleppmann-json-crdtstopics-31-crdts-reading-sequence-crdtstopics-31-crdts-reading-shapiro-crdtstopics-31-crdts-notestopics-32-htap-indextopics-32-htap-reading-f1-lightningtopics-32-htap-reading-hyper-hanatopics-32-htap-reading-tidb-htaptopics-32-htap-reading-tiflash-deltatreetopics-32-htap-notescapstone-indexcapstone-baselinesresources-papersresources-codebasesresources-tools

Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
  1. Open PROGRESS.md to see where we are.
  2. Pick the next topic (or any topic — order is a suggestion, not a rule) from PLAN.md.
  3. 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.
  4. Study, implement experiments, benchmark, take notes in notes.md.
  5. Implement the topic’s capstone milestone in capstone/.
  6. 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, perf counters, flamegraphs, latency percentiles vs throughput, roofline thinking.
  • Read code: criterion.rs internals (how it fights noise), RocksDB db_bench, redis redis-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 Vec scan vs HashMap lookup vs BTreeMap across sizes; produce flamegraphs; observe cache-line effects (seq vs random access).
  • Capstone milestone M0: scaffold the falkordb-scratch workspace + 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.rs after 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, redis t_zset.c (skiplist), hashbrown, RocksDB memtable/ (concurrent skiplist), redis rax.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 hashbrown and crossbeam-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), SQLite btree.c (the classic), LMDB mdb.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, redis zmalloc.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 own src/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-benchmark and memtier_benchmark against 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, RocksDB utilities/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 c2c for false sharing.
  • Capstone M9: threadpool + concurrent readers with single writer; parallel query execution (compare with the reference’s threadpool.rs design).

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!), postgres optimizer/ (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), postgres executor/ (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/, ClickHouse MergeTree/ (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/), memgraph storage/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.

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.rs territory).

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.rs bindings; 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/ and tck_done.txt show 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 vs std::simd and intrinsics).
  • Read code: polars crates/polars-compute/ kernels, simdjson (the masterclass — read with the paper), hashbrown SIMD group probing, DuckDB compressed-scan kernels, usearch/SimSIMD distance functions, memchr crate, Mojo stdlib + Modular’s matmul optimization blog series.
  • Papers: “Rethinking SIMD Vectorization for In-Memory Databases” (SIGMOD’15), “Parsing Gigabytes of JSON per Second” (simdjson, VLDB’19), “The FastLanes Compression Layout” (VLDB’23).
  • Build & bench: write filter-selection and dot-product kernels four ways: naive scalar, autovectorized, std::simd, NEON intrinsics; bench with perf stat (IPC, vector-lane utilization); then SIMD-ize a bit-packing decoder and compare against topic 12’s scalar version; port one kernel to Mojo and compare both the numbers and the code you had to write.
  • 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: wgpu compute 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), postgres src/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 for static 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 in GB_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/graphblas layer.

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 in src/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-scratch vs 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_lib in 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), redis geo.c/geohash.c (the zset trick end-to-end), s2geometry or h3 (cell covering APIs), RocksDB util/bloom* + ribbon filter, redis hyperloglog.c (the dense/sparse encoding dance — a classic), RedisBloom module, CRoaring + roaring-rs (container switching, SIMD intersections), Lucene RoaringDocIdSet, 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-rs and a plain HashSet<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) answering WHERE 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), CockroachDB kv/txn coordinator + 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 OF watermark), 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: todoin progressdone. Add a one-line takeaway when done.

#TopicStatusTakeaway
0The Performance ToolboxdoneBenchmarks 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.
1Storage Engine Landscape: B-Tree vs LSMin progress
2In-Memory Structures: Hash Tables, Skip Lists, Triestodo
3B-Tree Internals & Paged Storagetodo
4LSM-Tree Deep Divetodo
5Durability: WAL, fsync, Crash Recoverytodo
6Buffer Pool & Memory Managementtodo
7Networking, Protocols & Event Loopstodo
8Transactions & MVCCtodo
9Concurrency: Latches, Lock-Free & Epochstodo
10Query Engines I: Parsing, Planning, Optimizationtodo
11Query Engines II: Execution Modelstodo
12Columnar Storage & Analyticstodo
13Graph Enginestodo
14Vector Searchtodo
15Replication, Consensus & Distributiontodo
16Testing & Correctness Engineeringtodo
17SIMD & Hardware-Conscious Data Processingtodo
18GPU Acceleration for Databasestodo
19JIT & Query Compilationtodo
20Sparse Linear Algebra & GraphBLAS Internalstodo
21Formal Methods & Verificationtodo
22Standard Benchmarks: TPC-H, TPC-C, YCSB, LDBCtodo
23Full-Text Search & Inverted Indexestodo
24Advanced Graph Algorithms & Analyticstodo
25Graph Neural Networks & Graph MLtodo
26Indexing & Probabilistic Data Structurestodo
27Streaming & Incremental View Maintenancetodo
28Cloud-Native & Disaggregated Storagetodo
29Distributed Transactionstodo
30Time-Series Enginestodo
31CRDTs & Multi-Master Replicationtodo
32HTAP Architecturestodo

Capstone milestones (falkordb-rs-next-gen from scratch)

MilestoneDepends on topicStatus
M0 workspace + bench harness + reference baselines0done — workspace + workload gen + smoke bench + BASELINES.md (reference @ e8a44d25)
M1 storage-backend abstraction1todo
M2 attribute store + string pool + datablocks2todo
M3 B+tree backend (properties + range indexes)3todo
M4 LSM backend + backend shootout4todo
M5 WAL + crash recovery5todo
M6 buffer pool6todo
M7 RESP server (GRAPH.QUERY wire-compatible)7todo
M8 MVCC copy-on-write graph8todo
M9 threadpool + parallel execution9todo
M10 Cypher parser + binder + planner10todo
M11 vectorized runtime11todo
M12 columnar attribute storage12todo
M13 naive adjacency graph core (baseline)13todo
M14 vector index + distance kernels14todo
M15 replication → Raft15todo
M16 openCypher TCK runner + DST + fuzzing16todo
M17 SIMD kernels17todo
M18 GPU backend (experimental)18todo
M19 Cypher expression JIT19todo
M20 sparse-matrix/delta-matrix core (the heart)20todo
M21 TLA+ spec + Lean invariant proof21todo
M22 LDBC suite + 3-way FalkorDB shootout22todo
M23 full-text index + hybrid search23todo
M24 algorithm library as Cypher procedures24todo
M25 GNN embeddings pipeline + GraphRAG queries25todo
M26 MVCC secondary indexes + bloom + HLL count path26todo
M27 standing Cypher queries (incremental results)27todo
M28 tiered object storage + graph branching28todo
M29 cross-shard transactions + pattern matching29todo
M30 temporal graph + time-travel queries30todo
M31 active-active graph (CRDT merge)31todo
M32 HTAP: changelog-fed analytical replica + freshness-bound routing32todo

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), ## References appended 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) are todo!() 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) are todo!() 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) are todo!() 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) are todo!() 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 are todo!() 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 workload crate (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:

  1. Write a microbenchmark that isn’t lying to you, and explain why it isn’t.
  2. Read a flamegraph and perf stat-style counters and name the bottleneck (CPU-bound, memory-bound, branch-miss-bound, syscall-bound).
  3. Recite the memory-hierarchy latency ladder from registers to disk within 2x.
  4. 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_box on 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-benchmark got --latency modes 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:

LevelSizeLatency~Cycles
Registerbytes0
L164–192 KB~1 ns3–5
L24–16 MB~3–5 ns12–20
L3 / SLC8–96 MB~10–20 ns40–80
DRAMGBs~80–100 ns300–400
NVMe readTBs~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)

TaskTool
CPU profile + flamegraphsamply record ./target/release/... (opens Firefox Profiler UI), or cargo flamegraph
Microbenchmarkscriterion (stats, regression detection)
Hardware countersInstruments → CPU Counters template (macOS has no perf); for real perf stat work use a Linux box/container
AllocationsInstruments → Allocations, or dhat-rs
SyscallsInstruments → System Trace (dtruss needs SIP off)
Disk baselinefio — know your NVMe’s random-read IOPS and fsync latency before topic 5
CLI-level timinghyperfine

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/)

  1. cache_ladder — stride through arrays of 16KB → 512MB; plot ns/access. You should see L1/L2/SLC/DRAM as plateaus.
  2. lookup_shootout — point lookups: Vec linear scan vs Vec binary search vs HashMap vs BTreeMap, sizes 1e2 → 1e7. Find the crossover where linear scan beats hashing (it exists, and it’s bigger than you think).
  3. branch_misprediction — sum elements > threshold over sorted vs shuffled data. Then make it branchless and watch the gap vanish.
  4. Profile experiment 2 with samply; grab one flamegraph screenshot into notes.md.

8. Capstone milestone M0 (in ../../capstone/)

  • Cargo workspace scaffolded
  • workload crate: 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-gen recorded in capstone/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 of avg_times, each bootstrapped (line 321): resample the sample with replacement nresamples (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): fits total_time = slope × iters through the (iters, times) points — the slope is ns/iteration. Only valid for linear sampling (checked at line 152). The headline time: [lo mid hi] is the slope’s bootstrap CI. Why slope beats mean-of-averages: constant per-sample overhead (measurement, loop setup) inflates every avg_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:

  1. 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.
  2. 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.
  3. Min is statistically fragile for comparison. It’s an extreme-value statistic with no usable sampling distribution — you can’t compute a p-value on “min got 2% slower.” The regression-detection machinery only works because mean/slope have bootstrap distributions.
  4. A point estimate hides confidence. [69.6 70.1 70.5] µs says the measurement is tight; a bare 69.6 hides whether the spread was 1% or 40%.

Suggested reading order in the crate

  1. analysis/mod.rs::common — the spine
  2. stats/univariate/outliers/tukey.rs — fences, ~100 lines
  3. stats/bivariate/regression.rsSlope::fit is ~10 lines of least-squares
  4. analysis/compare.rs + stats/univariate/mixed.rs — the bootstrapped t-test
  5. routine.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 through tukey.rs, regression.rs, compare.rs, and routine.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_ladder experiment; compare his plateau shapes with yours before explaining your numbers in notes.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

  1. Why does cache_ladder show gradual transitions between plateaus rather than steps? (Hint: set associativity + random chain touching multiple sets.)
  2. Predict: on M-series with 128 B lines, at what stride does a strided-read benchmark stop getting faster per element? Verify with a quick experiment.
  3. How many memory accesses can a single TLB miss add on a 4-level page table, and why don’t we see it in cache_ladder? (Hint: 16 KB pages, working set vs TLB reach.)

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)"]
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Cold vs hot runs (3.5) — report them separately; hot runs discard initial iterations (criterion’s warmup, formalized).
  6. 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.)
  7. 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.
  8. 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 workload crate measures generation throughput separately from engine time.

Questions to answer in notes.md

  1. Which Appendix A checklist items does FalkorDB/benchmark currently fail? (I count at least four — list them.)
  2. The paper reports medians + CIs; Tene demands full percentile curves + max. When is each right? (Hint: throughput-style repeated identical runs vs latency under load.)
  3. Which “automatic preprocessing” (3.7) exists in FalkorDB that a fair Neo4j comparison must account for?

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

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

LinesWhat
61–108struct config — all global state, incl. pipeline, two HdrHistograms (99–100)
110–130struct _client — note start, latency, pending
368–375resetClient — the closed loop, in 8 lines
420–439clientDone — finished batch → resetClient (keepalive) or reconnect
442–553readHandler — latency capture + histogram recording
555–602writeHandler — batch start, c->start = ustime()
625+createClient — pipelining via buffer replication
830+showLatencyReport — percentiles off HdrHistogram
946benchmark() — sets up clients, runs the event loop
1696main — 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

  • writeHandler 574: c->start = ustime() when a batch begins writing.
  • readHandler 452: if (c->latency < 0) c->latency = ustime() - c->starton 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 pipeline requests 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:

  1. No target rate exists. The benchmark always sends as fast as the server answers, so a stall (fork for RDB save, AOF fsync, slow command) simply pauses the generator — requests that would have arrived during the stall are never sent, never measured. You get exactly one bad sample per client per stall instead of thousands.
  2. It measures service time and calls it latency. Queueing delay a real open-world client would experience never appears.
  3. HdrHistogram doesn’t save it. Redis added HdrHistogram (config lines 99–100) and full percentile output (830+) — good display of a biased sample. Correction would require an intended-arrival schedule, which doesn’t exist here. (Compare wrk2, which was written to fix exactly this; memtier_benchmark has --rate-limiting.)
  4. Small extra: hdr_record_value clamps at CONFIG_LATENCY_HISTOGRAM_MAX_VALUE (line 530) — the worst outliers are also truncated.

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 + the ae event 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)

LinesWhat
115–170DEFINE_string(benchmarks, ...) — the full workload menu; read the help text below it (172+), it’s the best documentation
275–458The knobs that define a workload: num, threads, value_size, histogram, read_random_exp_range (452)
1708–1717keyrange_dist_a..d — the mixgraph skew model
2436class Stats — per-thread stats, HistogramImpl per op type
2564Stats::FinishedOps — where each op’s micros get recorded
3802GenerateKeyFromInt — int → fixed-width key; all key distributions reduce to picking the int
4030–4140Benchmark::Run() dispatch: name == "fillseq" → method pointer — the map from workload name to implementation
4583RunBenchmark(n, name, method) — spawns N threads, merges per-thread Stats (histogram merge at 2488, same lesson as Tene: merge histograms, never average percentiles)
5869enum WriteMode { RANDOM, SEQUENTIAL, UNIQUE_RANDOM }
6088class 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/*whilescanning variants 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 Zipfian workload crate.
  • 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 plain HistogramImpl — 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. “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.
  6. 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.
  7. 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)

VariantSortedShuffled
branchy338 µs (3.1 Gelem/s)2.75 ms (0.38 Gelem/s)
branchless70 µ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 += x in the branch, LLVM if-converts + auto-vectorizes and the gap vanishes (both ~70µs). Had to put black_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):

nvec_linearvec_binary_searchhashmapbtreemap
10017.83.07.45.0
1e31414.96.87.9
1e41,3508.17.211.1
1e513,40016.08.317.2
1e625.88.826.6
1e744.19.338.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().find linear 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 setns/accessLevel
16KB–128KB1.02L1 (P-core L1d is 128KB — the plateau ends exactly there)
512KB–1MB5.3–5.8L2
4–8MB7.6–9.0L2 (Apple’s per-cluster L2 is huge — 16MB-class)
16MB17.1falling out of L2 into SLC
32MB59.6SLC → DRAM transition
64MB87.4DRAM
128–512MB104–113DRAM + 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 samplerustfiltinferno (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 in FxHashMap/ahash is 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:

  1. Draw the write path and read path of both engine families from memory.
  2. Predict which family wins a given workload (write-heavy / point-read / scan / space-constrained) before benchmarking, then verify.
  3. Explain any measured difference in terms of read/write/space amplification.
  4. 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

WorkloadWinnerWhy (amplification argument)
Write-heavy, random keysLSMsequential IO only; B-tree dirties a random page per write
Point reads, hot working setB-tree1 descent, upper levels cached; LSM pays run-check tax
Range scansB-tree (usually)leaves are one contiguous logical order; LSM merges k runs per scan
Space-constrainedLSM leveled~1.1x SA vs page slack + fragmentation
Cold-cache point readsLSM + bloomsone bloom-guarded IO vs full-height descent
Mixed read/write at scaleit dependsthis 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):

  1. fillrandom — N random-key inserts, measure sustained insert throughput.
  2. fillseq — same N, sequential keys (B-tree best case: no random page dirtying).
  3. readrandom — Zipfian point reads (s=0.99, the M0 generator’s skew) on the loaded DB.
  4. scan — full-range iteration throughput.
  5. 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.
  • storage crate: 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.rs and write a comparison: what did they need that you didn’t predict, and why?

Done when

  • Both experiments’ results are in notes.md with 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

SectionReturn at
§3 parser/rewritertopic 10
§4 query processor internalstopics 10–11
§5 transactions, ACID, lockingtopics 8–9
§7 shared components (catalog, replication)topics 15–16

Questions to answer in notes.md

  1. §6 argues the DBMS should bypass OS caching (O_DIRECT). What are the two distinct problems with letting the OS cache pages? (Double buffering; the OS evicts/flushes with zero knowledge of WAL ordering.)
  2. Which of the five §1 boxes does fjall implement? redb? (Neither has a query processor or client manager — “storage engine” ≠ “database”. The capstone builds the other boxes on top, milestone by milestone.)
  3. 2007 blind spots: name three things the paper couldn’t see coming. (Candidates: NVMe erasing the seek-time mental model, cloud disaggregation — topic 28, columnar dominance for analytics — topic 12, LSM taking over write paths.)

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. §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. §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. §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

  1. Why do B-trees guarantee ≥50% page occupancy, and what’s the measured average (~69%, ln 2)? Connect to space amplification in the README.
  2. B*-tree defers splits by redistributing into siblings. What does turso implement — B+, B*, or a hybrid?
  3. Comer’s B-trees assume one page write is atomic. It isn’t (torn writes). Which later machinery patches this hole? (WAL — topic 5; checksums — topic 3.)

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

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–145 sends WorkerMessage::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

  1. poison_dart.rs:27–33 — a Drop guard that poisons the whole keyspace if a background worker panics. Crash-visibly instead of serving from corrupt state.
  2. ingestion.rs:37–51 — comment explains holding the journal lock across finish() to prevent seqno inversion between writes and bulk ingest. Sequence numbers are the spine of LSM correctness (MVCC preview, topic 8).
  3. 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).
  4. 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

  • fjallsrc/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-tree crate 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. §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. §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
}
  1. §3 (cost model) — the payoff. The key result, in modern words: with batching, each insert’s amortized IO cost is ~(entry_size / page_size) × WA sequential bytes instead of one random page read+write. The COST_π algebra formalizes “sequential bandwidth is ~100x cheaper than random IOPS” — the topic 0 ladder in 1996 dollars.
  2. §4–5 (multi-component + concurrency/recovery) — skim. Multi-component C0…Ck with size ratio r between adjacent components is exactly modern leveled compaction’s fanout-10 geometry; the optimal-r derivation prefigures Monkey/Dostoevsky (topic 4).
  3. §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

  1. 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.)
  2. 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.)
  3. 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"]
DirWhat lives thereAnchor
db/engine core: DBImpl, column families, versions, compactiondb/db_impl/db_impl.h, db/column_family.h
table/SST file formatstable/block_based/, table/format.h
memtable/memtable representationsmemtable/skiplist.h
cache/block/row cachecache/lru_cache.h
file/IO helpers, prefetch, filenamesfile/filename.h
util/blooms, hashing, compressionutil/bloom_impl.h
options/the infamous config surfaceoptions/db_options.h
env/OS abstractionenv/env_posix.cc
monitoring/stats/histograms/perf contextmonitoring/statistics.h
utilities/transactions, backup, checkpointsutilities/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 @ 7c80a5a at ~/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. §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).
  2. §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)
  1. §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.
  2. §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

  1. Place your engine_shootout results on the triangle: which measured number is RO, UO, MO for fjall and redb?
  2. 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.)
  3. 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

FileRole
tidesdb.c (~38K lines)the whole engine: write/read/compaction orchestration
skip_list.cmemtable — lock-free skip list, arena bump allocator
block_manager.cphysical block IO (WAL + SSTs)
bloom_filter.c~600 lines, readable bloom filter
manifest.clevel 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_compacting flag: 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

  1. Key+value in one malloc (tidesdb.c:26579): op->value = op->key + key_size — layout as pointer arithmetic. Rust equivalent would be a single Box<[u8]> with split indices; here the trick is load-bearing and explicit.
  2. Memory ordering spelled out (tidesdb.c:29761): memory_order_acq_rel on the memtable refcount during rotation. Rust’s Arc hides exactly these barriers — topic 9 makes you write them yourself.
  3. 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

  • tidesdbtidesdb.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:

FileSizeRole
core/storage/btree.rs~13K linesB-tree cursor, slotted pages, balance
core/storage/pager.rs~6.6K linespage cache, dirty tracking, IO
core/storage/wal.rs~10K linesWAL frames + checkpoint
core/storage/page_cache.rsSIEVE-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 via copy_within in shift_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:5779insert_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

  • Pager struct: 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 in append_frames_vectored() (wal.rs:708), and checkpoint() (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

  1. 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?
  2. Why does balance_non_root prefer redistribution over splitting? What does it do to write amplification (3 dirty pages vs 2)?
  3. 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

  • tursocore/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:

WorkloadPredicted winnerPredicted mechanismVerdict
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:

  1. Explain open addressing vs chaining in terms of cache lines, not textbook O(1).
  2. Describe incremental rehashing (redis) and SIMD group probing (SwissTable) from memory, and say which latency problem each one solves.
  3. Explain why LSM memtables use skip lists instead of hash tables or B-trees.
  4. 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 InlineSkipList supports 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 rax packs 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:

StructureDense filterPayload touched only on match
SwissTable1-byte control tagsinline kv slots
ART Node1616-byte key arraychild pointers
RocksDB skiplisttower before nodekey 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!()):

  1. src/skiplist.rs — single-threaded skip list (insert, get, ordered iter).
  2. 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-store crate: 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

  1. §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.)
  2. §III.C–D — insert/delete with node-type transitions. Skim.
  3. §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.
  4. §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

  1. 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.)
  2. 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?
  3. 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

WhatWhere
RawTablesrc/raw.rs:557
Tag constants + h2 extractionsrc/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 reusesrc/raw.rs:1952–1984, 1033–1043
Load factor 7/8src/raw.rs:152–156

3. Read in this order

  1. tag.rs — EMPTY/DELETED encoding. Why is EMPTY 0xff and full tags 0b0xxxxxxx? (So match_empty_or_deleted = “high bit set” — one SIMD sign test.)
  2. 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.)
  3. raw.rs:76–93ProbeSeq: 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.
  4. Insert path raw.rs:1952 — find first EMPTY or DELETED. Tombstone subtlety (raw.rs:1033–1043): inserting over DELETED doesn’t consume growth_left, and a table full of tombstones triggers rehash-in-place instead of growth.
  5. Aha: the trailing mirrorraw.rs:223: the control array allocates buckets + Group::WIDTH bytes; 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

  1. Why 7/8 load factor rather than redis’s 1.0? (Open addressing degrades near full — probe lengths explode; chaining just grows chains linearly.)
  2. 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.
  3. 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 at dict_force_resize_ratio even 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

  1. During rehash, dictAddRaw inserts only into ht[1]. Why is inserting into ht[0] a correctness bug, not just a wasted move?
  2. What does pauserehash exist for? (Hint: safe iterators.)
  3. Redis caps empty_visits at 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; the dictScan comment (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)

raxART (Leis 2013)
node sizesone variable-size layoutadaptive Node4/16/48/256
child searchlinear scan of char bytesSIMD (Node16), direct index (Node256)
pointersunaligned, memcpy’daligned arrays
optimized formemory (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

  1. Why does rax put the char bytes before the pointers instead of interleaving (char,ptr) pairs? (Branch decision reads only chars — one dense scan.)
  2. 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 to update[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

  1. 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?
  2. 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 in src/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–397 implements MemTableRep; siblings in memtable/: 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

  1. Redis’s skiplist has spans + backward pointers; this one has neither. For each, say exactly what breaks under concurrent CAS inserts.
  2. 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.)
  3. 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; also memory/concurrent_arena.h:57–68 (sharded arena) and memtable/skiplistrep.cc (the MemTableRep plug-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_map can’t be fixed in place (pointer stability + bucket API promises).
  • The metadata byte slide — the h2/control-byte idea introduced.
  • The SSE2 _mm_movemask_epi8 slide — the group probe; this is hashbrown’s Group::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 momentYou saw it in
metadata byte = 1 bit state + 7 bits hashtag.rs:9–49
group probe, movemaskgroup/neon.rs:78–90 (ARM twist: 8-wide)
“deleted vs empty” probe-stop ruleraw.rs tombstone logic :1033–1043
iterators break on rehash — API costRust never promised stability, so hashbrown got this for free

Questions to answer in notes.md

  1. Google couldn’t ship this as std::unordered_map because the standard’s API promises (pointer stability, bucket interface) mandate chaining. Which redis dict features would SwissTable similarly break? (Incremental rehash needs stable entries? Check — redis moves entries between tables anyway; the real conflict is dictScan’s bucket cursor.)
  2. 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.
  3. 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

Topic 2 — notes

Predictions (fill BEFORE running benches)

BenchhashbrownBTreeMapcrossbeam SkipMapmy skiplistmy 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)

  1. Insert into ht[0] during rehash — why a bug:
  2. pauserehash exists for:
  3. empty_visits=10n tail guarantee:

redis skiplist (reading-redis-skiplist.md)

  1. Why skiplist + dict both:
  2. Expected search cost at p=0.25, priced vs measured:

hashbrown (reading-hashbrown.md)

  1. 7/8 vs 1.0 load factor:
  2. Hash policy paragraph (for M2 decision):
  3. DELETED churn ↔ LSM tombstones:

RocksDB memtable (reading-rocksdb-memtable.md)

  1. spans/backward under concurrent CAS:
  2. acquire/release vs SeqCst at line 383:
  3. 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:

  1. 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.
  2. Narrate a node split (SQLite’s 3-sibling balance) and say why it’s ≤3.
  3. Explain LMDB’s no-WAL commit (COW + double meta page) and its costs.
  4. 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)

CellFormat
table interiorleft_child u32 ∥ rowid varint
table leafpayload_size varint ∥ rowid varint ∥ payload
index interiorleft_child u32 ∥ payload_size varint ∥ payload
index leafpayload_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):

  1. src/page.rs — slotted page: header, cell ptr array, insert/delete/defrag.
  2. 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)

SectionPages (approx)Why
§2 Basic techniquesskimyou know this from the code
§3.1–3.3 Prefix + suffix truncationreadyour experiment; separators need only be shorter than both neighbors, not real keys
§3.4 Normalized keysreadbinary-comparable encoding again (ART §III.E) — one memcmp replaces typed comparison
§3.5 Poor man’s normalized keyreadfirst bytes of the key cached IN the pointer array slot — dense filter pattern yet again
§4.2 Overflow / variable-length recordsskimyou saw SQLite’s version
§5.1–5.2 Node sizesreadwhy 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

  1. Why does suffix truncation apply to interior separators but prefix truncation mostly to leaf pages? (Separators are synthetic; leaf keys must be exact.)
  2. SQLite/turso do neither. Given SQLite’s design goals (simplicity, robustness, integer rowids as the common key), argue whether that’s the right call.
  3. 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_meta struct :1358).
  • mdb_txn_commitmdb_page_flush (write dirty pages) → fsync → mdb_env_write_meta (mdb.c:4847, slot txnid & 1 at :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 table mti_readers, MDB_reader struct :869 — one slot per reader in a shared lock file, holding a frozen mr_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 big PROT_READ mmap; writes go through pwrite (default) or a writable map with MDB_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

  1. Why does LMDB’s split not bother with SQLite-style sibling redistribution? (COW already dirties the path; also no freeblocks — append-style page builds.)
  2. Double meta + fsync ordering: which of the two fsyncs could you drop, under what hardware assumption, and what breaks on consumer SSDs?
  3. 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; the MDB_meta comment (: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. Note xCellSize / xParseCell function pointers picked once per page type at init — devirtualized dispatch, 1994 style. And nFree is 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 an xRecordCompare callback 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 = 3 at :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

  1. 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.)
  2. Why does balance_quick exist when balance_nonroot handles the same case? Estimate the work saved for a fillseq insert (pages touched, cells copied).
  3. SQLite computes nFree lazily and validates cells only under SQLITE_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_check exists 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

  • sqlitesrc/btree.c (11,633 lines; don’t read linearly) and src/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. §1 The database file — 100-byte file header: page size (offset 16), file change counter, freelist head + count (offsets 32–39), schema cookie.
  2. §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).
  3. §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.
  4. §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

  1. 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_end without scanning.)
  2. 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.)
  3. 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–7680find_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’s fragmented_bytes counter.
  • 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–9042max_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 — keep min_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 after split_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_varintsqlite3_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

  1. 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?
  2. The freeblock minimum is 4 bytes and fragmented_bytes caps at 60 in SQLite — what goes wrong without defragmentation? When must allocateSpace defrag even though total free space suffices?
  3. 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

  • tursocore/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)

Benchmy btreeredb
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)

  1. Table vs index interior cell contents / fanout:
  2. Why defrag is needed despite freeblocks:
  3. Yield-point invariant in async balance:

SQLite btree.c (reading-sqlite-btree.md)

  1. fillInCell overflow-first ordering safety:
  2. balance_quick savings for fillseq:
  3. Trust-vs-verify position:

LMDB (reading-lmdb.md)

  1. Why no sibling redistribution on split:
  2. Which fsync could go, on what hardware:
  3. 1-key commit cost LMDB vs WAL engine; where LMDB still wins:

Graefe survey

  1. Suffix (interior) vs prefix (leaf) truncation asymmetry:
  2. Is SQLite right to skip both?
  3. 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:

  1. Draw an SST from blocks to trailer and explain restart points + prefix truncation.
  2. Derive write amplification for leveled vs tiered compaction and check it by measurement on your own mini-LSM.
  3. Explain Monkey’s bloom-bit allocation argument in one paragraph.
  4. 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

StrategyMerge triggerWrite ampRead ampSpace amp
Leveledlevel size > target (10x ratio)high: ~10 per level ⇒ O(10·L)low: 1 run/levellow (~1.1)
TieredK runs of similar sizelow: ~1 per levelhigh: K runs/levelhigh (~K)
FIFOsize cap1n/a1
Lazy hybrids (Dostoevsky)tiered upper, leveled lastbetweenbetweenbetween

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:

  1. src/memtable.rs — wrap your topic-2 skiplist (or BTreeMap to start).
  2. src/sst.rs — block-based SST writer/reader: 4KB blocks, restart points every 16, per-block xxhash, whole-file bloom (10 bits/key).
  3. src/lsm.rs — put/get/scan, flush at 1MB, pluggable compaction: Leveled (ratio 10) and Tiered (K=4).
  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

  1. §3 — the taxonomy. Make the table for: your mini-LSM, lsm-tree crate, RocksDB leveled, RocksDB universal, FIFO.
  2. §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.
  3. §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).
  4. Skim the workload sensitivity plots — note which finding you’ll test.

Questions to answer in notes.md

  1. 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.)
  2. 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?)
  3. 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:

LevelWhat merging there improvesWho cares
upper (small) levelsalmost nothing — they’re small, probes are filterednobody
largest levelspace amp (dead versions live here) + zero-result readseverybody

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

  1. §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.
  2. §3 — Lazy Leveling analysis. Check the claim: same point-read + space complexity as leveled, write cost close to tiered.
  3. §4 — Fluid LSM + the tuning section (skim the solver, keep the knobs).
  4. Evaluation — find the throughput-vs-skew plots.

Questions to answer in notes.md

  1. 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.)
  2. 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.)
  3. 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 every restart_interval items; between restarts, items store shared_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; if maybe_contains_hash says no, the point read never touches a data block (:281–288).

3. Bloom filter — src/table/filter/standard_bloom/

  • builder.rs:55–127with_fp_rate computes m,k from −n·ln(fpr)/ln²2 (:58); with_bpk direct (:93).
  • Double hashingbuilder.rs:10–13 + mod.rs:102–129: k probes from two hashes via h1 += 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, so get_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_atomic on 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–98choose(version, config, state) → Merge | Move | Drop | DoNothing. Note Move: a segment that doesn’t overlap the next level is relinked, zero IO — find where leveled uses it (leveled/mod.rs:19 pick_minimal_compaction).
  • Leveled: leveled/mod.rs:113–143 — L0 trigger 4 runs, ratio 10.
  • Worker: worker.rs:382–389tombstones 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

  1. 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.)
  2. 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?
  3. 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. §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.
  2. §4 — the allocation argument (above). Follow the Lagrange-multiplier sketch once; then re-derive the “FPR ∝ level size” conclusion informally yourself.
  3. §5 — merging co-tuning (T as a continuum from leveled to tiered). Skim — Dostoevsky does this better.
  4. §6 evaluation — look for the ~2× lookup improvement at equal memory.

Questions to answer in notes.md

  1. 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).
  2. Monkey assumes point lookups dominate. What breaks for range scans? (Filters don’t help ranges at all — prefix blooms exist for a subset.)
  3. 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_triggerstop
  • pending compaction bytes ≥ hard limit → stop
  • L0 files ≥ level0_slowdown_writes_triggerdelayed
  • 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 a VersionEdit (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

  1. Why does leveled compaction pick by score rather than round-robin? Construct a workload where round-robin lets one level grow unboundedly.
  2. 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?
  3. 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/rocksdbdb/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. §1–2 — background + the resource-priority history (the arc above).
  2. §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.
  3. §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.
  4. §5 — future directions (2021 vintage): remote compaction, tiered storage — check which happened (topic 28 will).

Questions to answer in notes.md

  1. 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.)
  2. 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.)
  3. 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:

Metricleveled (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

  1. Why L0 can’t be disjoint / what it costs:
  2. Restart interval 16 trade; why B-tree pages don’t:
  3. Whole-version rewrite vs MANIFEST log breakdown point:

RocksDB

  1. Score vs round-robin — adversarial workload:
  2. Partitioned index vs per-block hash index:
  3. Blocked bloom FPR cost:

Monkey

  1. Uniform vs Monkey expected false probes (computed, then measured):
  2. What breaks for range scans:
  3. Zero-result-heavy workloads outside LSMs:

Dostoevsky

  1. Lazy-leveling score on MY measured numbers:
  2. Why ranges don’t benefit:
  3. Universal-compaction knobs ≈ K and Z:

TODS ’21

  1. CPU costs an LSM adds over a hash table:
  2. Checksums-at-every-layer; FalkorDB/redis story:
  3. 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:

  1. State the WAL rule (log reaches disk before the page it protects) and derive why each system’s recovery works from it.
  2. Explain torn pages and the two industrial fixes (full-page writes, double-write).
  3. Measure the fsync ladder on your own SSD and design group commit from it.
  4. 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 != 0 marks 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 everysec trades ≤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)

CallGuaranteesTypical 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 flushedfast and weak
macOS F_FULLFSYNCdrive cache flushedms-scale — measure it!
O_DIRECT + own bufferingbypass page cachetopic 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.c vs rdb.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/)

  1. 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.
  2. 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).
  3. src/bin/crash_test.rs — crash injection: child process inserts keys, parent kill -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.
  4. benches/commit_throughput.rs — fsync-per-commit vs group commit (batch 8/64/512) vs appendfsync 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_test harness, 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:

BottleneckFixModern descendant
B: fsync per commitgroup commitpostgres XLogFlush recheck
C: locks held across flushEarly Lock Release (ELR)controversial; see Q2
D: schedulingflush pipelining (async commit queues)redis everysec (cruder)
A: buffer insert mutexconsolidation arraypostgres 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. §1–2 for the bottleneck taxonomy (the table above).
  2. §5 consolidation arrays — the durable contribution.
  3. §3 ELR — for the argument about log order as dependency tracking.
  4. Skim §4 + evaluation (§6): note which fix buys what at which core count.

Questions to answer in notes.md

  1. Why does ELR NOT violate durability for the dependent transaction? (Its commit record is behind yours; a crash that loses yours loses its too.)
  2. 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.
  3. 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.)
  4. 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)

TermMeaning
stealdirty pages may hit disk BEFORE commit (⇒ need undo)
no-forcepages need NOT hit disk at commit (⇒ need redo)
LSNlog sequence number; every page stamps the LSN of its last change
CLRcompensation log record — undo work is itself logged, redo-only
DPTdirty page table (checkpointed) — which pages might need redo
ATTactive 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

  1. A summary (above) until the three passes + CLRs feel obvious.
  2. Paper §3 (“the problem”): why the naive undo-then-redo attempts fail — the best catalog of recovery bugs ever assembled.
  3. §6: the passes in detail — read for pageLSN ≥ recLSN ⇒ skip redo (idempotence via LSN comparison) and the CLR undoNext chain.
  4. 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

  1. Why must CLRs be redo-only (never undone)? Walk a crash-during-undo.
  2. 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.)
  3. 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 of NUM_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 rollingfuzzy: 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

  1. Why is xl_prev needed when records are read forward anyway? (Detects a valid-looking record left over from a recycled segment file.)
  2. FPI sawtooth: checkpoint_timeout ↑ ⇒ WAL volume ↓ but recovery time ↑. Write the trade as a formula in (dirty rate, checkpoint interval).
  3. 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/postgressrc/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!) to server.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.QUERY commands 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

  1. 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).
  2. AOF-as-LSM: map BASE/INCR/rewrite/manifest onto topic-4 terms. What’s the “write amp” of an AOF rewrite?
  3. 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

  • redissrc/aof.c (feed, flush policies, rewrite, multi-part manifest) and src/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) distinguishes Fsync from FullFsync (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 with db_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

  1. 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).
  2. 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.)
  3. 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/tursocore/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)

RungPredicted p50Measured p50Measured 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

Policycommits/sdurability window
fsync per commit0
group 80
group 640
group 5120

Reading-guide questions

postgres xlog (reading-postgres-xlog.md)

  1. Why xl_prev when reading forward:
  2. FPI sawtooth formula in (dirty rate, checkpoint interval):
  3. Raising NUM_XLOGINSERT_LOCKS — when, and the flush-time cost:

turso WAL (reading-turso-wal.md)

  1. Page images vs deltas — two buys, one cost:
  2. The failure salts catch that checksums alone miss:
  3. My experiment’s format choice + justification:

redis AOF/RDB (reading-redis-aof-rdb.md)

  1. everysec loss window + the write-postpone logic:
  2. AOF-as-LSM mapping + rewrite write amp:
  3. Command-log vs page-image vs logical-record ranking for graph mutations:

ARIES (reading-aries.md)

  1. Why CLRs are redo-only (crash-during-undo walkthrough):
  2. Nested top action for a B-tree split — why correct AND necessary:
  3. My topic-3 B+tree + WAL: steal? force? ⇒ which passes needed:

Steal/force 2×2 (from memory):

forceno-force
no-stealundo: __ redo: __undo: __ redo: __
stealundo: __ redo: __undo: __ redo: __

Aether (reading-aether.md)

  1. Why ELR preserves durability for dependents:
  2. The ELR hazard (non-logging escape channel):
  3. Consolidation array vs postgres’s 8 insert locks:
  4. 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:

  1. Walk a page request through hash-lookup → pin → CLOCK victim search and say where every atomic and lock is.
  2. 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.
  3. Explain pointer swizzling and the cooling stage — how LeanStore makes an in-memory hit cost ~0 extra instructions.
  4. 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 nextVictimBuffer atomic ticks around a fixed array; each buffer has a 4-bit usage_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

  1. No write ordering — the kernel flushes dirty pages whenever; WAL’s “log before page” needs msync gymnastics or is simply unenforceable.
  2. TLB shootdowns — evicting a page means IPIs to every core that might cache the mapping; scales worse with more cores.
  3. Page-fault stalls — a fault blocks the thread; no async I/O, no admission control, no prefetch you control.
  4. 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/)

  1. src/buffer_pool.rs — CLOCK buffer pool: fixed frame array, page_id → frame map, 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).
  2. 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).
  3. 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), atomic readers pin 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’s PageGuard should work the same way.

2. The eviction queue — buffer_pool.cpp

  • BufferEvictionNode — :42: a weak_ptr to the block memory + the handle_sequence_number at 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+): IterateUnloadableBlocks pops nodes; a node whose seq_num ≠ the handle’s current one is dead — skip; whose weak_ptr won’t lock — dead; else Unload (:38 in that loop) frees the memory.
  • Cleanup is amortized: PurgeIteration (:104 hpp) runs every INSERT_INTERVAL = 4096 insertions (: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

  1. Why weak_ptr in the queue node? What breaks with shared_ptr? (Queue would keep every block alive — the cache becomes a leak.)
  2. 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?
  3. 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/duckdbsrc/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 just virt + 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

  1. 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?
  2. Why does the cooling stage need to be a FIFO and not a stack? (Second chance requires time between cool and evict.)
  3. 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?
  4. 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 raw BufferFrame*.
  • 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_BIT in 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: latch sits 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

  1. 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?
  2. 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.)
  3. 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).
  4. 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 in backend/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

SystemUsesEscapes the trap because
LMDBmmap everythingCOW + read-mostly + single writer
SQLiteoptional mmap for readsWAL still explicit; mmap read-only
postgresno mmap; shared_buffersneeds write ordering (FPIs, ckpts)
LeanStore/vmcacheanonymous mem / virt mappingexplicit residency control

Questions to answer in notes.md

  1. 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.rs would start failing and why.)
  2. TLB shootdowns: why does eviction trigger them but faulting-in not?
  3. 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)?
  4. 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 call GetVictimBuffer (:2548).
  • GetVictimBuffer: StrategyGetBuffer picks a candidate; if it’s dirty, the backend writes it out itselfFlushBuffer right 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 ringsGetAccessStrategy :426: a seqscan gets a 256KB private ring (BAS_BULKREAD, :442–459) so one SELECT 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

  1. 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.)
  2. A client pins a page and crashes mid-query — who unpins? (Resource owner machinery: ReservePrivateRefCountEntry in GetVictimBuffer :2559.)
  3. Buffer rings vs LeanStore’s cooling stage: both defend against scans. Which defends at admission and which at eviction? What does each miss?
  4. 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/postgressrc/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_size path 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_hand raw pointer (:107); comment :95–98 states the discipline (insert behind the hand).
  • advance_clock_hand — :174; insert — :204.
  • Note what’s unsafe (Send/Sync impls :115–116, raw pointers) and what your Rust version can do differently with indices into a Vec<Frame> instead of pointers (safe, and the array is exactly postgres’s layout).

Questions to answer in notes.md

  1. Why per-thread counters instead of one atomic? Estimate the cost of a shared fetch_add on every malloc at 8 threads (topic-0 numbers).
  2. 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.)
  3. 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

  • redissrc/zmalloc.c (the ledger) and src/defrag.c (cooperative userspace defragmentation). Local clone at ~/repos/redis.
  • tursodatabase/tursocore/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)

p50p99p99.9max
mmap
pool (CLOCK)

Pool hit rate: ___ %. Explanation of the tail difference:

eviction bench results

policyhit ratens/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)

  1. 18 refcount bits vs 4 usage bits — which cap fails gracefully:
  2. Who unpins after a crashed query:
  3. Buffer rings (admission) vs cooling stage (eviction) — what each misses:
  4. What O_DIRECT buys/costs given double buffering:

DuckDB buffer manager (reading-duckdb-buffer.md)

  1. Why weak_ptr in eviction nodes:
  2. Worst-case dead-node ratio; when CLOCK’s fixed array is strictly better:
  3. Throw-on-pressure vs error-when-all-pinned — capstone choice:

LeanStore (reading-leanstore.md)

  1. One-parent constraint walkthrough; are FalkorDB matrix blocks a tree or DAG:
  2. Why eviction must be bottom-up:
  3. Random-cooling hit-rate loss vs LRU (estimate, then measure):
  4. What survives in vmcache, what dies:

mmap paper (reading-mmap-paper.md)

  1. Which topic-5 crash_test would fail under mmap and why:
  2. Why eviction (not fault-in) triggers TLB shootdowns:
  3. Reconciling the paper with LMDB’s wins:
  4. vmcache vs the four problems — solved vs softened:

LeanStore/vmcache papers (reading-leanstore-paper.md)

  1. Classic-pool overhead as arithmetic (topic-0 numbers):
  2. Why cooling is a FIFO, not a stack:
  3. vmcache state word vs postgres packed state — what colocation buys:
  4. Which design is admissible for matrix-tile DAGs:

redis zmalloc (reading-redis-zmalloc.md)

  1. Cost of a shared fetch_add per malloc at 8 threads:
  2. Key eviction vs page eviction — why the comparison is unfair:
  3. 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.c and 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 with memchr.

Outcomes

By the end you can:

  1. Parse and generate RESP2/RESP3 from memory, and explain each design choice (length prefixes, CRLF, type-first bytes) in parser terms.
  2. Narrate one full redis event-loop iteration: poll → read → parse → execute → queue reply → write, and say what beforeSleep does.
  3. Explain the three threading models (single loop, io-threads, thread-per- core) and what each serializes.
  4. 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*N lets the server size argv[] 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 writtenaddReply appends to a per-client buffer and the next beforeSleep writes everything with one write() per client (handleClientsWithPendingWrites). Batching by loop iteration.
  • Pipelining falls out for free — the input buffer may hold 100 commands; processInputBuffer loops until the buffer is drained, and all 100 replies coalesce into one write. This is why redis-benchmark -P 64 is ~10× -P 1: same work, 1/64th the syscalls.

3. Three threading models, one question: what’s serialized?

ModelCommand executionI/O + parsingExample
single loopserialserial, same threadredis ≤5, our M7 v1
io-threadsserial (main thread)parallelredis 6+, valkey 8 (rewritten)
thread-per-coreparallel (keyspace sharded)parallel, no cross-core locksDragonflyDB, 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_LEN 16KB reads (server.h:188), max query buffer size; a client streaming faster than execution grows querybuf → kill.
  • Output side: PROTO_REPLY_CHUNK_BYTES 16KB chunks (server.h:189); a slow reader (or a KEYS * 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 DataRow messages 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:

framingtypingstreaming
RESPlength-prefixed text markersstrings + ints (client re-parses)none — full reply or die
pgwiretyped binary messagesper-column OIDs, text/binaryportal row limits (pull-ish)
Boltchunked messages of PackStream structsfull type system incl. Node/Relationship/Path on the wireexplicit 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 servergit 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/)

  1. src/resp.rs — RESP2 parser/encoder (your build; tests fix the format incl. partial-input resumption — the hard part of any wire parser).
  2. src/bin/server.rs — tokio GET/SET/PING/DEL server over your parser
    • a sharded HashMap. Compiles against your resp.rs.
  3. Bench protocol (in notes.md): redis-benchmark -t get,set -P 1 and -P 64 against (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 over BST_* — 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

  1. 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.)
  2. 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?
  3. 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?
  4. 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?
  5. 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.
  6. 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 with git 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):

  1. thread per connection — dies at ~10K in 1999 (stacks, context switches)
  2. select/poll — O(n) scans of the fd set per wakeup
  3. readiness notification (epoll/kqueue) — O(ready) not O(registered): this line wins; ae.c is exactly this + a dispatch table
  4. 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

  1. 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.)
  2. 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)?
  3. 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?
  4. 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: one Query message in, a stream of RowDescription + DataRow* + CommandComplete out. 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.rsStartupHandler (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:138 and :277Server::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 (see api/ 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 with nc.
  • 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)

RESPpgwiregRPC
framingASCII len prefixestype byte + i32 lenHTTP/2 frames
parse costmemchr + atoifixed header readprotobuf decode
streamingno (buffer all)portals, row-at-a-timeHTTP/2 streams
backpressureoutput-buffer killportal suspendflow-control windows
debuggabilitync worksneeds a toolneeds grpcurl
your GRAPH.QUERY???

Questions to answer in notes.md

  1. 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.)
  2. Why does qdrant run TWO tonic servers instead of one with authz? What attack/ops story does the split simplify?
  3. 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/pgwiresrc/messages/, src/api/query.rs, src/api/auth.rs; the crate structure IS the protocol lesson. Local clone at ~/repos/pgwire.
  • qdrant/qdrantsrc/tonic/mod.rs (two servers, tower middleware) plus the generated services in the api/ 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), then aeApiPoll (: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) into querybuf, 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 $len then exactly len bytes. Note PROTO_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: the nc-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, writeToClient each (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

  1. Why write in beforeSleep rather than in addReply? Count syscalls for a pipeline of 100 GETs both ways.
  2. events[fd] arrays vs a HashMap<fd, handler>: why is the array not just faster but correct here? (fd reuse semantics after close.)
  3. The big-arg zero-copy: what property of sds + querybuf repositioning makes it safe? When does it fail (arg spans two reads)?
  4. 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

  • redissrc/ae.c (read fully), src/networking.c (the five functions above), plus the buffer-size constants in src/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 of BATCH_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 to max_prefetch_size commands from multiple clients.
  • The file comment (:7) states the idea: walk each key’s lookup path (hash → bucket → entry → value) issuing __builtin_prefetch at 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

  1. 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?
  2. 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.)
  3. 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?
  4. 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/valkeysrc/io_threads.c, src/memory_prefetch.c (the file comment at :7 states the whole idea), plus the grep points in src/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)

MeasurementPredictionActualSurprised?
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 641. 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

  1. 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?
  2. Trace one GET at the function level: aeApiPoll → readQueryFromClient → processInputBuffer → processMultibulkBuffer → addReply → handleClientsWithPendingWrites. Where is the syscall boundary crossed (exactly twice — where)?
  3. 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.
  4. 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

  1. 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?
  2. 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)?
  3. memory_prefetch.c batches dict lookups to overlap cache misses. What is the equivalent MLP opportunity in a GraphBLAS BFS step?
  4. 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

  1. 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?)
  2. 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?
  3. 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

  1. Which C10K strategy is tokio’s multi-thread runtime? (Two layers — name both.)
  2. 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.
  3. Thread-per-core sharding unit for a graph: graph? subgraph? matrix tile? What does a BFS crossing shards cost in messages?
  4. 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”:

AnomalyShapeRCRR/SISerializable
dirty readread uncommitted writeblockedblockedblocked
non-repeatable readre-read sees new commitallowedblockedblocked
phantomre-scan sees new rowsallowedblocked*blocked
lost updater-m-w over another’s writeallowedblockedblocked
write skewdisjoint writes, overlapping readsallowedallowedblocked

* 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 writersyesnono
writers block readersyesnono
conflict handlingwait (deadlock detect)validate at commit, abortfirst-committer-wins abort
cost centerlock manager trafficwasted work on abortversion storage + GC
shines whenhigh contentionlow contentionread-heavy mixes
code you’ll readRocksDB pessimistic txnRocksDB optimistic txnpostgres, 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)

GuideWhat you’ll trace
reading-postgres-heapam.mdPostgres MVCC: every tuple carries its own visibility
reading-rocksdb-transactions.mdOCC and 2PL, same skeleton: RocksDB transactions
reading-surrealdb-tx.mdThe minimal transactional KV interface: surrealdb’s kvs layer
reading-ansi-critique.mdIsolation levels, made rigorous: history patterns and write skew
reading-ssi-postgres.mdSSI: serializable snapshot isolation without blocking anyone
reading-inmemory-mvcc.mdIn-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::Serializable prevents 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.rs and 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

  1. 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).
  2. Why can’t first-committer-wins catch write skew? (One sentence: the conflict is r→w across txns, not w→w.)
  3. 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)?
  4. 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:

  1. 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.)
  2. 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).
  3. Indexes point at version chains; lock-free hash + Bw-tree (topic 9’s protagonists) — MVCC and lock-free structures co-designed.
  4. 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:

AxisOptionsVerdict (their workloads)
concurrency controlMVTO / MVOCC / MV2PL / SI+SSNno universal winner; MVTO strong; the version machinery dominates CC choice
version storageappend-only / delta / time-traveldelta wins for writes (N2O append-only for reads); append-only pays full-tuple copies
orderingnewest-to-oldest / oldest-to-newestN2O wins — readers want the newest; O2N walks garbage first
GCtuple-level background / cooperative / txn-level / epochcooperative + epoch wins; background vacuum-style lags under write bursts
index mgmtlogical pointers / physicallogical (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

  1. 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.)
  2. 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?
  3. 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?
  4. 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.)
  5. 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:

  1. xmin aborted → invisible; xmin in-progress and not me → invisible
  2. xmin mine and cid < my command → visible (read-your-own-writes lives here, via CommandId — statement-level granularity inside a txn)
  3. xmin committed but XidInMVCCSnapshot(xmin) → invisible (committed AFTER my snapshot — this is the line that makes it “snapshot”)
  4. 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)

 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

  1. Why must the index-entry deletion happen BEFORE line pointers are recycled? Construct the corruption if the order flipped.
  2. Hint bits make reads write. Which topic-6 lesson does that complicate (think: checksums, dirty buffers from SELECTs)?
  3. A snapshot with 10K concurrent writers makes XidInMVCCSnapshot a binary search over 10K xids per tuple. What does Hekaton’s timestamp design pay instead?
  4. 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

  • postgressrc/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 — read HeapTupleSatisfiesMVCC in 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) — note snapshot_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

  1. 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?
  2. 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?
  3. Neither flavor validates READ sets by default — so what isolation do you actually get, and where does write skew sneak in?
  4. 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

  • rocksdbutilities/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)

  1. 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.
  2. Commit ordering refinement — only abort when T_out committed first (fewer false positives than raw Cahill).
  3. 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).
  4. 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.
  5. 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

  1. Why must SIREAD locks outlive commit? Construct the history where the dangerous structure completes after the reader committed.
  2. 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)?
  3. Read-only txns: why can they NEVER be T_pivot? (Which edge can’t they have?) How does that justify the safe-snapshot optimization?
  4. 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): just Read | Write — declared UP FRONT at begin. Compare postgres (any txn can write) — declaring intent enables single-writer engines and read-only fast paths.
  • LockType on Datastore::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’s StorageBackend trait, grown up.

2. The Transactor API (tr.rs) — read the signatures

  • get/getm/getr/getp (:119–155) all take version: 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 vs put :190 vs putc :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

  1. 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)?
  2. 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?
  3. putc (CAS) as the portable OCC primitive: sketch how you’d build first-committer-wins snapshot isolation on top of ONLY get/putc.
  4. 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

  • surrealdbsurrealdb/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)

MeasurementPredictionActualSurprised?
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

  1. Index-entry deletion before line-pointer recycling: the corruption if flipped?
  2. Hint bits make reads write — which topic-6 consequence?
  3. XidInMVCCSnapshot at 10K writers vs Hekaton timestamps: costs?
  4. Where do old versions live in a matrix-backed graph — append-only or delta?

Questions — reading-rocksdb-transactions.md

  1. Why is memtable-only OCC validation sound (and when does it TryAgain)?
  2. Striped key locks vs a super-node’s adjacency: the pathology?
  3. No read-set validation by default — what isolation, where does skew enter?
  4. Single-writer M8: which machinery survives?

Questions — reading-ansi-critique.md

  1. Doctors write skew in history notation; which phenomenon does it evade?
  2. Why first-committer-wins can’t catch write skew (one sentence).
  3. Predicate phantoms for MATCH — label matrix? index range? key locks?
  4. Test-to-phenomenon mapping done in mvcc.rs comments?

Questions — reading-ssi-postgres.md

  1. History where the dangerous structure completes after the reader commits?
  2. Your read-set granularity vs SIREAD escalation — the graph equivalent?
  3. Why can a read-only txn never be the pivot?
  4. 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

  1. end_ts-as-lock CAS pseudocode; the equivalent check in your mvcc.rs is at line ___.
  2. Delta matrices in the Wu/Pavlo taxonomy; predicted read-path cost?
  3. Logical node-ids as indirection: which graph updates still touch indexes?
  4. Write-only hot key vs cooperative GC — the fix?
  5. MVOCC at 40 cores: validation aborts or ts allocation? (Predict, then check §6.)

The 5-axis placement (fill after all readings)

SystemCCVersion storageOrderingGCIndex ptrs
postgresSI+SSIappend-only (in heap)old-to-new (t_ctid)vacuum+prunephysical (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)
protectslogical content (rows, predicates)physical structure (a node, a page)
held fora transaction (seconds)a critical section (nanoseconds)
deadlockdetected/resolvedmust be IMPOSSIBLE by ordering
implemented bylock manager tableatomics 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)

OrderingGuaranteeWhen you reach for it
Relaxedatomicity only, no orderingcounters, stats
Acquire (loads)later reads/writes can’t move before itreading a “ready” flag
Release (stores)earlier reads/writes can’t move after itpublishing a “ready” flag
AcqRelboth, for RMW opsCAS that links a node
SeqCstone global order of all SeqCst opswhen 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)

GuideWhat you’ll trace
reading-postgres-lwlock.mdOne word, one CAS, one queue: postgres’s production rwlock
reading-crossbeam-epoch.mdEpoch reclamation: the GC that makes lock-free reads free
reading-concurrent-skiplists.mdTwo concurrent skiplists: CAS vs lazy locking
reading-bwtree.mdBw-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 crossbeam SkipSet (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

  1. 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).
  2. Why must helpers complete OTHER threads’ SMOs? What deadlock/livelock does “just wait for the owner” reintroduce?
  3. 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)?
  4. 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.)
  5. 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): InsertConcurrently is 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 — one compare_exchange_strong per level. Insert per level: read pred/succ, set new->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) and RecomputeSpliceLevels (:331/:1016) repairs only the invalid levels. Amortize the O(log n) search across nearby inserts.
  • Insert :908 vs InsertConcurrently :913 — same template, UseCAS flag: 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 tower nexts[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 with fully_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): every Accessor (: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) and create_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)

RocksDBmemgraph
writers coordinate byCAS per levelper-node spinlocks
readers see partial insert?yes — per-level linking is independent (fine for a set)no — fully_linked gate
deletenever (tombstones)marked bit + unlink
reclamationnone needed (arena dies at flush)accessor-id GC
failure/retryre-find level, re-CASunlock all, restart

Questions for notes.md

  1. 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)?
  2. Why does the lazy list lock preds BOTTOM-up and validate after locking? Construct the lost-insert without validation.
  3. A splice cache assumes locality of consecutive inserts. Does a graph bulk-load (sorted node ids) hit that path? What about random edges?
  4. 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 = 128 pins (:335, check at :454–456), the pinning thread calls collect (: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_advance is 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

  1. 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.
  2. What does Shared<'g, T>’s lifetime buy over C++ epoch libraries? Which bug class does it delete at compile time?
  3. 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?
  4. 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

  • crossbeamcrossbeam-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 to proclist (: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 count spin_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

  1. Why must the shared count live in the SAME word as the exclusive bit? Sketch the race if they were two atomics.
  2. The recheck-after-enqueue: write the lost-wakeup interleaving it prevents, as a 2-thread timeline.
  3. LWLocks are non-recursive and panic on double-acquire in assert builds. Why is recursion banned for latches but fine for locks?
  4. Compare with std::sync::RwLock on 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), then LWLockAttemptLock and LWLockAcquire

Topic 9 notes — latches, lock-free & epochs

Predict FIRST, then measure.

Measured already (false_sharing, provided binary — this Mac, 8 threads)

layouttimerate
packed636 ms63 M inc/s
pad6424 ms1697 M inc/s
pad12811 ms3707 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)

MeasurementPredictionActualSurprised?
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_race 50×).
  • 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 test result (readers_survive_concurrent_removal_churn is the UAF canary):

Questions — reading-postgres-lwlock.md

  1. Shared count + exclusive bit in ONE word: the race if split in two?
  2. Lost-wakeup timeline that recheck-after-enqueue prevents?
  3. Why are latches non-recursive by design?
  4. What does rolling their own buy over pthread rwlock?

Questions — reading-crossbeam-epoch.md

  1. Why 3 epochs, not 2 (interleaving)?
  2. What bug class does Shared<'g>’s lifetime delete?
  3. Reader pins then blocks on I/O 100 ms — consequence and fix?
  4. Epoch-per-operation vs per-morsel for second-long graph queries?

Questions — reading-concurrent-skiplists.md

  1. Arena-per-memtable dodge → does M8 CoW give M9 the same dodge?
  2. Lost-insert without validate-after-lock (construct it)?
  3. Splice cache: bulk-load vs random edges?
  4. Comparison table filled from memory?

Questions — reading-bwtree.md

  1. 6-delta point read: cache misses vs OLC B+tree (topic-0 numbers)?
  2. Why must helpers finish others’ SMOs?
  3. OLC restart probability, 4 levels, 1% write rate — and the hot-leaf case?
  4. Why do deltas win for sparse matrices but lose for B-tree nodes?
  5. CAS-the-matrix-pointer: which Bw-tree lesson transfers to FalkorDB?

scaling.rs results (after implementing)

impl1t2t4t8t16t
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 simplification1+1=2 at 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:

AssumptionRealityBlowup
uniformity (1/NDV per value)skew: one hot value = 50% of rows100×
independence: sel(a)×sel(b)correlated columns (city↔country)1000×
containment for joinsfk distributions vary10× 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)

GuideWhat you’ll trace
reading-duckdb-optimizer.mdThe readable optimizer: DuckDB’s pass pipeline and join-order DP
reading-postgres-optimizer.mdPostgres’s optimizer: Selinger ’79, still in production
reading-rust-planner-stack.mdThe Rust planner stack: Pratt parsing, rule traits, lazy frames
reading-selinger-cascades.mdSelinger and Cascades: the two optimizer architectures
reading-how-good-optimizers.mdCardinality 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 get DEFAULT_SELECTIVITY (:917 — DuckDB’s 0.005 moment). No histograms here: distinct-count-based, plus base stats from relation_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

  1. Why does pullup-then-pushdown beat pushdown alone? Find one operator in pullup/ where hoisting first enables a deeper sink.
  2. 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)?
  3. Exact→greedy threshold: what workload shape triggers it — star schema (one fact, k dims) or chain? Count connected subgraphs for both at n=10.
  4. 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?
  5. 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

  • duckdbsrc/optimizer/: optimizer.cpp (the pass pipeline, read :197–367 top to bottom), filter_pushdown.cpp + pushdown/, and join_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:

  1. cardinality estimates — compare against TRUE cardinalities (computed offline) for every subplan;
  2. cost model — feed it TRUE cardinalities, see if better cost = faster;
  3. 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

  1. Why does independence UNDERestimate join sizes on correlated data? Construct a 2-table example where sel(a)×sel(b) is 100× low.
  2. 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)?
  3. “Robust plans”: hash join degrades linearly with a bad estimate, nested-loop quadratically. Frame it as a minimax decision — what’s the regret matrix?
  4. 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. First set_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); else standard_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_level only 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

  1. Interesting orders: construct the query where the globally-cheapest {AB} subplan loses — a sorted-but-pricier {AB} wins at level 3.
  2. Why does geqo exist instead of DuckDB-style greedy? What does genetic search preserve that greedy can’t (hint: it searches TREES, not sequences)?
  3. Two costs (startup, total): which plan flips between LIMIT 10 and full result — index scan vs sort — and why does one number fail?
  4. 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

  • postgressrc/backend/optimizer/: path/allpaths.c (make_one_rel, standard_join_search), path/joinrels.c (join_search_one_level), plus src/include/utils/selfuncs.h for 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).
  • The heart: parse_subexpr :1428–1450 — Pratt / precedence-climbing expression parsing: parse a prefix, then loop “while get_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 Dialect trait plumbing — one AST, many SQLs; the AST types in src/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; the Transformed wrapper tracks “did anything change”.
  • The driver (optimize :581): run ALL rules in order, REPEAT up to max_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

  1. Trace a + b * c > d AND e through parse_subexpr by hand (precedence table lookups included). Now write the Cypher expression subset you need for M10 and its precedence table.
  2. DataFusion’s fixpoint-of-all-rules vs DuckDB’s once-in-order: which catches filter → (rewrite exposes new filter) → filter chains, and what’s the worst-case cost?
  3. Why can polars skip join reordering but FalkorDB can’t? Where exactly does Cypher hide the join order decision (pattern → expansion order)?
  4. The Transformed flag: 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-rssrc/parser/mod.rs (parse_subexpr is the heart), src/ast/
  • datafusionoptimizer/src/optimizer.rs (OptimizerRule trait + fixpoint driver), then skim the one-file-per-rule menu
  • polarscrates/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)
searchDP over relation setsmemoized task recursion
spacejoins only; rewrites separaterewrites + physical, one space
pruningnone needed (small space)branch-and-bound essential
extensibilityedit the enumeratoradd a rule
shipped inpostgres, DuckDB, SQLiteSQL Server, CockroachDB, Orca

Questions for notes.md

  1. Selinger’s W (CPU weight): what happens to plan choice as storage moves NVMe→RAM (topic 6’s numbers)? Which plans flip?
  2. Interesting orders are DP state. What’s the Cascades equivalent (required physical properties), and why is top-down more natural for propagating them?
  3. 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?).
  4. 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.)
  5. 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)

questionpredictionactual
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:

querymy orderduckdb orderwhy
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_stats was 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)

  1. Pullup-then-pushdown — which pullup rule enables a deeper sink:
  2. DP keeps one plan per set; what Selinger kept that DuckDB drops, and why it’s ok:
  3. Exact→greedy threshold — star vs chain connected-subgraph counts at n=10:
  4. What splitting join-order from build/probe-side choice loses:
  5. Cypher chain → which DuckDB piece is anchor selection:

Postgres optimizer (reading-postgres-optimizer.md)

  1. Query where sorted-but-pricier {AB} wins at level 3:
  2. Why geqo instead of greedy (trees vs sequences):
  3. Which plan flips between LIMIT 10 and full scan, why one cost number fails:
  4. Super-node degree skew as join skew — what stat M10 needs:

Rust planner stack (reading-rust-planner-stack.md)

  1. Pratt parsing: precedence climb for a = 1 AND b < 2 OR c — draw the tree:
  2. DataFusion fixpoint vs DuckDB ordered passes — which bug class each risks:
  3. Why polars can skip join reordering and a Cypher engine can’t:

Selinger vs Cascades (reading-selinger-cascades.md)

  1. Interesting orders = extra DP state; Cascades equivalent (enforcers + physical properties):
  2. What the memo shares that Selinger’s table doesn’t:
  3. M10 architecture decision — bottom-up Selinger-style or memo-based, and why:

How Good Are Query Optimizers (reading-how-good-optimizers.md)

  1. 2-table example where independence underestimates 100×:
  2. Cout validates which of my engines’ knobs:
  3. Hash vs nested-loop regret matrix (minimax framing):
  4. 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 estimate is 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); MaxThreads on the source caps fan-out. polars-stream literally names its unit Morsel and 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’s GroupedHashAggregateStream interns 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:

  1. volcano.rs — PROVIDED: tuple-at-a-time iterators, dyn dispatch. The honest 1990 baseline.
  2. vectorized.rs — YOU implement: 1024-row batches, columnar arrays, selection vectors, group-by into a flat array (k is dense).
  3. 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

guidewhat it walks
reading-duckdb-execution.mdDuckDB’s execution engine: 2048 rows at a time
reading-postgres-executor.mdVolcano in production: postgres’s executor, warts and wisdom
reading-rust-execution-stack.mdVectorized in Rust: polars-stream morsels and DataFusion streams
reading-x100.mdX100: the vectorization manifesto
reading-compiled-vs-vectorized.mdCompiled vs vectorized: the fair fight ends in a near-tie
reading-morsel-parallelism.mdMorsel-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

dimensioncompiled (Typer)vectorized (Tectorwise)
computation-heavywins (registers)loses (intermediates)
memory-bound (probes)loses (1 miss in flight)wins (miss overlap)
compile latency100s of ms (LLVM)zero
profiling/debuggingopaque blobper-primitive
adaptivityrecompileswap primitives
implementation effortLLVM dependency, codegen bugs100s of kernels

Questions for notes.md

  1. 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?
  2. 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?
  3. 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?
  4. 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?
  5. 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–20STANDARD_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:44DataChunk = a set of vectors + count. This is what next() returns.
  • src/include/duckdb/common/types/selection_vector.hpp:31SelectionVector: 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:136Pipeline::ScheduleParallel: asks source AND sink whether they support parallelism, creates one PipelineTask per allowed thread; :95 sequential fallback.
  • src/execution/operator/scan/physical_table_scan.cpp:77MaxThreads comes 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:260Execute(max_chunks): the main loop. Fetch a chunk from source (:281), push it through the operator chain — ExecutePushInternal :375, which walks operators via Execute(input, result, idx) :483 — into the sink. Note the OperatorResultType protocol: 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: Sink collects chunks into partitioned row-format storage (sink_collection, :169 Combine 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 with ResidualPredicateProbeState selection juggling (header :74–:80).

Questions for notes.md

  1. 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).
  2. CONSTANT vectors: trace 2 * price where price is FLAT and 2 is CONSTANT — which loop runs? What would a Volcano engine do per row?
  3. HAVE_MORE_OUTPUT: which operators need it and why can’t they just buffer internally? (Memory bound + who owns the chunk.)
  4. The salt trick: with 64-bit hashes and k salt bits, what fraction of non-matching probes still chase a pointer? Pick k.
  5. M11: your Expand operator explodes one source node into deg(n) results — that’s HAVE_MORE_OUTPUT shaped. 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 + MaxThreads on 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

  1. 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.
  2. 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.)
  3. 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?
  4. 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?
  5. 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:322ExecProcNode(node) is just return 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 with ExecProcNode = ExecProcNodeFirst (:448), a wrapper that does one-time checks (stack depth :457, instrumentation) then REPLACES the pointer with ExecProcNodeReal — 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:

  • :14 and :86–:126 — dispatch is a computed goto where the compiler supports it (EEO_SWITCH/EEO_CASE, :119–:126): each opcode’s implementation ends with goto *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).
  • :146ExecInterpExpr: 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

  1. 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.
  2. Computed goto vs switch: WHY does one predictor entry per opcode site help? (Think topic 0’s branch_misprediction bench.)
  3. 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.)
  4. M11: your eval.rs will interpret property predicates over batches. Linear steps or closure tree? What does postgres’ :300 peephole 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

  • postgressrc/backend/executor/: execProcnode.c (the dispatch), execExprInterp.c (the computed-goto interpreter — read the :14 header comment first), plus src/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:82Morsel = 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 explicit Graph of GraphNodes connected by pipes (pipe.rs); execute.rs:301 execute_graph drives it. Nodes are async tasks; pipes are channels — the pipeline parallelism falls out of the async runtime rather than a hand-rolled scheduler. Skim nodes/ for the operator implementations.

2. polars-compute: what a SIMD kernel actually looks like

crates/polars-compute/src/float_sum.rs:

  • :44 vector_horizontal_sum — reduce a SIMD register to a scalar, shaped “to map to good shuffle instructions”.
  • :67 SumBlock trait: sum_block_vectorized + sum_block_vectorized_with_mask — every kernel comes in a masked variant (nulls!). The mask is a BitMask, 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:97trait ExecutionPlan; execute(partition, ctx) -> SendableRecordBatchStream (:478). It’s open() returning a stream; poll_next is next(). Volcano’s SHAPE survived — what changed is the unit (Arrow RecordBatch, ~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:275GroupedHashAggregateStream, 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 vectorized update_batch(values, group_indices). No per-group objects — the group-by IS array arithmetic. This is exactly the shape your vectorized.rs group-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

DuckDBpolars-streamDataFusion
unitDataChunk 2048Morsel (config)RecordBatch ~8K
parallelismmorsel pullasync graph + tokensstatic partitions
schedulingown schedulerasync runtimetokio
orderingimplicitMorselSeqstream contract

Questions for notes.md

  1. 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.
  2. MorselSeq: which graph query results are order-sensitive? (ORDER BY obviously — anything else in Cypher? LIMIT without ORDER BY?)
  3. 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.)
  4. 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.
  5. 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

  • polarscrates/polars-stream/src/ (morsel.rs, graph.rs, execute.rs, nodes/) and crates/polars-compute/src/float_sum.rs for what a SIMD kernel actually looks like
  • datafusiondatafusion/physical-plan/src/execution_plan.rs (the trait) and aggregates/ (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

  1. 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?
  2. 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?
  3. 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.)
  4. 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:

enginepredicted M rows/spredicted ratio vs volcanoactualactual ratio
volcano (sel 50)180.7
vectorized (sel 50)
kernel (sel 50)
questionpredictionactual
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)

  1. Why 2048 not 64K (chunk bytes vs L2):
  2. CONSTANT vector trace for 2 * price:
  3. Which operators need HAVE_MORE_OUTPUT and why:
  4. Salt trick: fraction of non-matches still chasing pointers, for k salt bits:
  5. M11 Expand as HAVE_MORE_OUTPUT — state between calls:

Postgres executor (reading-postgres-executor.md)

  1. Indirect branches per tuple for SELECT sum(x) WHERE y > 10 vs per 2048 in DuckDB:
  2. Why computed goto helps (predictor entry per opcode site):
  3. Other “first call replaces itself” patterns:
  4. eval.rs: linear steps or closure tree; 3 Cypher shapes worth peepholing:

Rust execution stack (reading-rust-execution-stack.md)

  1. Async operators vs hand-rolled state machines for M11 (one-threadpool decision from topic 7):
  2. Which Cypher results are order-sensitive (MorselSeq equivalent needed?):
  3. Select-in-lanes vs compact-then-compute — selectivity threshold guess:
  4. HT probes saved by intern-then-index with 4 aggregates:
  5. Which operator contract fits SpMV-produces-a-matrix best:

X100 (reading-x100.md)

  1. Vector length that keeps 3 ops × 8 cols in M-series L1:
  2. Full-column materialization: GB moved for Q1, seconds at ~100 GB/s:
  3. Rust monomorphization of primitives — compile time/binary size cost:
  4. What autovec gives free vs what needs std::simd (answer from kernels.rs asm):

Compiled vs vectorized (reading-compiled-vs-vectorized.md)

  1. Why vectorized probing overlaps misses and fused loops don’t (MLP):
  2. Why prefetching is easy vectorized, contorted compiled:
  3. Wide-pipeline load/store count per row, both models:
  4. Prediction for kernels.rs vs vectorized.rs on THIS workload:
  5. Graph workloads’ scorecard column → JIT priority for M19:

Morsel-driven parallelism (reading-morsel-parallelism.md)

  1. What bounds morsel size below and above:
  2. Thread-local HTs + merge vs shared lock-free HT: 64 groups / 64M groups:
  3. Restoring order after morsel pulling — cost:
  4. P/E cores: NUMA-shaped or skew-shaped problem:
  5. 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:

encodingideawins ondecode cost
RLE(value, run_length) pairssorted / low-cardinality~free, can even predicate-push on runs
Dictionaryids into a distinct-value dictstrings, low NDVarray index; comparisons become int ==
Bit-packingints in ceil(log2(max-min+1)) bitssmall int rangesshift+mask, SIMD-able
FOR (frame of reference)store min + deltas from itclustered valuesadd a constant
Deltadiffs from previous valuetimestamps, sequencesprefix sum (SIMD-able, but sequential-ish)
FSSTstring symbol table: 8-byte substrings → 1-byte codesmed-cardinality strings dictionary can’t deduptable 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 MergeTreeDuckDBPinot/Druid
shapeLSM-flavored: sorted PARTS merged in background (topic 4!)single file, row groups of 122880ingest-time indexing, segments
primary indexSPARSE: one key per 8192-row granule, binary-search markszone maps onlyper-segment inverted/star-tree
orderingORDER BY key, physical sortinsertion order (+ optional sort)time-partitioned
nichefastest brute-force scansembedded analyticsreal-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/)

  1. encodings.rs — YOU implement RLE, dictionary, and bit-packing encode/decode for Vec<u64>; tests fix round-trips and exact compressed sizes.
  2. 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.
  3. duckdb-clickbench.md — run 5 ClickBench queries on DuckDB, EXPLAIN ANALYZE, note which compression each hot column chose (PRAGMA storage_info).

Reading guides

guidechapter
reading-duckdb-compression.mdDuckDB’s encoding zoo: analyze, score, commit
reading-clickhouse-mergetree.mdMergeTree: brute force, organized
reading-arrow-parquet.mdArrow & Parquet: the layout compute wants, the bytes disk wants
reading-cstore-compression.mdC-Store: operate on compressed data
reading-btrblocks-fsst.mdFSST & BtrBlocks: compress harder, stay random-access
reading-clickhouse-paper.mdClickHouse: 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 > 65 prunes 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:208ArrayData: 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 + len over 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:696 get_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:

systemstrategy
DuckDBown format; scans execute over encodings, decode per-vector
polars/DataFusionParquet → Arrow at scan, engine sees Arrow only
ClickHouseown format; decompress granules, engine sees flat columns

Questions for notes.md

  1. 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?
  2. 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?)
  3. 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.
  4. 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.)
  5. 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

  1. 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)?).
  2. Why must FSST’s table be STATIC (immutable after training) for random access + vectorized decode? What would adaptive (LZ78-style) codes break?
  3. BtrBlocks samples; DuckDB analyzes everything; ClickHouse makes you declare. Place the three on an ingest-cost / ratio-quality / operator-burden triangle.
  4. The escape byte: worst-case FSST inflation on incompressible input? Compare with Parquet RLE-hybrid’s worst case from the arrow-parquet guide.
  5. 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

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:424 getIndex / :425 loadIndexToCache).
  • Query on the key → binary search granule RANGES: MergeTreeDataSelectExecutor::markRangesFromPKRange (:1725, used at :189) — turns a predicate into a list of MarkRanges to 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

  1. 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?
  2. Two offsets per mark: why can’t it be one? (Compression block boundaries ≠ granule boundaries.)
  3. 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.)
  4. 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.
  5. 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

Code

  • ClickHousesrc/Storages/MergeTree/ (the anchors above: MergeTreeSettings.cpp, IMergeTreeDataPart.h, MergeTreeDataSelectExecutor.cpp, MergeTreeDataMergerMutator.cpp, MergeTask.h), src/Formats/MarkInCompressedFile.h, and src/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

  1. 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)?
  2. 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?
  3. Part-shipping replication: what does it give up vs WAL shipping (replication lag granularity, partial-part visibility) and why is that acceptable for analytics?
  4. 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.)
  5. 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

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 betsurvived as
columns, not rows, for readseverything in this topic
projections: same table stored MULTIPLE times, each sorted differentlymostly 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 betweenLSM-shaped! delta + main (SAP HANA), parts + inserts (ClickHouse)
compression per column, chosen by data propertiesDuckDB’s analyze/score
late materialization: join on position lists, fetch payload lastDuckDB selection vectors, Parquet late decode
k-safety via projection redundancy instead of RAIDdied; 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: SUM over 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

  1. SUM over RLE runs is O(runs). Which OTHER aggregates stay run-shortcuttable (min/max? count? avg?) and which break (distinct? median?)?
  2. 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.)
  3. The WS/RS + tuple-mover design is an LSM with different names. Map the four components onto topic 4’s vocabulary.
  4. Position lists vs bitmaps for intermediate results: when does each win? (Selectivity — connect to your topic 11 select-vs-compact question.)
  5. 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 :113 returns bytes = runs × (value + count size).
  • RLECompressState :126 — writes two interleaved arrays (values, counts).
  • The CompressionFunction registration :570 bundles 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:423ColumnData::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

  1. The analyze pass doubles ingest cost. What does BtrBlocks do instead (sampling) and what does it risk?
  2. fetch_row on 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).
  3. RLE score vs dictionary score on a column of 50% NULLs: which wins and why does validity (empty_validity.cpp) change the answer?
  4. Zone map always-true result removes the FILTER — when does that matter more than segment skipping? (Selectivity ~100% — filter cost itself.)
  5. 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.hpp first (the lifecycle contract is documented in the header), then the encoders in src/storage/compression/ (rle.cpp, bitpacking.cpp, dictionary_compression.cpp, fsst.cpp, dict_fsst/, zstd.cpp); zone maps in src/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.

shapeencodingpredicted vs raw (faster/slower, ×)actual
sorted low-cardrle sum (no decode)
sorted low-cardrle decode+sum
shuffled low-carddict sum (codes only)
small-range randombitpack decode+sum
questionpredictionactual
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)

querytimerows pruned by zone mapshot column compression (storage_info)
Q0
Q3
Q8
Q13
Q20

Questions from the reading guides

DuckDB compression (reading-duckdb-compression.md)

  1. BtrBlocks sampling vs full analyze — what sampling risks:
  2. fetch_row on DELTA_FOR — cost, and why OLAP tolerates it:
  3. RLE vs dict on 50% NULLs (validity changes the answer how):
  4. Zone-map always-true (filter removal) — when it beats skipping:
  5. Bitpacking mode for graph node-id payload columns:

ClickHouse MergeTree (reading-clickhouse-mergetree.md)

  1. Worst-case over-read for a point query (granularity 8192, 3 cols):
  2. Why marks need two offsets:
  3. ORDER BY (user_id,ts) vs (ts,user_id) — zone maps on col 2:
  4. Too-eager vs too-lazy merging — topic 4 analogue:
  5. Part-equivalent for mergeable property segments + SummingMergeTree-style degree stats:

Arrow + Parquet (reading-arrow-parquet.md)

  1. Why Arrow has ~no encodings (what delta breaks for kernels):
  2. RLE-hybrid rationale + worst case vs PLAIN:
  3. BYTE_STREAM_SPLIT = columns-beat-rows one level down:
  4. Truncated string max stats — the increment-the-prefix bug:
  5. M12 optional properties: validity bitmap vs roaring presence, 1% vs 99%:

C-Store + SIGMOD ’06 (reading-cstore-compression.md)

  1. Run-shortcuttable aggregates (min/max/count/avg yes-ish; distinct/median?):
  2. What made ClickHouse projections affordable when C-Store’s weren’t:
  3. WS/RS/tuple-mover → topic 4 vocabulary map:
  4. Position lists vs bitmaps — selectivity crossover:
  5. Process-compressed plan for n.country = 'IL' at 1% selectivity:

BtrBlocks + FSST (reading-btrblocks-fsst.md)

  1. URLs / country codes / UUIDs — winner per case + cascade for URLs:
  2. Why FSST’s table must be static:
  3. Ingest-cost/ratio/burden triangle: sample vs analyze vs declare:
  4. FSST worst-case inflation vs RLE-hybrid worst case:
  5. String property cascade + where predicate-on-encoded works:

ClickHouse paper (reading-clickhouse-paper.md)

  1. Where ClickHouse barely wins and why:
  2. Merge-starvation failure mode — topic 4 stall analogue:
  3. Part-shipping vs WAL-shipping tradeoffs:
  4. M12 decision: who chooses encodings for graph property columns (declare / analyze / sample) — commit + reason:
  5. 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 > 65 before/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

neo4jmemgraphkuzuFalkorDB
storefixed-size records on disk, linked listsin-memory skip lists per vertexcolumnar CSR node groups on disksparse matrices (SuiteSparse)
Expandchase rel-chain pointerswalk vertex’s edge vectorslice CSR + vectorized opsmatrix row extract / SpMV
pattern matchone expand at a timeone expand at a timebinary joins + WCOJ intersectmasked matrix multiply
MVCCpage-based + txn statedelta chains per object (topic 8’s N2O)MVCC on node groupssingle-writer + matrix COW (delta matrices)
updatesin-place recordsin-memory, GC’d deltasCSR rebuild per node group, delta buffersdelta 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:

modelmatchingcomposable?pushdown-friendly?
Cypher/openCypherproperty graphhomomorphism, rel-trail for var-lengthweak (CALL {} bolted on)good
GQL (ISO 39075:2024)property graphconfigurable: ALL/TRAIL/ACYCLIC + quantified path patternsgraph tablesgood
SQL/PGQproperty graph inside SQLGQL’s MATCH in GRAPH_TABLE(...)full SQLinherits SQL
SPARQLRDF tripleshomomorphism (BGP)subqueriesunion-heavy plans
Gremlinproperty graphimperative traversalpipelinesalmost none — you ARE the plan
Datalogrelationshomomorphism + fixpointtotal — rules feed rulesrecursion-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:

  1. adj_list.rs — PROVIDED: Vec<Vec<u32>> walk. The oracle.
  2. csr.rs — YOU implement: build (counting sort) + two_hop over slices with a reusable visited bitmap.
  3. 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).
  4. 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.
  5. 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

guidechapter
reading-graphblas-internals.mdGraphBLAS & Delta_Matrix: the graph as matrices
reading-neo4j-record-store.mdNeo4j’s record store: the price of index-free adjacency
reading-memgraph-storage.mdMemgraph: skip lists, edge vectors, delta MVCC
reading-kuzu.mdKùzu: DuckDB for graphs
reading-wcoj.mdWorst-case optimal joins: intersect, don’t enumerate
reading-ldbc-snb.mdLDBC SNB: the graph benchmark referee
reading-query-languages.mdGraph 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:

  • :1664 GxB_HYPERSPARSE — offsets only for NON-empty rows (graphs where most node IDs have no edges of a given type)
  • GxB_SPARSE — plain CSR/CSC
  • :1666 GxB_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/dot4 files in Source/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:46 SyncMatrixFunc)
  • 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)

  1. Why does FalkorDB need delta_minus at all — why not delete directly from M? (What does deleting one entry from CSR cost?)
  2. dot3 vs saxpy for a BFS step at frontier size 10 vs 10⁶ on a 1M-node graph — which algorithm and why?
  3. When is BITMAP the right format for a label matrix? Relate to the density thresholds.
  4. The read = (M ∪ DP) ∖ DM identity means every read touches three matrices. Why is this still a win vs flushing on every write?
  5. 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.h for the four formats and switch thresholds, Source/mxm/GB_AxB_meta.c (the header comment is the algorithm menu), Source/mask/GB_masker.c
  • FalkorDBsrc/graph/graph.h, src/graph/delta_matrix/delta_matrix.h (the ASCII state diagrams in the header are the spec), plus delta_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 a csrIndex mapping bound node → row indices). Reads merge both. Same LSM-shaped answer as Delta_Matrix: read-optimal core + mutable overlay.
  • :172 class 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)

  1. Rebuild-per-node-group bounds update cost. What’s the worst case — which insert pattern still hurts? (Hint: supernode crossing groups.)
  2. Why must adjacency lists be SORTED for Intersect? What does the build side (intersect_build.h) have to guarantee?
  3. Triangle count on m=16M edges: estimate intermediates for binary join vs AGM bound. How many × saved?
  4. 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!)
  5. 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)

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)

  1. Why does Interactive schedule inserts with timed dependencies instead of firing them as fast as possible?
  2. Pick IC5-ish “recent posts of friends-of-friends”: write the pattern, mark the anchor, count the expands. Which topic-13 representation hurts most?
  3. Uniform-degree graph, same edge count: which of this topic’s four architectures looks RELATIVELY better than it deserves, and why?
  4. What’s the graph analogue of JOB’s “cardinality errors dwarf cost model errors” — at which hop does estimation die?
  5. 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

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:29 Edges = 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 — each Vertex* dereference is a fresh miss.
  • PointerPack<Delta, 2> — the delta pointer with kDeletedBit and kNonSeqDeltasBit smuggled 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)

  1. 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)?
  2. small_vector inlines a few elements before heap-spilling. Which degree distribution fact (power law) makes this a big win?
  3. Delta chains are per-OBJECT here, per-VERSION-ROW in postgres. Which is better for a graph supernode under concurrent edge inserts, and why?
  4. 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.)
  5. 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.hpp is 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)

  1. 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 × ?
  2. Why 15 B for nodes but 34 B for relationships? What does each field buy?
  3. The doubly-linked chain gives O(1) delete-given-record. What does delete cost in CSR? In Delta_Matrix?
  4. neo4j stores properties in a separate chain (nextProp). How does that compare to M12’s columnar property storage for WHERE n.age > 65?
  5. “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 both readRecord methods 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

  1. 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?
  2. 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?
  3. RDF reification: model (:alice)-[:KNOWS {since: 2019}]->(:bob) as plain triples. How many triples? What does the since > 2015 filter look like, and what index does it now need?
  4. 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?
  5. 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?
  6. 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’s ALL 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)

  1. Star graph, hub degree 1M: count R⋈S intermediates vs triangle output. Where did they go?
  2. 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)?
  3. Galloping search wins when d1 ≪ d2. Which real-graph fact makes this the common case?
  4. 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!)?
  5. 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

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.

implsourcespredicted vs adj_list (×)actual ns/query
csrrandom
csrsupernodes
matrix (SpMV)random
matrix (SpMV)supernodes
questionpredictionactual
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)

  1. Why delta_minus instead of deleting from M (CSR delete cost):
  2. dot3 vs saxpy at frontier 10 vs 10⁶:
  3. When BITMAP fits a label matrix:
  4. Why (M ∪ DP) ∖ DM reads beat flush-per-write:
  5. Delta_Matrix → LSM vocabulary map:

neo4j record store (reading-neo4j-record-store.md)

  1. 1000-edge Expand: chain (~110 ns/edge) vs CSR stream — ×:
  2. Why 15 B nodes / 34 B rels — field inventory:
  3. Delete cost: chain vs CSR vs Delta_Matrix:
  4. Property chains vs M12 columns for WHERE n.age > 65:
  5. Index-free adjacency: what survives DRAM, what died:

memgraph storage (reading-memgraph-storage.md)

  1. Why edges in both endpoints’ vectors; FalkorDB’s transposed trio:
  2. small_vector inline + power-law degrees:
  3. Per-object vs per-row delta chains under supernode edge inserts:
  4. memgraph vector vs kuzu CSR slice — 16 B triples vs 8 B ids:
  5. PageRank on pointer soup vs matrix — where the bus time goes:

kuzu (reading-kuzu.md)

  1. Node-group-bounded rebuilds — which insert pattern still hurts:
  2. Why Intersect needs sorted adjacency:
  3. Triangle intermediates: binary join vs AGM at m=16M:
  4. Factorized count(*) 2-hop as a matrix expression:
  5. Best topic-12 encoding for CSR target columns:

WCOJ (reading-wcoj.md)

  1. Star-graph R⋈S intermediates vs triangle output:
  2. AGM bound for the 4-cycle:
  3. Galloping and power-law skew:
  4. Why C<A>=A² never materializes A² (dot3):
  5. M10 trigger for intersect vs binary join (pattern cyclicity):

LDBC SNB (reading-ldbc-snb.md)

  1. Why timed dependency-tracked inserts:
  2. IC5-ish pattern: anchor + expand count + worst representation:
  3. Which architecture flatters itself on uniform-degree graphs:
  4. Where multi-hop cardinality estimation dies:
  5. 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:

schemebytes/dim (f32=4)distance on encodedrecall cost
scalar u81integer dot + affine postprocesstiny
PQ (m chunks × 256 centroids)~0.06–0.5LUT sums — d/m table lookupsreal
binary1 bitXOR + popcountbig, 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/)

  1. brute.rs + data.rs + distance.rs — PROVIDED: exact top-k oracle over seeded clustered vectors; the recall referee.
  2. 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.
  3. quant.rs — YOU implement: global-min/max scalar u8 quantization + integer-dot distance + rescoring pipeline. Tests pin the error bound and rescored recall.
  4. 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

guidechapter
reading-hnsw-paper.mdHNSW: a skip list in metric space
reading-qdrant-hnsw.mdQdrant’s HNSW: filtered search is a planner problem
reading-qdrant-quantization.mdThe quantization ladder: shrink, search, rescore
reading-usearch.mdusearch: HNSW with the fat trimmed
reading-pq.mdProduct quantization: 2^128 centroids in 16 bytes
reading-diskann.mdDiskANN: 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:

  • vector property 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)

  1. Count SSD reads: HNSW-on-disk (links and vectors separate) vs DiskANN per hop. Where did the factor go?
  2. Why α > 1 provably shortens greedy walks — sketch the geometric argument (each hop shrinks distance by α).
  3. Beam search issues W reads concurrently. Connect to topic 0’s MLP: what’s the SSD equivalent of “10 outstanding misses”?
  4. Why is it fine that PQ steers but f32 ranks? What recall failure remains possible (PQ error > neighbor spacing → wrong REGION)?
  5. 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. extendCandidates and keepPrunedConnections are 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

parampaperusearch defaultmeaning
M5-4816 (connectivity)links/node upper layers
M02M32links at layer 0
ef_construction~100128 (expansion_add)build-time beam
ef≥ k64 (expansion_search)query-time beam — THE knob

What to notice

  1. ef is per-QUERY: the recall/latency trade is decided at search time, not build time — nothing in the index changes.
  2. The heuristic (Alg 4) is where implementations differ or cheat; qdrant’s use_heuristic flag (graph_layers_builder.rs:41-42) makes it optional, usearch always applies it.
  3. Distance metric only enters via comparisons — HNSW works for any metric-ish function, which is why cosine/dot/l2 are one codebase.
  4. 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)

  1. Derive why mL = 1/ln(M) gives expected max level ln(n)/ln(M).
  2. What breaks if you connect to the M NEAREST instead of Alg 4’s heuristic on two well-separated clusters? Draw it.
  3. Why must ef ≥ k? What happens at ef = k exactly?
  4. Where does HNSW’s memory go for n=1M, d=128, M=16 (f32)? Vectors vs links — which dominates and by how much?
  5. 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

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’s EncodedQueryPQ, 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)

  1. 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?
  2. 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).
  3. ADC table build is m·256·(d/m) float ops per query. At what shortlist size does table build dominate scanning?
  4. Why encode residuals instead of raw vectors in IVFADC? State it in FOR terms.
  5. 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’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:35 GraphLayersBuilder — per-node RwLock’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_factor at :391), link_new_point (:414) — Alg 1.
  • :41-42 use_heuristic — Alg 4 as a flag; find select_candidates_with_heuristic below it and match the paper.
  • graph_layers.rs:74 GraphLayers — 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:8 SearchContext — the two bounded heaps of Alg 2. visited_pool.rs:9 VisitedListHandle — 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, FilteredScorer rejects non-matching points during traversal
  • selectivity low → search_plain_batched (:264) — brute-force the filtered id list; below full_scan_threshold the 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-109full_scan_threshold derives the “indexing threshold”: tiny segments never build a graph at all
  • graph_links.rs — serialized link format: delta-compressed, topic 12 encodings applied to graph edges
  • gpu/ — 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)

  1. Why does the visited pool matter more here than in hop_bench? (Concurrency + allocation, name both.)
  2. 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?
  3. estimate_cardinality comes from the payload index. What’s the M14 equivalent — which structure estimates label selectivity? (M13’s label bitmaps.)
  4. Why is full_scan_threshold in BYTES-ish terms (kB) rather than a point count? (Think d and the real cost unit.)
  5. 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)

  • :30 CENTROIDS_COUNT = 256 — one byte per chunk, by construction; :27-29 k-means over a 10K sample (BtrBlocks-style sampling, topic 12), max 100 iterations
  • :32 EncodedVectorsPQ — codes = chunk-wise centroid ids; :46 Metadata.centroids
  • :39-41 EncodedQueryPQ — 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)

  • :26 EncodedVectorsBin, one bit per dim (sign)
  • :144 xor_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)

  1. Derive the u8 dot-product expansion above; what must be stored per vector for it to work? (Σvᵢ.)
  2. Why does PQ hurt HNSW traversal more than it hurts a flat IVF scan? (Where do approximate distances compound?)
  3. 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?
  4. 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?
  5. 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 in lib/segment/src/vector_storage/quantized/ (quantized_scorer_builder.rs and the storage variants) and lib/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

  • :2242 class index_gt — the whole index: a vector of node pointers + per-node tapes
  • :2404 neighbors_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

  • :1563 default_connectivity() = 16 (M)
  • :1591 connectivity_base = 2 × M (M0) — computed at :1604
  • :1568 default_expansion_add() = 128 (ef_construction)
  • :1573 default_expansion_search() = 64 (ef)

3. The three core walks

  • :3234 search_to_insert_ — Alg 1’s per-level beam during insert; :3239 form_links_to_closest_ (defined :4262) applies the Alg 4 heuristic and back-links (shrinking overfull neighbors)
  • :3446 search_to_find_in_base_ — Alg 2 on layer 0 with an optional predicate — 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)

  1. 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).
  2. Why preallocate link slots to the max instead of growing? What does it cost in memory, and what does it buy under concurrent insert?
  3. Filter-during-traversal with a 1% predicate on usearch: what happens, and which qdrant mechanism was built to fix exactly this?
  4. usearch templates the metric; qdrant enum-dispatches scorers. Map this to topic 11’s compiled-vs-vectorized argument — who wins where?
  5. 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

Code

  • usearch — all of it in include/usearch/index.hpp (+ index_dense.hpp for 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).

configpredicted recall@10predicted QPSactual recallactual QPS
hnsw ef=16
hnsw ef=64
hnsw ef=256
u8 scan+rescore ×1
u8 scan+rescore ×4
questionpredictionactual
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)

  1. Why mL = 1/ln(M) ⇒ E[max level] = ln(n)/ln(M):
  2. M-nearest vs Alg-4 heuristic on two clusters (draw it):
  3. Why ef ≥ k; what happens at ef = k:
  4. 1M × 128-d, M=16: vectors vs links bytes:
  5. Skip-list analogue of the fixed entry point:

qdrant HNSW + filtering (reading-qdrant-hnsw.md)

  1. Why the visited pool matters more than in hop_bench:
  2. ACORN 2-hop vs payload_m extra links — cost each, when each:
  3. M14’s estimate_cardinality equivalent (label bitmaps):
  4. Why full_scan_threshold is in kB not points:
  5. Build/serve split → kuzu transient/persistent + Delta_Matrix map:

qdrant quantization (reading-qdrant-quantization.md)

  1. u8 dot expansion derivation + what’s stored per vector:
  2. Why PQ hurts HNSW traversal more than IVF scans:
  3. binary 1536-d vs u8 128-d: bytes/distance/recall/oversampling:
  4. ADC LUT [m×256] in L1 — d=128 m=16 vs m=64:
  5. M14 quantization rung — commit + reason:

usearch (reading-usearch.md)

  1. Bytes/node: tape vs Vec-of-Vecs (headers, slack, allocator):
  2. Why preallocate max link slots:
  3. 1% filter on usearch traversal — what happens, qdrant’s fix:
  4. Template metric vs enum scorer → compiled-vs-vectorized map:
  5. My hnsw.rs layout decision + M17 SIMD needs:

PQ (reading-pq.md)

  1. m=16 vs m=64 at fixed bytes — which knob trades what:
  2. Chunk independence + OPQ ↔ BYTE_STREAM_SPLIT:
  3. When ADC table build dominates:
  4. Residual encoding in FOR terms:
  5. Why nobody ships SDC:

DiskANN (reading-diskann.md)

  1. SSD reads per hop: naive HNSW-on-disk vs DiskANN:
  2. α > 1 shortens walks — geometric argument:
  3. Beam width W ↔ topic 0’s MLP:
  4. PQ steers / f32 ranks — the remaining failure mode:
  5. 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, AppendEntries carries (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/)

  1. 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).
  2. 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.
  3. partition_test — PROVIDED: 5-node cluster timeline under partition/heal, prints who leads, what commits when.
  4. 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

guidechapter
reading-raft-paper.mdRaft: logs converge by construction
reading-valkey-replication.mdValkey replication: ack first, replicate later
reading-raft-rs.mdraft-rs: consensus with the I/O left out
reading-qdrant-consensus.mdQdrant’s consensus: raft for metadata, replica sets for data
reading-vsr.mdViewstamped Replication: same invariants, opposite choices
reading-ddia-repl.mdLag, 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_timeout ticks).
  • 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_commit test 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

  1. Build the 2×3 matrix: {async, semi-sync, raft} × {read-your- writes, monotonic reads, consistent prefix} — which combos hold?
  2. 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?
  3. Fencing tokens: sketch how M15’s follower rejects a stale leader’s WAL stream using terms.
  4. Why does FLP not doom Raft in practice? One sentence.
  5. 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

anchorwhat it is
consensus.rs:36type Node = RawNode<ConsensusStateRef>
consensus.rs:48struct Consensus — the driving loop owner
consensus.rs:537the ready loop: tick / step / process
consensus.rs:877on_ready — drain the Ready bundle
consensus.rs:885/928/1017Ready 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

  1. Why is metadata volume low enough for raft but point writes not? Estimate: 10K upserts/s × majority fsync (topic 5 numbers) = ?
  2. Replica states Active/Dead/Partial — map each to a Raft Progress state (replicate/probe/snapshot). Same problem, different layer?
  3. What consistency does a qdrant READ get on vectors? Is it linearizable? Under what config?
  4. 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?
  5. Where does qdrant persist the raft log and HardState? Find the Storage impl behind ConsensusStateRef.

References

Code

  • qdrantsrc/consensus.rs (the driving loop; the anchor map above) and lib/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

sectionwhat to extract
§5.1the three states + RPC menu (only 2 RPCs!)
§5.2elections: terms, randomized timeouts
§5.3log replication: the consistency check + repair
§5.4safety — read TWICE, especially §5.4.2
§6membership changes (joint consensus) — skim
§7log compaction / snapshots — skim, topic 5 déjà vu
Fig 2the 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_for is PERSISTED before answering. Question: what double-vote scenario does a crash+restart create if voted_for were 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:

  1. 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).
  2. §5.4.2 — the current-term commit rule: a leader only advances commit_index via 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

  1. Why persist (current_term, voted_for, log) but NOT commit_index? What recomputes commit_index after restart?
  2. Fig 8 step-by-step: which specific quorum-intersection argument fails without the current-term rule?
  3. Why does a leader never overwrite/delete its OWN log entries, and what breaks if it could?
  4. §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?)
  5. 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

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

anchorwhat it is
raw_node.rs:293RawNode — the public wrapper
raw_node.rs:487ready() — collect pending work
raw_node.rs:562has_ready() — the poll predicate
raw_node.rs:663advance() — “I did the work”
raw_node.rs:678advance_append — split persistence ack
raft.rs:263Raft<T: Storage> — the actual state machine
raft.rs:939maybe_commit — §5.4.2 lives here
raft.rs:1148/1176/1226become_follower/candidate/leader
raft.rs:1283campaign
raft.rs:1346step — the message dispatch root
raft.rs:1539hup — election timeout fires
raft.rs:2045/2291/2348step_leader/candidate/follower
raft.rs:2499handle_append_entries
tracker/progress.rs:8-12Progress { 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_append lets 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-rsour stub
Ready bundle + advancedirect send via Sim (no I/O to defer)
Storage trait + persistencein-memory Vec<(term, cmd)>
Progress probe/snapshot statesjust next_idx decrement
joint-consensus membershipfixed peer set
pre-vote, leases, learnersabsent

Same invariants pinned by tests; ~10× less plumbing.

Questions for notes.md

  1. Why does raft-rs contain no fsync, no sockets, no threads — and what does that buy tikv/qdrant integration-wise?
  2. maybe_commit: write out the sorted-matched-index computation for 5 nodes with matched = [7,5,5,3,2]. Commit index?
  3. 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?
  4. advance_append: how does splitting the persistence ack enable pipelining, and what must you still NOT reorder?
  5. Map Ready → M15 stage 2: which parts of your WAL commit path play the roles of persist/send/apply/advance?

References

Papers

Code

  • raft-rssrc/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

anchorwhat it is
replication.c:137createReplicationBacklog — the resync ring
replication.c:352-366feedReplicationBufferWithObject — one buffer, many readers
replication.c:449feedReplicationBuffer — append + wake replicas
replication.c:854primaryTryPartialResynchronization — PSYNC accept/deny
replication.c:1077syncCommand — full sync: fork + RDB + stream
replication.c:3731+replica-side REPL_STATE_* handshake machine
replication.c:4564replicaofCommand — topology is a runtime command
replication.c:4947replicationRequestAckFromReplicas
replication.c:4996waitCommand — the semi-sync opt-in
replication.c:5565failoverCommand — coordinated manual failover
server.c:3609propagateNow — 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

  1. Replication is statement-shipping after propagateNow rewrites — what’s the analogue of topic 5’s logical-vs-physical WAL choice?
  2. Backlog sizing: repl-backlog-size vs write rate vs disconnect duration — write the inequality for “partial resync succeeds”.
  3. Chained replication (replica of a replica): how do offsets stay coherent down the chain?
  4. Why does full sync fork? Connect to topic 5’s copy-on-write snapshot discussion.
  5. For M15 stage 1: which parts of PSYNC do you keep (replid+offset, backlog ring, +CONTINUE/+FULLRESYNC) and which do you simplify?

References

Code

  • valkeysrc/replication.c (~5600 lines; slice it with the anchor map above rather than reading linearly) and src/server.c (propagateNow, the statement-rewrite point)

Papers

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

RaftVSR
termview
leaderprimary
electionview change
log indexop-number
commit_indexcommit-number
RequestVote / AppendEntriesSTARTVIEWCHANGE / DOVIEWCHANGE / PREPARE / PREPAREOK

The three sub-protocols

  1. Normal operation: client → primary → PREPARE to all → wait f PREPAREOKs (f+1 including self = majority) → commit → reply. Same wire shape as AppendEntries.
  2. 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.
  3. 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

  1. 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)?
  2. DOVIEWCHANGE ships whole logs to the new primary — Raft ships nothing at election, repairing later. Bandwidth vs latency: when is each better?
  3. The no-disk argument: write the failure sequence where VSR- without-disk loses committed data but Raft-with-fsync doesn’t.
  4. Why does the recovery protocol need a nonce?
  5. 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 fsyncentries/sack p50 µsack p99 µs
every 13392972.73506.5
every 8243118.53706.3
every 64966513.62880.1
never185686.232.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.

questionpredictionactual
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)

  1. Why persist (term, voted_for, log) but not commit_index:
  2. Fig 8 without the current-term rule — which intersection fails:
  3. Why a leader never overwrites its own entries:
  4. Snapshot needs last_included_term because:
  5. valkey vs Raft: what async gives up, what it gets back:

valkey replication.c (reading-valkey-replication.md)

  1. Statement-shipping vs WAL-shipping ↔ topic 5 logical/physical:
  2. Backlog-size inequality for partial resync success:
  3. Chained replication offset coherence:
  4. Why full sync forks (COW):
  5. M15 stage 1: which PSYNC parts to keep:

raft-rs (reading-raft-rs.md)

  1. Why no fsync/sockets/threads in the library:
  2. maybe_commit on matched=[7,5,5,3,2] → commit index:
  3. next_idx decrement optimization (§5.3 footnote):
  4. advance_append pipelining — what still can’t reorder:
  5. Ready → M15 stage 2 mapping:

qdrant consensus (reading-qdrant-consensus.md)

  1. 10K upserts/s through raft = ? (use the 3 ms fsync above):
  2. Active/Dead/Partial ↔ Progress replicate/probe/snapshot:
  3. qdrant vector-read consistency:
  4. WAL-through-raft: right for a graph DB? FalkorDB’s answer:
  5. Storage impl behind ConsensusStateRef:

VSR (reading-vsr.md)

  1. Round-robin primary: removes / costs:
  2. DOVIEWCHANGE ships logs vs Raft repairs later — when each wins:
  3. VSR-no-disk loses committed data when:
  4. Recovery nonce prevents:
  5. TigerBeetle’s “disk can lie” ↔ topic 5 torn pages:

DDIA ch. 5/8/9 (reading-ddia-repl.md)

  1. {async, semi-sync, raft} × {RYW, monotonic, prefix} matrix:
  2. WAIT-1-then-wrong-promotion — which guarantee broke:
  3. Terms as fencing tokens in M15’s follower:
  4. Why FLP doesn’t doom Raft in practice:
  5. 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:

techniquegeneratororacle
property testingrandom opsin-memory model
DSTrandom ops + FAULTS + sim clockmodel + invariants
PQS (SQLancer)random query around a pivot row“pivot row must appear”
TLP / metamorphicone query, three partitionsself-consistency
fuzzingcoverage-guided byte mutation“doesn’t crash”
Jepsen/elleconcurrent client historieslinearizability checker
Z3 / Cosettesymbolic (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!). So Q ≡ 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 t counted 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/)

  1. 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.
  2. 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::None survives 500 seeds.
  3. shrink.rs — YOU implement: delta-debugging minimizer — a failing op sequence shrinks to a minimal reproducer that still fails.
  4. 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.
  5. 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

guidechapter
reading-turso-simulator.mdturso’s simulator: every failure is a u64 seed
reading-fdb-simulation.mdFoundationDB & Antithesis: the whole cluster in one thread
reading-sqlancer.mdSQLancer: 450+ bugs from three tiny oracles
reading-pqs-tlp-papers.mdPQS & TLP: solving the test-oracle problem twice
reading-jepsen.mdJepsen & elle: isolation anomalies are cycles
reading-z3.mdZ3 & 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

  1. Determinism: one seed reproduces a whole-cluster failure, including the partition timings. (Our topic 15 sim.rs in the large.)
  2. 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)?
  3. 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

  1. 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?
  2. BUGGIFY is compiled out in production. What’s the argument that test-only branches DON’T invalidate what you tested?
  3. 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?
  4. Why does deterministic simulation get FASTER than real time for IO-bound workloads (logical clock jumps to next event)?
  5. 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

  • foundationdbflow/README.md — the Flow language: actors + futures compiled to deterministic state machines; skim for the wait()-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)

JepsenDST (turso/FDB)
SUTunmodified binaryinstrumented / DI’d
faultsreal (iptables, SIGSTOP)simulated
reproducibilitystatistical, flakyperfect (seed)
findsintegration + env bugslogic bugs, deep interleavings
checkerelle (history-based)model/invariant (state-based)

They’re complements: DST explores deeper, Jepsen believes nothing you told it.

Questions for notes.md

  1. 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)?
  2. elle needs append+read-full-list ops. What can it NOT check about a system that only exposes get/set registers?
  3. An elle cycle of pure rw edges (write skew) — which isolation level permits it and which forbids it? (Topic 8 refresher.)
  4. Redis-raft served stale reads from deposed leaders. Write the ReadIndex fix in one sentence and its cost per read.
  5. 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

  1. 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?
  2. Containment-not-equality: construct a bug PQS provably misses and TLP provably catches, and vice versa.
  3. TLP with p = col = col — why is this predicate USELESS for partitioning, and what does that say about predicate generation?
  4. 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?
  5. 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: 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

anchorwhat it is
PivotedQuerySynthesisBase.java:14the PQS skeleton
PivotedQuerySynthesisBase.java:30pivotRow — the chosen row
PivotedQuerySynthesisBase.java:37-51check(): rectified query → containment query → “pivot missing” = bug
TLPWhereOracle.java:76-92check(): original result vs 3-way partition union
NoRECOracle.java (reproducer)optimizedQuery != unoptimizedQuery → bug
TernaryLogicPartitioningOracleBase.javagenerates 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(&not(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

oracleneeds own evaluatorfindsblind to
PQSYES (per dialect)expression eval bugsbugs off the pivot row
TLPnooptimizer logic bugsbugs symmetric across partitions
NoRECnopushdown/index bugsanything both paths share

They compose: run all three on the same generated schema/data (CompositeTestOracle.java).

Questions for notes.md

  1. PQS checks ONE row per query. Why is that enough in expectation (think: bugs are input-conditioned, generation is cheap)?
  2. Rectification: predicate evaluates NULL on the pivot. Show why WHERE p loses the row but WHERE p IS NULL keeps it.
  3. Write the TLP identity for COUNT(*) and for MAX(c) — which aggregate makes the partition check subtle, and why?
  4. turso’s SelectSelectOptimizer / WhereTrueFalseNull properties (reading-turso-simulator.md) — map each to PQS/TLP/NoREC.
  5. 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

Code

  • sqlancersrc/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

anchorwhat it is
runner/clock.rs:8-13SimulatorClock { curr_time, rng: ChaCha8Rng, min_tick, max_tick }
runner/clock.rs:25-34now() ADVANCES time by a seeded random tick — time is data
runner/io.rs:14fault: Cell<bool> — the injection master switch
runner/io.rs:64-77inject_fault / inject_fault_selective (per-file stem!)
runner/io.rs:135-138per-op fault counters: pread/pwrite/sync faults
runner/file.rs:40latency_probability — seeded IO delay
runner/file.rs:100-110generate_latency_duration — random_bool from the file’s rng
runner/file.rs:149-233every op (read/write/sync) can be delayed into a DelayedIo queue
generation/property.rs:270FsyncNoWait / FaultyQuery — fault-flavored properties
generation/property.rs:276-282the metamorphic set: SelectSelectOptimizer, WhereTrueFalseNull, UnionAllPreservesCardinality, ReadYourUpdatesBack
fuzz/fuzz_targets/expression.rs:299fuzz_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

  1. ChaCha8 everywhere, not the default RNG — why does DST need a portable, versioned RNG? What breaks on rand upgrades?
  2. inject_fault_selective targets file stems (WAL vs db file) — which bug class needs faults on ONE file only?
  3. Where does turso’s simulator sit vs Antithesis (whole-VM determinism)? What can each test that the other can’t?
  4. The shrink/ module: why is shrinking HARDER for stateful op sequences than for pure inputs (proptest’s integrated shrinking vs delta debugging)?
  5. For M16: which three properties from generation/property.rs port directly to Cypher? Sketch the graph equivalents.

References

Code

  • tursotesting/simulator/ (clock/io/file fault injection, interaction plans, properties, doublecheck, shrink) plus fuzz/fuzz_targets/expression.rs for structured fuzzing via arbitrary — 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

anchorwhat it is
src/solver/solver.h:58class solver — check_sat over assertions
src/smt/smt_context.h:89smt::context — the CDCL(T) core loop
src/tactic/tactic.h:34class tactic — composable transformers
src/tactic/portfolio/default_tactic.cppthe default strategy: probe → dispatch by logic
src/tactic/portfolio/smt_strategic_solver.cpptactic → solver bridge
src/ast/hash-consed terms (one node per distinct expr — topic 2’s interning)
src/smt/mam.cppmatching 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

  1. 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)?
  2. Hash-consing in src/ast: same trick as our string interning (topic 2) and Arrow dictionary encoding — what operation becomes O(1) pointer compare?
  3. Encode WHERE NOT (a = b) vs WHERE a <> b over nullable a, b in Kleene logic — equivalent or not? (Do it on paper, then check what Z3 says in the z3 rewrite exercise.)
  4. Why does Cosette need K-relations (bags) rather than sets — which standard rewrite is set-valid but bag-INVALID? (DISTINCT pushdown…)
  5. 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

  • z3src/ — start from src/solver/solver.h and src/smt/smt_context.h, then the tactic machinery in src/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:

bugcaughtratefirst seed
None00.0%
LostDelete373874.8%0
NoSyncOnCommit498099.6%0
TornWriteAccepted244248.8%3
StaleRead470694.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)

questionpredictionactual
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)

  1. Why now() must ADVANCE time:
  2. Per-file-stem fault targeting — which bug class:
  3. turso sim vs Antithesis — what each can’t test:
  4. Why stateful shrinking is harder than pure-input shrinking:
  5. Three properties to port to Cypher:

FDB / Antithesis (reading-fdb-simulation.md)

  1. Disk-lies vs Raft’s assumptions (+ VSR/TigerBeetle answer):
  2. Why BUGGIFY branches don’t invalidate the test:
  3. The four escapes (compiler/kernel/sim-bug/wall-clock) — who catches:
  4. Why simulation outruns real time:
  5. Our engine’s remaining nondeterminism sources for M16:

SQLancer code (reading-sqlancer.md)

  1. PQS false-positive triage (whose evaluator is wrong):
  2. Why TLP’s p must be deterministic:
  3. NoREC-visible vs -invisible topic 10 rules:
  4. turso properties → PQS/TLP/NoREC mapping:
  5. Cypher TLP: what plays NULL in a graph pattern:

PQS + TLP papers (reading-pqs-tlp-papers.md)

  1. Why rectification wastes no generated expressions:
  2. A bug PQS misses but TLP catches (and vice versa):
  3. Why AVG doesn’t decompose (↔ topic 11 partial aggregation):
  4. Why index-present/absent runs sharpen the oracles:
  5. First three Cypher TLP recombinations + their ⊎:

Jepsen / elle (reading-jepsen.md)

  1. What SIGSTOP models that kill -9 doesn’t:
  2. What elle can’t check over plain registers:
  3. Pure-rw cycle = write skew — permitted/forbidden where:
  4. ReadIndex fix in one sentence + cost:
  5. Mini-elle over topic 15’s sim — what determinism trivializes:

Z3 / Cosette (reading-z3.md)

  1. Why DB rewrite proofs stay quantifier-free:
  2. Hash-consing → what becomes pointer compare:
  3. NOT (a = b) vs a <> b under NULLs — Z3’s verdict:
  4. The set-valid bag-invalid rewrite:
  5. 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)

  1. 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).
  2. 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.
  3. Gather/indirection: vals[idx[i]] — hardware gathers exist but cost ~1 load/lane anyway (topic 13’s pointer-chasing tax).
  4. 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 vqtbl1q nibble lookups, carry-less multiply prefix_xor for 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: Vector trait over ISAs; the 4×-unrolled search loop.
  • Mojo: SIMD[type, width] as a first-class parametric type — what std::simd wants 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):

  1. dot.rs — dot product. PROVIDED: naive (1 chain) + unrolled-8 (autovec). YOU implement: wide f32x4 and NEON vfmaq_f32 with 4 accumulators. Tests: equivalence within 1e-2 relative.
  2. filter.rs — count + compact under a threshold. PROVIDED: branchy + branchless scalar. YOU implement: NEON count (vcltq+vshrn popcount) and LUT-compress compact (simdjson’s trick, f32 edition). Tests: exact match vs branchy oracle.
  3. unpack.rs — 4-bit unpack to u32. PROVIDED: scalar (topic 12’s). YOU implement: NEON shift/mask. Tests: round-trip.
  4. 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

guidechapter
reading-simdjson.mdsimdjson: parsing without branches
reading-polars-compute.mdpolars-compute: shipping SIMD in stable Rust
reading-hashbrown-simd.mdhashbrown & memchr: movemask without movemask
reading-simsimd.mdSimSIMD: the port/latency table is the design doc
reading-sigmod15-vectorization.mdSIMD for databases: two primitives, four operators
reading-fastlanes.mdFastLanes: bit-unpacking at memory bandwidth
reading-mojo-simd.mdMojo’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

  1. Block = 1024 values regardless of width. What two constraints pick 1024 (largest vector ISA lanes × smallest type, and cacheline alignment of every plane)?
  2. Interleaved decode touches w planes 128B apart — is that still sequential enough for the prefetcher (topic 13’s stride limits)?
  3. 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)?
  4. 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)?
  5. 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

anchorwhat it is
hashbrown group/mod.rs:8-30the cfg_if! backend choice + the famous “NEON wasn’t worth it” comment
hashbrown group/sse2.rs:20Group(__m128i) — 16 control bytes
hashbrown group/sse2.rs:73-84match_tag = _mm_cmpeq_epi8 + _mm_movemask_epi8BitMask(u16)
hashbrown group/neon.rs:16Group(uint8x8_t) — EIGHT bytes, not 16!
hashbrown group/neon.rs:68-75match_tag = vceq_u8 + reinterpret as u64 — NO movemask at all
hashbrown group/neon.rs:85-99match_empty_or_deleted via vcltz_s8 (sign bit test)
hashbrown group/generic.rs:41Group(GroupWord) — SWAR on a plain u64
hashbrown group/generic.rs:105-109SWAR match_tag: x ^ repeat(tag), then the zero-byte trick
memchr vector.rs:25-64the Vector trait: splat/load/cmpeq/movemask over 3 ISAs
memchr vector.rs:322-328NEON movemask: vshrn_n_u16(_, 4) → u64 with 4 bits/lane
memchr arch/generic/memchr.rs:107LOOP_SIZE = 4 * V::BYTES — the 4× unroll
memchr arch/generic/memchr.rs:171-206the 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

  1. 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)?
  2. The vshrn nibble-mask means trailing_zeros()>>2; hashbrown’s byte-mask means >>3. What breaks if you forget the shift? (memchr wraps it in NeonMoveMask newtype — why?)
  3. 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))?
  4. 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?
  5. 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

  • hashbrownsrc/control/group/ — one file per backend (sse2/neon/generic); the cfg_if! block in mod.rs and its “NEON wasn’t worth it” comment are the design doc
  • memchrsrc/vector.rs (the Vector trait + NEON movemask idiom) and src/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

Mojostable 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 params4 named accumulator variables
width-1 fallbackyour scalar fn, kept for dispatch

Questions for notes.md

  1. 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)?
  2. vectorize generates 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)?
  3. 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?
  4. 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?
  5. 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

anchorwhat it is
float_sum.rs:13-14STRIPE = 16, PAIRWISE_RECURSION_LIMIT = 128
float_sum.rs:44vector_horizontal_sum — reduce lanes at the END only
float_sum.rs:67-90SumBlock: sum 128 elems as 16-lane chunks (chunks_exact)
filter/scalar.rs:12mask-bit loop: while m > 0 + trailing_zeros (simdjson’s flatten!)
filter/scalar.rs:9064-element blocks — process a whole mask word
filter/avx512.rs:7simd_filter! macro — the shared loop skeleton
filter/avx512.rs:50-60filter_u8_avx512vbmi2: _mm512_maskz_compress_epi8
filter/avx512.rs:87-95u32 via _mm512_maskz_compress_epi32 (AVX-512F)
filter/mod.rsdispatch: 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 as Simd<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

  1. STRIPE=16 for f32 = 512 bits = 4 NEON registers. Why does a WIDER stripe than the vector width still help on ARM (ports × latency)?
  2. Pairwise limit 128: derive the error bound difference vs left-to-right for n = 10⁸ (hint: ~ε·log₂(n/128) vs ~ε·n).
  3. The simd_filter! skeleton advances out by popcount without zeroing skipped lanes. Why is the trailing garbage safe (who truncates)?
  4. Filter returns (values, validity) — how does the validity BITMAP itself get filtered (bit-level compress — the harder problem)?
  5. For M17: polars chose NOT to use std::simd for filter, only intrinsics + scalar. Why does compress specifically defeat portable SIMD abstractions?

References

Code

  • polarscrates/polars-compute/src/ — start with float_sum.rs and filter/ (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

  1. Why does branchless lose to branchy at 99% selectivity in their data (hint: store traffic — branchless writes EVERY element)?
  2. 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)?
  3. Their bloom-filter kernel is probe-minus-refill — why do bloom lookups vectorize even better than hash probes (fixed iteration count)?
  4. 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.
  5. 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)

anchorwhat it is
arm64/simd.h:179repeat_16 — build 16-byte LUTs
arm64/simd.h:226-229lookup_16 = vqtbl1q_u8 — the classification workhorse
arm64/simd.h:267-276compress via pruned vqtbl1q + LUT — NEON’s missing vpcompress, emulated
arm64/bitmask.h:15-22prefix_xor — carry-less multiply (PMULL) turns quote bits into in-string regions
src/generic/stage1/json_string_scanner.h:16-30the string-state block: escaped/quote/in_string masks
src/generic/stage1/json_structural_indexer.h:24-28bit_indexer — flatten mask bits to positions
src/generic/stage1/json_structural_indexer.h:194the stage-1 driver loop
src/generic/stage1/utf8_lookup4_algorithm.hUTF-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

  1. Why 64-byte blocks (one u64 mask = 64 lanes) rather than the 16-byte NEON width?
  2. 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?
  3. Stage 2 is still branchy. Why does Amdahl not kill the speedup (what fraction of bytes reach stage 2)?
  4. UTF-8 validation in 3 lookups: what property of UTF-8 error patterns makes nibble tables sufficient?
  5. 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

  • simdjsoninclude/simdjson/arm64/ (simd.h, bitmask.h) plus src/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

anchorwhat it is
numkong/spatial/neon.h:10-20THE table: per-instruction latency/ports on A76 vs Apple M-series
numkong/spatial/neon.h:123-140nk_sqeuclidean_f32_neon — f64 accumulation, 2 f32/iter
numkong/dot/neon.h:126-146nk_dot_f32_neon — FCVTL upcast, TWO independent FMA chains
numkong/dot/neon.h:37-45stateful streaming API: FOUR nk_dot_f32x2_state_neon_ts
numkong/dot/neon.h:~150the FCMLA comment: measured 39.7 vs 17.1 GiB/s — why they said no
numkong/spatial/neon.h:~100rsqrt 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

  1. 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?
  2. 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.
  3. Newton-Raphson: why 3 rounds for f64 (~48 bits) — how many bits does each round double from FRSQRTE’s ~8-bit estimate?
  4. The stateful API returns float32x4_t of 4 results — how does this shape M14’s candidate-scoring loop signature?
  5. 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

  • SimSIMDinclude/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

rungGB/smsvs naive
naive (1 chain)10.893.0811.0×
unrolled-8 (autovec)42.120.7973.9×
wide f32x4 ×4 accstub
neon vfmaq ×4 accstub

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%branchybranchlessneon-compress
110.9512.70stub
252.1313.32stub
501.1912.73stub
752.1112.38stub
996.6511.98stub

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

rungGB/s (output)
scalar10.20
neon shift/maskstub

Predictions (fill BEFORE implementing the stubs)

questionpredictionactual
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)

  1. Why 64-byte blocks over 16-byte NEON width:
  2. Compress LUT size/indexing, why 8 lanes max per shuffle:
  3. Amdahl on branchy stage 2 (fraction of bytes reaching it):
  4. Why nibble tables suffice for UTF-8 validation:
  5. RESP stage-1 mask sketch for M7:

polars-compute (reading-polars-compute.md)

  1. Why STRIPE=16 (wider than one NEON vector) helps (ports×latency):
  2. Pairwise error bound vs left-to-right at n=10⁸:
  3. Why compress trailing garbage is safe (who truncates):
  4. Bit-level validity-bitmap filtering:
  5. Why compress defeats portable SIMD abstractions:

hashbrown + memchr (reading-hashbrown-simd.md)

  1. Why 16B NEON Group lost to u64 SWAR in hashbrown:
  2. vshrn nibble-mask >>2 vs byte-mask >>3 — the newtype guard:
  3. Why SWAR false positives are acceptable in match_tag:
  4. 7-bit tags + sign-bit encoding — cost/benefit for M2:
  5. Compile vs init vs call-time dispatch for our engine:

SimSIMD (reading-simsimd.md)

  1. Fraction of 32 flops/cy peak that f64-accumulating dot reaches:
  2. Is single-chain sqeuclidean_f32 actually a bottleneck:
  3. Newton-Raphson rounds → bits (8 → 16 → 32 → 48):
  4. 4-target stateful API → M14 scoring loop signature:
  5. M17 fn-pointer dispatch table sketch:

SIGMOD ’15 (reading-sigmod15-vectorization.md)

  1. Why branchless loses at 99% in their data (store traffic):
  2. Vertical probing vs join output order:
  3. Why bloom filters vectorize better than probes:
  4. Their permutation-LUT compress vs simdjson’s:
  5. Rank filter/probe/partition/bloom by engine-level win (Amdahl):

FastLanes (reading-fastlanes.md)

  1. Why 1024-value blocks:
  2. Interleaved planes vs prefetcher:
  3. Chain-length ratio for transposed delta on NEON:
  4. Random access cost we traded away:
  5. Predicted vs measured unpack4 GB/s reconciliation:

Mojo (reading-mojo-simd.md)

  1. w=1-as-oracle testing trick (adopt in dot.rs? already done — scalar IS the oracle):
  2. Remainder-loop bugs vs simdjson’s padding:
  3. What’s actually blocking std::simd stabilization:
  4. When topic 13 (tiling) out-levers topic 17 (lanes):
  5. 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

modelreachwhy it matters here
CUDANVIDIA onlywhere all the DB literature lives
MetalApple onlywhat actually runs on this Mac
wgpu/WebGPUeverywhereour experiments: WGSL → Metal/Vulkan/DX12
Mojo/MLIRCPU SIMD + GPUtopic 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:

  1. filter_count — WGSL skeleton provided: fold per-thread, reduce per-workgroup, ONE atomicAdd per group. Test: exact vs CPU.
  2. l2_batch — one invocation per target; row-major first, then transpose and measure the coalescing gap. Test: 1e-3 relative.
  3. Fill the crossover table in notes.md — l2_batch at dim 128 × 100K targets is where the GPU should finally win. Verify.
  4. Stretch (M18/M20 bridge): BFS as SpMV over a bitmap frontier in WGSL vs SuiteSparse CPU.

Reading guides

guidechapter
reading-crystal-sigmod20.mdGPU vs CPU for analytics: two regimes, two verdicts
reading-wgpu-compute.mdwgpu compute: the 1.5 ms tax before your first FLOP
reading-libcudf.mdlibcudf: GPU kernels can’t push
reading-gunrock.mdGunrock: advance, filter, and the ragged-frontier problem
reading-cagra.mdCAGRA: HNSW rebuilt for warps
reading-faiss-gpu.mdFaiss 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

anchorwhat it is
cpp/src/neighbors/detail/cagra/cagra_build.cuhbuild: NN-descent → rank-based pruning
detail/cagra/graph_core.cuhgraph optimization (detour counting, reverse edges)
detail/cagra/search_single_cta_kernel.cuh:30-34the search kernel params: itopk, hashmap ptr
detail/cagra/search_single_cta.cuh:127-143shared-memory budget assembly (dataset ws + topk scratch)
detail/cagra/hashmap.hppthe visited set: open-addressing table IN SHARED MEMORY
detail/cagra/topk_by_radix.cuh + bitonic.hppk-select without sorting everything
detail/cagra/search_multi_cta.cuhmany CTAs per query for large k / low QPS
detail/cagra/compute_distance_vpq-impl.cuhPQ-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

  1. 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?).
  2. 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?
  3. 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)?
  4. 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)?
  5. 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

  • cuvscpp/src/neighbors/detail/cagra/ — the anchor map above is the reading order; start from search_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

  1. 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)?
  2. 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?
  3. 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)?
  4. 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)?
  5. 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

  1. 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)?
  2. 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?
  3. 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)?
  4. 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)?
  5. 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

anchorwhat it is
include/gunrock/algorithms/bfs.hxx:95-149the whole BFS loop: advance + optional filter
include/gunrock/framework/operators/advance/advance.hxx:94-123load-balance dispatch: thread/block/merge_path
operators/advance/thread_mapped.hxx1 thread : 1 vertex — dies on power laws
operators/advance/block_mapped.hxx1 block : 1 vertex’s edges — dies on leaves
operators/advance/merge_path.hxxbinary-search work split — even by EDGE count
framework/frontier/vector_frontier.hxxsparse frontier (vertex list)
framework/frontier/experimental/boolmap_frontier.hxxdense 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

  1. 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)?
  2. BFS’s lambda uses CAS on parent[] — why is a LOST race benign here (any parent is a valid BFS tree — idempotence again)?
  3. 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)?
  4. 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?
  5. 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/ — read algorithms/bfs.hxx first, then the three load-balance strategies in framework/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

anchorwhat it is
src/join/hash_join/size/retrieve split: inner_join_size.cu THEN inner_join_retrieve.cu
src/join/distinct_hash_join.cucuco-based build + cooperative-groups probe
src/join/hash_join/kernels_common.cuhthe probe kernel shapes
src/groupby/hash/compute_shared_memory_aggs.cuper-block shared-mem aggregation + spill test
src/groupby/hash/compute_global_memory_aggs.cuthe global-atomics fallback
src/groupby/hash/compute_mapping_indices.cukey → group index pass
src/join/conditional_join.cunon-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

  1. 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?
  2. 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)?
  3. 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)?
  4. 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)?
  5. 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

  • cudfcpp/src/ — the anchor map above: join/hash_join/ for size/retrieve, join/distinct_hash_join.cu for 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

anchorwhat 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.wgslminimal 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

CUDAWGSLnote
__global__ kernel@compute @workgroup_size(N) fnsize 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 shufflessubgroup ops (feature-gated)portable fallback: shared memory
atomicAddatomicAdd(&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

  1. Measure: GpuCtx::sum with upload hoisted out (regime B). Does the GPU beat 2258 µs CPU at n=16M now? Predict first.
  2. 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)?
  3. 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)?
  4. Subgroup (warp) ops vs shared-memory reduction: rewrite sum.wgsl’s tree loop with subgroupAdd — how many barriers disappear?
  5. For M18: the feature flag should gate at the operator boundary. Which signature do you expose: sum(&[f32]) (per-call upload, regime A) or upload(&[f32]) -> GpuVec + sum(&GpuVec) (regime B)? Justify from this guide’s measurements.

References

Code

  • wgpuexamples/ — 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), then hello_workgroups / hello_synchronization / big_compute_buffers as 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.

nCPU µsGPU µsuploadkernel+submitreadbackwinner
16K2.31618.948.51567.13.2CPU
64K9.21633.569.61560.83.1CPU
256K36.81701.5151.81547.12.6CPU
1M154.41985.5437.31544.24.0CPU
4M588.64554.81654.82887.212.8CPU
16M2257.714332.97384.76929.119.1CPU

No crossover, ever, for a memory-bound reduction on unified memory. Two reasons, cleanly separated by the phase columns:

  1. ~1.5 ms FIXED encode/submit/poll cost per dispatch (flat from 16K to 1M — pure overhead, not work).
  2. 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)

questionpredictionactual
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)

  1. Which topic 22 benchmark shape flips regime B back to CPU:
  2. Regime table rewritten for Apple unified memory:
  3. Group-by atomics collapse cardinality + fallback:
  4. GPU profiling replacement for flamegraphs:
  5. Roofline go/no-go for expand/filter/distance:

wgpu compute (reading-wgpu-compute.md)

  1. Regime-B sum at 16M measured vs prediction:
  2. Why workgroup_size is a pipeline-time constant:
  3. Why upload ≫ readback on unified memory:
  4. subgroupAdd rewrite — barriers removed:
  5. M18 API: per-call upload vs GpuVec handle:

libcudf (reading-libcudf.md)

  1. Kernel launches per inner_join × 1.5 ms — min batch to amortize:
  2. When size/retrieve recompute is free (roofline):
  3. Why conditional_join is NL + device AST:
  4. cudf JIT ↔ WGSL pipeline specialization (topic 19):
  5. filter compact pass-2 via BlockScan sketch:

Gunrock (reading-gunrock.md)

  1. Advance’s unknown output size — Gunrock’s two-phase:
  2. Why lost CAS races are benign in BFS:
  3. Direction-optimizing needs CSC — memory doubling worth it when:
  4. Hub degree 10⁶: thread_mapped vs merge_path arithmetic:
  5. M24: advance strategy per LDBC frontier shape:

CAGRA (reading-cagra.md)

  1. Where the levels’ log-factor went at fixed degree 32:
  2. Why bitonic topk beats a heap on a warp:
  3. multi_cta partial-itopk merge (no device barrier):
  4. ADC tables vs visited hashmap — shared memory budget fight:
  5. M14+M18: which rescore half goes GPU first + batch size:

Faiss GPU (reading-faiss-gpu.md)

  1. WarpSelect k limit + thread-queue threshold t:
  2. Fused vs staged distance→k-select HBM traffic ratio:
  3. Coarse matmul near-peak vs PQ scan bandwidth-bound — intensities:
  4. Shard vs replicate ↔ topic 15 read-scaling:
  5. 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:

filerole
src/expr.rsPROVIDED — Expr tree (Col/Const/Add/Mul/Lt/And) + seeded random generator
src/interp.rsPROVIDED — AST-walking eval(expr, row) (the strawman)
src/vectorized.rsPROVIDED — column-at-a-time batch eval (topic 11’s answer)
src/jit.rsSTUB — cranelift: compile Exprfn(*const f64) -> f64
src/bin/jit_bench.rsPROVIDED — 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

  1. reading-neumann-vldb11.md — the model
  2. reading-sqlite-vdbe.md — the bytecode floor
  3. reading-umbra-tidy-tuples.md — compile-latency war (+ copy-and-patch)
  4. reading-postgres-jit.md — how it goes wrong in production
  5. reading-graphblas-jit.md — kernel-grain JIT (FalkorDB’s inheritance)
  6. 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

anchorwhat it is
src/jit.rs:39-41JITBuilder::with_isa(...)JITModule::new
src/jit.rs:12-25the four state objects (see §1)
src/jit.rs:55-92compile() — the whole ladder, annotated below
src/jit.rs:135FunctionBuilder::new(&mut ctx.func, &mut builder_context)
src/jit.rs:180builder.finalize() — seals the CLIF function
src/jit.rs:189-191FunctionTranslator — AST→CLIF recursion lives here
src/jit.rs:400+helper emitters (calls, comparisons)
src/frontend.rsthe 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; enable is_pic false default is fine for JIT.
  • MemFlags::trusted() = aligned + notrap: we promise row_ptr is valid — the unsafe contract lives at the eval() 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

  1. 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)?
  2. FunctionBuilder “handles SSA construction” — what does that mean concretely for a var assigned in two branches (block params instead of phi nodes — how do they differ)?
  3. 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)?
  4. The demo transmutes to fn(f64) -> f64. Spell out every precondition that makes our fn(*const f64) -> f64 transmute sound (ABI = System V default? signature match? module alive? W^X handled by JITModule?).
  5. 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-demosrc/jit.rs — read it top to bottom; src/frontend.rs (the toy parser) can be skipped, we already have Expr

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

anchorwhat it is
Source/jitifyer/GB_jitifyer.c:21-40the static hash table of loaded kernels
GB_jitifyer.c:2119GB_jitifyer_lookup — hash-table probe
GB_jitifyer.c:1565-1576GB_jitifyer_load — the full ladder
GB_jitifyer.c:1677-1710load2_worker — compile path under a critical section
GB_jitifyer.c:1937, 2050GB_file_dlopen — load the compiled .so
Source/jitifyer/GB_encodify_mxm.c:16-59problem → GB_jit_encoding + hash
GB_jitifyer.c:48direct 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

  1. Find the generic mxm path (function-pointer per multiply-add, Source/generic/). Estimate its per-entry cost vs a JITed z += a*b on f64 (call + load fn ptr vs 1 FMA) — does the ratio match this topic’s interpreter/compiled gaps (~10×)?
  2. 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)?
  3. 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)?
  4. 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)?
  5. 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

  • GraphBLASSource/jitifyer/ (GB_jitifyer.c is the machine, GB_encodify_mxm.c the cache key) and Source/jit_kernels/ (the templates the JIT instantiates); GB_control.h for 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

  1. 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?
  2. 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)?
  3. 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?
  4. 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)?
  5. 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

anchorwhat it is
llvmjit.c:156provider hook: cb->compile_expr = llvm_compile_expr
llvmjit.c:85-101session state: two LLJITs — llvm_opt0_orc / llvm_opt3_orc
llvmjit.c:363llvm_get_function — lookup + (lazy) emission
llvmjit.c:716-781module → ThreadSafeModule → LLJIT dylib + resource tracker
llvmjit_expr.c:80llvm_compile_expr(ExprState*) — the entry point
llvmjit_expr.c:302-307one 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-700the 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

  1. 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)?
  2. 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)?
  3. 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)?
  4. 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?
  5. 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

  • postgressrc/backend/jit/llvm/ — llvmjit.c (lifecycle), llvmjit_expr.c (the EEOP switch), llvmjit_deform.c (the underrated half); pair with src/backend/executor/execExprInterp.c to see what each EEOP block replaces, and planner.c:699 for 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

anchorwhat it is
src/vdbe.c:1049THE loop: switch( pOp->opcode )
src/vdbe.c:1062comment: file is ordered by case OP_ convention
src/vdbeInt.h:55struct VdbeOp — opcode, p1,p2,p3 ints, p4 union, p5 flags
src/vdbe.c:1098OP_Goto — jump = set pOp, break re-enters switch
src/vdbe.c:1154 / :1187OP_Gosub / OP_Return — subroutines via a register
src/vdbe.c:1209 / :1264OP_InitCoroutine / OP_Yield — coroutines!
src/vdbe.c:1284OP_HaltIfNull — constraint checks as opcodes
199 case OP_ totalthe 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

  1. 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?
  2. Register machine vs stack machine: count the ops a*b + c*d needs on each. Why did SQLite pick registers (fewer dispatches, at the cost of codegen doing register allocation)?
  3. 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)?
  4. Why is case OP_Column (the B-tree record decoder) enormous while case OP_Add is ~10 lines — and what does that say about where VDBE dispatch overhead actually matters?
  5. Sketch the fourth lane: a bytecode compiler for our Expr enum (flatten to Vec<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; the case OP_ comment convention makes it navigable
  • sqlite src/vdbeInt.hstruct VdbeOp and the register/cursor state
  • EXPLAIN in 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

  1. 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.
  2. 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?
  3. 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?
  4. 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)?
  5. 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 WHERE clause 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.

depthnodesinterp M/svector M/svector/interp
2760-94380-518~6×
431~19.5150-186~8×
6127~4.139-54~11×
8511~0.9510-13~12×
102047~0.242.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)

questionpredictionactual
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)

  1. Pipelines for the Cypher plan / what M19 compiles vs produce-consume:
  2. Why push gives ONE loop, pull needs suspendable state:
  3. Cocktail rule applied to our executor:
  4. Tuple-in-registers dies at what width:
  5. VLDB’18 hash-probe MLP argument:

SQLite VDBE (reading-sqlite-vdbe.md)

  1. EXPLAIN program for a+1 WHERE b<10, ops/row:
  2. Register vs stack machine op counts for ab+cd:
  3. OP_Yield pc-swap — where do locals live:
  4. Why OP_Column is huge and OP_Add tiny:
  5. Bytecode 4th lane prediction:

Umbra / copy-and-patch (reading-umbra-tidy-tuples.md)

  1. Three Umbra-IR choices enabling single-pass:
  2. Why linear-scan regalloc is fine for query code:
  3. musttail stencil composition ↔ wgpu pipeline specialization:
  4. What state interp/compiled must agree on at swap:
  5. Break-even formula with measured numbers; 1M-node WHERE verdict:

Postgres JIT (reading-postgres-jit.md)

  1. EEOP_QUAL in both executors — what LLVM folds:
  2. One function per ExprState vs per step:
  3. Time-based gate — which half stays an estimate:
  4. Deform JIT vs generic for col 19 of 20:
  5. Per-query vs per-shape cache for Cypher:

GraphBLAS JIT (reading-graphblas-jit.md)

  1. Generic mxm fn-pointer cost vs JITed FMA:
  2. Critical-section scope / benign duplicate compiles:
  3. Why unbounded cache is fine (key space size for FalkorDB):
  4. PreJIT ↔ copy-and-patch stencils:
  5. Cypher expression cache key (shape vs parameter):

cranelift-jit-demo (reading-cranelift-jit-demo.md)

  1. Why define ≠ callable (relocations); which Expr nodes need them:
  2. FunctionBuilder SSA — block params vs phis:
  3. compile() linearity + constant term:
  4. Transmute soundness preconditions:
  5. 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&lt;M&gt;=A*B] --> META[GB_AxB_meta.c — pick engine]
    META -->|"mask present, C sparse: C&lt;M&gt;=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
unittask list built up frontjoin(a, b) recursive split
balancepre-computed from flopcountwork-stealing (crossbeam_deque)
thread countcosted per-operationpool-global, steal if idle
skew handlingfine tasks + atomicsidle threads steal halves
code~500 lines of slicingpar_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/)

filerole
src/csr.rsPROVIDED — CSR type, COO→CSR build, transpose, RMAT + uniform generators
src/spmv.rsPROVIDED — row-parallel SpMV (f64) + (ANY,PAIR) bool variant
src/spgemm.rsPROVIDED hash-Gustavson (HashMap SPA, slow-but-obvious); STUB dense-SPA Gustavson (scatter/gather workspace, the saxpy3 coarse task)
src/bfs.rsPROVIDED scalar queue BFS oracle; STUB push (SpMSpV), pull (masked SpMV w/ early exit), and direction-optimizing switch
src/hyper.rsPROVIDED — hypersparse row index; bench shows when 80 MB of rowptr disappears
src/bin/gb_bench.rsPROVIDED — 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/graphblas layer; 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

  1. reading-davis-toms19.md — the system paper (+ v2 update)
  2. reading-suitesparse-internals.md — formats, conform, saxpy3/dot3
  3. reading-gustavson-spgemm.md — the ’78 paper + Buluç-Gilbert survey
  4. reading-beamer-sc12.md — direction-optimizing BFS
  5. reading-lagraph.md — the algorithms as executable linear algebra
  6. reading-falkordb-delta-matrix.md — then implement the stubs
  7. 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.

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

  1. 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)?
  2. 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)?
  3. 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.
  4. 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 <-[]-)?
  5. 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

  1. Map GrB objects to executor concepts: semiring ↔ ?, mask ↔ ?, accum ↔ ?, descriptor ↔ ? (operator, semi-join filter, UPDATE expression, query hints — defend each).
  2. 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)?
  3. 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×?)?
  4. 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?
  5. 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

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

anchorwhat it is
delta_matrix.h:34-108the state-transition comment table (A/DP/DM invariants per op) — the spec
delta_matrix.h:110-116the struct: M + delta_plus + delta_minus + transposed twin
delta_matrix.h:17-22accessor macros incl. the T* transposed trio
delta_wait.c:13-33sync_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.cthe 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:

  1. 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.
  2. 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).
  3. 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

  1. 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?
  2. 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)?
  3. 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.
  4. 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)?
  5. 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 in delta_matrix.h:34-108 (it IS the design doc), then delta_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

  1. 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?).
  2. 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).
  3. 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)?
  4. 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)?
  5. 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

anchorwhat it is
template/LG_BreadthFirstSearch_SSGrB_template.c:184-187α=8, β1=8, β2=512 — the Beamer thresholds
…template.c:243-292the push↔pull switch logic (growing/shrinking + thresholds)
…template.c:307push: GrB_vxm(q, mask, …, q, A, GrB_DESC_RSC)
…template.c:313pull: GrB_mxv(q, mask, …, AT, q, GrB_DESC_RSC)
…template.c:140-143GxB_ANY_SECONDI_INT{32,64} — parent BFS with zero comparisons
…template.c:196, 261-277edges_unexplored maintained incrementally
LAGr_TriangleCount.c:31-46all SIX masked-mxm triangle formulations + which wins where
LAGr_PageRankGAP.c:99-135GAP-style PR: prescaled degrees, mxv + PLUS_SECOND at :135
LG_CC_FastSV7.cconnected 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:

  • .*L masks 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

  1. 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)?
  2. Why does the template keep BOTH q sparse and the visited mask as 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)?
  3. 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.
  4. 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?
  5. 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

  1. GB_AxB_saxpy3.c:22-48 — the header comment is a scheduling essay: coarse/fine taxonomy, Gustavson-vs-hash per task.
  2. 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.
  3. GB_AxB_saxpy3_flopcount.c:80 — exact flops per column of B, cheap because it only walks pattern, not values.
  4. rayon join/mod.rs:93-140join_context: inline + push + steal-back. The “potential parallelism” framing: join costs ~nothing when no thread is idle.
  5. registry.rs:10-60, :248 — one Worker deque per thread, Stealer handles crossed between them; the sleep/wake protocol is why idle rayon threads don’t spin.

The trade in one table

axisstatic (SuiteSparse)stealing (rayon)
needs a cost modelyes (flopcount)no
skew responsepre-balanced or fine-task atomicsautomatic
per-task overhead~zero at runtimedeque push + potential steal
determinism of schedulehighnone
lines of scheduler code you ownmanyzero (but tune min_len)

Questions

  1. 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?
  2. 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?
  3. GB_nthreads(work, chunk, nthreads_max) returns 1 for small work. Write the rayon equivalent — where does with_min_len go, and what happens if you omit it on a 1000×1000 multiply with 5K nonzeros?
  4. 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?
  5. 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?
  6. 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 (:93 join_context), rayon-core/src/registry.rs (:248 — one deque + Stealer per 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

anchorwhat it is
Source/convert/GB_convert_sparse_to_bitmap_test.c:32-38THE bitmap heuristic: nnz > bitmap_switch * nnz_dense
Source/convert/GB_conform_hyper.c:52hyper→sparse test via hyper_switch
Source/convert/GB_conform.c:33-89conform runs after every op; sparsity_control bitmask
Source/mxm/GB_AxB_meta.cengine dispatch (dot vs saxpy vs saxbit)
Source/mxm/GB_AxB_saxpy3.c:22-60coarse/fine tasks × Gustavson/hash — read this header comment twice
Source/mxm/GB_AxB_saxpy3.c:57hash > m/16 ⇒ fall back to Gustavson
Source/mxm/GB_AxB_saxpy3_flopcount.cthe sizing pre-pass
Source/mxm/GB_AxB_dot3.c:2-10C<M>=A'*B — mask REQUIRED, work ∝ nnz(M)
Source/mxm/GB_AxB_dot2.c / dot4.cunmasked / 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

  1. 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?
  2. 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)?
  3. 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).
  4. 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)?
  5. 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

scalennnzµsGB/s
1416K120K14619.1
1665K495K61718.6
18262K2.0M254718.3
201.05M8.2M1195815.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)

scalennz(A)flopsnnz(C)hash msMflop/s
106.7K298K142K3.976
1228.7K2.27M1.14M33.069
14120K17.1M8.9M279.461

~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)

questionpredictionactual
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)

  1. GrB objects ↔ executor concepts mapping:
  2. Why FalkorDB needs own deltas over zombies/pending:
  3. Which FalkorDB matrices are iso; cost of losing iso:
  4. BFS step through v2 machinery (engine + JIT specialization):
  5. 32-bit index memory math v9 vs v10:

SuiteSparse internals (reading-suitesparse-internals.md)

  1. FalkorDB’s GxB_set sparsity pins:
  2. Fine-task atomics vs coarse — topic 11 analogue:
  3. Hash-task underestimate handling vs SwissTable resize:
  4. dot3 vs saxpy3 cost estimate for triangle counting:
  5. SPA-vs-hash crossover scale measured:

Gustavson + survey (reading-gustavson-spgemm.md)

  1. Why flops = Σ nnz(B(k,:)) is the lower bound; ANY short-circuit:
  2. SPA-vs-hash crossover from cache numbers:
  3. When guess-and-grow beats symbolic+numeric:
  4. Outer-product = LSM runs + merge:
  5. Masked saxpy vs masked dot on C<L>=L*L:

Beamer SC ’12 (reading-beamer-sc12.md)

  1. Measured wasted-check fraction at peak level:
  2. Why early exit needs ANY/OR; which semirings break:
  3. Road vs RMAT pull prediction + trace check:
  4. Which FalkorDB query shapes need AT besides pull-BFS:
  5. Why LAGraph β2=512 vs paper’s 24:

LAGraph (reading-lagraph.md)

  1. Switch-heuristic inputs and their maintenance cost:
  2. Frontier format at peak level (conform reasoning):
  3. dot3 + tril mask visits each wedge once — spell out:
  4. PageRankGAP prescaling savings:
  5. M20 semiring per BFS variant (parent vs level):

FalkorDB delta matrices (reading-falkordb-delta-matrix.md)

  1. 4×2 case table verified against set/remove code:
  2. Transposed-twin write cost; lazy-rebuild breakage:
  3. delta_mxm over-masking counterexample + caller compensation:
  4. Sync thresholds ↔ LSM L0 triggers:
  5. 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 class
  • union (:1147) — union-find merge; repair is DEFERRED
  • rebuild (: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:

configstates (distinct)result
SyncCommit = TRUE2583 (1080), depth 14Durability holds
SyncCommit = FALSE123 checkedviolated 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

Experiments

filestatuswhat it shows
expr.rsprovidedtiny expression IR + AstSize cost + random gen
hand.rsprovidedordered fixpoint rewriter with the R2-before-R4 trap
eqsat.rsstubegg_optimize — saturate, extract, beat the trap
bin/eqsat_bench.rsprovidedtrap case + depth sweep, hand vs egg lanes
specs/WalReplication.tlaprovidedquorum-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.jar step — 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

  1. 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.
  2. 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.
  3. “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.
  4. 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)

  1. Which capstone protocol clears the paper’s cost/benefit bar for a spec — MVCC visibility, delta-matrix wait concurrency, or WAL replication — and which is fine with proptest alone (topic 16)?
  2. 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).
  3. “Exhaustively testable pseudo-code”: how is a TLA+ Next action different from a proptest state-machine transition (topic 16)? What does TLC explore that proptest samples?
  4. 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?
  5. 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:linewhat
unionfind.rs:30/:37/:47find (path-compressing in find_mut), union — 60 lines, the whole thing
egraph.rs:66memo: HashMap<L, Id> — the hashcons; canonical only after rebuild
egraph.rs:970EGraph::add — canonicalize children, memo lookup-or-insert
egraph.rs:1147EGraph::union — merge classes, push parents onto pending (:69)
egraph.rs:1346process_unions — drain pending: re-canonicalize node, re-insert into memo; a collision is a discovered congruence → recursive union
egraph.rs:1416rebuild — the public batched-repair entry point
machine.rs:8/:24pattern matching compiled to a tiny VM: Bind/Scan/Compare instructions over the e-graph
run.rs:138/:161/:237Runner, RunnerLimits (iter/node/time), StopReason
extract.rs:41/:116/:157/:225Extractor, 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)

  1. Trace (a*2)/2 by hand: which unions happen in iteration 1, and in which e-class do (/ 2 2) and 1 meet?
  2. Why must memo re-canonicalization happen in a loop (a repair can create a new collision)? Find the fixpoint in process_unions (:1346).
  3. machine.rs: what does Scan cost when a pattern’s root op has thousands of e-nodes? Relate to classes_by_op (:81).
  4. Assoc+comm on + alone: estimate e-graph growth per iteration on a depth-8 sum. Which RunnerLimit trips first (predict, then measure in the stub)?
  5. 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; skip explain.rs on 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_mut as a compiler pass.
  • Borrowed params = zero-cost read path: an executor passing &Value down 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)

  1. 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.)
  2. 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)?
  3. Perceus “garbage-free” claim: what does peak-memory = live-data buy a memory-budgeted buffer pool (topic 6) design?
  4. Lean proof vs TLC vs proptest for DP ∩ M = ∅: rank by (cost to write, strength of guarantee, maintenance under refactor).
  5. Koka’s effect types let Perceus assume no hidden aliasing. What’s the moral equivalent in Rust that makes Arc::make_mut sound?

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

linewhat
:24message types incl. AppendEntriesRequest/Response
:155Init — everything empty, all followers
:204AppendEntries(i, j) — leader ships up to 1 entry per action (model-size discipline; same reason our Ship moves one entry)
:229BecomeLeader(i) — quorum of votes ⇒ leader
:327HandleAppendEntriesRequest — 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)

  1. 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.)
  2. Why does Failover need “longest log among survivors” — exhibit the quorum-intersection argument for Quorum=2, |Replicas|=3, and the trace when failover picks an arbitrary survivor instead.
  3. raft.tla:204 ships ≤1 entry per action. What bug class would a “ship everything atomically” model hide in OUR spec?
  4. 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.
  5. [][Next]_vars allows 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/

anchorwhat
euf_egraph.h:23comment: “same effect as delayed congruence table reconstruction from egg” — the 2021 paper flowing back into the 2008 solver
euf_egraph.h:85class egraph
euf_egraph.h:91-96to_merge queue (plain / commutativity / justified) — the pending-unions worklist, egg’s pending
euf_enode.he-node: term + parents + root pointer
euf_etable.hthe congruence table (hashcons keyed on canonicalized children)
euf_justification.hproof-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 → 1 caveat is checkable — assert x=0 ∧ rewrite-changes-result, SAT means unsound rule.

Questions (answer in notes.md)

  1. Why must Z3’s e-graph carry justifications while egg’s can skip them? What would proof-producing unions cost egg’s rebuild?
  2. The trail/backtracking requirement: why does deferred rebuilding interact badly with undo, and how does to_merge_t (:91) hint at the resolution?
  3. Encode the x/x → 1 soundness check as an SMT query (ints, then reals). Which theory answers each?
  4. Nelson-Oppen needs theories to agree on equalities of shared terms — spot the analogy to exchanging join keys between operators (topic 11).
  5. 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 the to_merge worklist), 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)

depthin costhand costµs/exprfirings
431215.44
61279025.215
8511355129.761
1020471390534.8248

~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)

configstates generateddistinctdepthresulttime
SyncCommit=TRUE2583108014Durability holds<1 s
SyncCommit=FALSE1831235VIOLATED<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)

questionpredictionactual
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)

  1. Which capstone protocol clears the spec cost/benefit bar:
  2. 35-step vs our 5-step trace — reachable-but-rare:
  3. TLA+ Next action vs proptest state-machine transition:
  4. Small-scope hypothesis: protocols vs B+tree edge cases:
  5. Keeping spec and code honest in CI:

egg POPL’21 (reading-egg-popl21.md)

  1. Hand-trace (a*2)/2 unions; where (/ 2 2) meets 1:
  2. Why memo re-canonicalization loops to fixpoint:
  3. machine.rs Scan cost; classes_by_op index:
  4. Assoc+comm growth per iteration; which limit trips:
  5. Cascades memo vs e-graph — what each has the other lacks:

Z3 TACAS’08 (reading-z3-tacas08.md)

  1. Why Z3’s e-graph needs justifications, egg doesn’t:
  2. Deferred rebuild vs backtracking trail:
  3. x/x→1 soundness as SMT query (ints vs reals):
  4. Nelson-Oppen equality exchange ↔ join-key exchange:
  5. Trigger selection = index choice of SMT:

Specifying Systems + raft.tla (reading-tlaplus-raft.md)

  1. Rejoin(r) → what invariant breaks → why terms exist:
  2. Longest-log failover: quorum-intersection argument + bad trace:
  3. What “ship everything atomically” would hide:
  4. MVCC visibility as TLA+ sketch (M21 outline):
  5. Why stuttering is essential for refinement:

Beans + Perceus (reading-lean-perceus.md)

  1. Arc costs that borrow inference eliminates:
  2. When the RC==1 reuse check costs more than it saves:
  3. Garbage-free peak memory ↔ buffer pool budgets:
  4. Proof vs TLC vs proptest ranking for DP∩M=∅:
  5. 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_flat makes 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)

laneresult
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/F2.88 / 4.15 / 3.72 / 4.40 / 1.11 / 2.85 Mops/s
YCSB E p50917 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)

  1. closed-loop tails quoted as latency (coordinated omission)
  2. warm cache vs cold unstated; SF that fits in LLC
  3. “TPC-C” without think times ⇒ you measured a latch
  4. geomean over arithmetic mean (or vice versa) chosen post hoc
  5. comparing your tuned build vs their defaults (Fair Benchmarking’s core sin)
  6. 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.md analogue — JOB, real cardinalities

Experiments

filestatuswhat it shows
lineitem.rsprovideddbgen-lite columnar lineitem (SF×6M rows)
tpch.rs oraclesprovidedQ1 HashMap row-at-a-time, Q6 branchy scalar
tpch.rs q1_flat/q6_branchlessstubflat group array; mask-multiply scan
zipf.rs Zipfian/ScrambledstubTHE YCSB generator, statistical contract tests
ycsb.rsprovidedA-F mixes, BTreeMap store, ns-percentile driver
bin/bench_suite.rsprovidedboth 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)

querychoke pointswhat it really measures
Q1CP1.2 + CP4expression evaluation + tiny-domain aggregation: ~4 groups, so the hash table is FREE and fused arithmetic dominates — our q1_flat stub makes this explicit
Q6CP4 + scanpure selection: ~2% selectivity, SIMD-able predicates — DBMS “GB/s scanned” headline numbers are usually Q6
Q9CP2 + CP4 (LIKE) + CP6 skew6-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)

  1. 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)?
  2. 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?
  3. 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.
  4. Choke point CP3 (materialization): which of the 22 queries would an incremental-view engine (topic 27 preview) answer in O(1)?
  5. 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 in dbgen/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/)

pathwhat
tpch_extension.cpp:17-30DBGenFunctionData — dbgen exposed as a table function: CALL dbgen(sf=1)
tpch_extension.cpp:49-95bind (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.sqlthe 22 queries, parameter-substituted
dbgen/answers/reference results per SF — correctness oracle, not just speed
tpch_config.pygenerates 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)

  1. 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)?
  2. Why does shipping answers/ matter more than shipping queries/? Relate to topic 16’s oracle taxonomy.
  3. DbgenFunction streams chunks instead of writing .tbl files — which topic-11 concept is that (operator vs materialization)?
  4. Q9 with join order disabled: how much slower, and which topic-10 lesson does the number reproduce?
  5. 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

  1. 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).
  2. 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.
  3. 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)
anchorwhat
TPCCWorker.java:85-100keying + think times: -log(c)·mean, capped at 10× — the spec’s human simulator
TPCCUtil.java:94-116NURand 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.javathe 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)

  1. 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)?
  2. Why must load-time and run-time C_LAST constants differ (TPCCUtil:94)? What cheat does the constraint block?
  3. Design “TPC-C for graphs”: what’s the hot-counter analogue in a social-network write workload (hint: supernode edge appends, topic 13)?
  4. Open vs closed loop on workload E (our scan-heavy mix): which reports the higher p999, and why is that the honest one?
  5. 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) and config/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:

anchorwhat
:43ZipfianConstant = 0.99 — why every paper says θ=0.99
:92-118constructor: zetan (harmonic-ish sum, O(n)!), eta, alpha = 1/(1-θ)
:125-132zetaStatic — the O(n) sum; incremental recompute when item count GROWS (:135-147), full recompute (slow, warned) when it shrinks
:150-163the sampler: two fast paths (uz < 1 → rank 0, < 1+0.5^θ → rank 1), else n·(ηu − η + 1)^α
scrambled_zipfian.gofnv64(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)

  1. 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.)
  2. Why do the two fast paths in next() exist — what fraction of draws do they absorb at θ=0.99?
  3. 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.
  4. 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.
  5. 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)

SFrowsQ1 oracle msQ1 GB/sQ6 oracle msQ6 GB/s
0.05300K2.44.70.711.9
0.251.5M10.25.62.715.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)

workloadMops/sp50 nsp99 nsp999 ns
A update-heavy2.882927922083
B read-mostly4.15208500667
C read-only3.72209542958
D read-latest4.40208417625
E short-ranges1.1191715832041
F read-mod-write2.852927081541

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)

questionpredictionactual
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)

  1. Q1/Q6/Q9 → Cypher choke-point analogues:
  2. Which dbgen-lite columns need correlation to break q1_flat:
  3. Q6 2% vs 50% selectivity branchy/branchless crossover:
  4. Which of the 22 queries an IVM engine answers in O(1):
  5. What TPC-C NewOrder tests that no TPC-H query can:

YCSB (reading-ycsb.md)

  1. P(rank 0) = 1/ζ(n,θ) derivation; top-100 mass at n=1M:
  2. The two fast paths — what fraction of draws at θ=.99:
  3. Per-workload uniform→zipf prediction (table above):
  4. Coordinated-omission fix sketch; workload E p999:
  5. Why plain zipfian on a growing keyspace is wrong (zetan staleness):

OLTP-Bench + TPC-C (reading-oltpbench-tpcc.md)

  1. D_NEXT_O_ID abort rate under OCC at 4WH×16T:
  2. Why load-time vs run-time C_LAST constants must differ:
  3. “TPC-C for graphs” — the supernode hot counter:
  4. Open vs closed loop p999 on workload E:
  5. Phased-rate config reproducing an eviction storm:

DuckDB tpch extension (reading-duckdb-tpch.md)

  1. DuckDB Q1/Q6 SF1 measured; gap analysis vs our lanes:
  2. answers/ over queries/ — oracle taxonomy:
  3. dbgen-as-table-function = which topic-11 concept:
  4. Q9 with join order disabled — how much slower:
  5. 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

  1. 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.
  2. 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)

laneresult
index buildgen 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

Experiments

filestatuswhat it shows
corpus.rsprovidedZipfian corpus (term id = rank), tokenizer
index.rsprovidedpostings + 128-block metas with per-block max BM25
bm25.rsprovidedK1/B, saturating tf, exhaustive term-at-a-time oracle
wand.rs wand_topkstubblock-max WAND: same top-k, fraction of the work
postings.rs Roaringstubarray/bitmap containers, AND/OR vs vec oracle
bin/fts_bench.rsprovidedall 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) + 1 without 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 concepttantivy anchor
pivot selectionquery/boolean_query/block_wand_union.rs:8-24 find_pivot_doc — walks scorers sorted by doc, accumulates max_weight until > threshold
block metadatapostings/skip.rs:93 SkipReader, :175/:186
term upper boundScorer::max_score per term weight
union top-kblock_wand_union.rs (OR queries), block_wand_intersection.rs (AND)

Traps for the implementation (learned by others, cheaply)

  1. θ 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).
  2. 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).
  3. Ties at the k-boundary: WAND may return a different doc with an EQUAL score — compare scores, not doc ids (our test does).
  4. docs_scored counts 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)

  1. 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).
  2. Why does block-max help MOST on common terms? Relate to the variance of per-block maxima under Zipf tf distributions.
  3. 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?
  4. 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.
  5. 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-24 find_pivot_doc), block_wand_intersection.rs, src/postings/skip.rs (:175 block_max_score, :186 last_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 quick 500× beats a doc with quick 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 pieceanchor
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 bytebm25.rs:59
fieldnorm quantized to 1 byte, 256-entry cache tablefieldnorm/ + 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)

  1. 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?
  2. The +0.5s in idf are a smoothing (Jeffreys prior). What happens at df=0 and df=N without them?
  3. 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.
  4. 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?
  5. 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)

anchorwhat
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:229a 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 Encoderwrite(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 VarintEncodethe wire format under most codecs
gc.rsgarbage 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.SEARCH with scores is expensive on big result sets).

Questions (answer in notes.md)

  1. num_entries: u16 and 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?
  2. The gc_marker/unique_id cursor-validation dance: map it onto FalkorDB’s delta-matrix wait + version story. What does each protect against, and which is stricter?
  3. Eleven codecs vs tantivy’s one postings format + fast fields: which RediSearch codecs correspond to “positions” and “doc values” in the Lucene taxonomy?
  4. Varint vs bitpacked at df=99888/100K docs (delta≈1, one byte each): compute bytes/posting for both. Where does varint actually WIN?
  5. 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 in c_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 ∩/∪ Barraybitmap
arraytwo-pointer merge (galloping when sizes differ ≥64×)probe each u16 into the bitmap: O(
bitmap← same, swapped1024 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 cnt on 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)

  1. 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)?
  2. 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…).
  3. 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)
  4. 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).
  5. 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/
subsystemanchorwhat to see
analysistokenizer/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 dicttermdict/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 infopostings/term_info.rs:9-13 TermInfo { doc_freq, postings_range }df rides in the dictionary — idf is known before touching postings
postingspostings/compression/mod.rs:3 COMPRESSION_BLOCK_SIZE = BitPacker4x::BLOCK_LEN (=128); :61 delta-encode against block_minus_one128 deltas bit-packed to the block’s max width; SIMD unpack
skip datapostings/skip.rs:93 SkipReader; :175 block_max_score(bm25_weight); :186 last_doc_in_blockblock-max metadata lives in skip entries — moving blocks never decodes postings
scoringquery/bm25.rs:8-9 K1/B; :52 idf; :59 tf-norm via 1-byte fieldnorm tablescoring = table lookup + multiply-add
WANDquery/boolean_query/block_wand_union.rs:8-24 find_pivot_doc; sibling block_wand_intersection.rsthe 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

  1. postings/term_info.rs + termdict/fst_termdict/termdict.rs (15’)
  2. postings/compression/mod.rs then skip.rs (25’)
  3. query/bm25.rs (10’)
  4. query/boolean_query/block_wand_union.rs — compare with your wand_topk after implementing, not before (30’)
  5. indexer/log_merge_policy.rs (10’)

Questions (answer in notes.md)

  1. 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 — MapBuilder needs sorted keys, hence per-segment build + merge).
  2. TermInfo.doc_freq lives in the dictionary. Which of WAND’s inputs does that make free, before any posting is read?
  3. 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.
  4. 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?
  5. 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

sectionwhy
§2-3vocabulary + postings anatomy; the doc-id vs word-position granularity trade
§4compression: deltas are what make postings compressible at all — Zipf gives small gaps for common terms
§5merge-based construction — recognize topic 4’s LSM before Lucene made it famous
§6query eval: term-at-a-time vs doc-at-a-time (our oracle is TAAT, WAND is DAAT); the accumulator-limiting trick
§7index maintenance — why everyone chose immutable segments + merge
§8ranked 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)

  1. 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?
  2. §6’s capped accumulators vs WAND: both bound work; which gives an exactness guarantee and what does the other buy instead?
  3. 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?
  4. Positions multiply index size ~3×. For M23’s node/edge property search, when do you actually need them (phrase queries on description props?) and what’s the cheaper substitute?
  5. 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

querymspostings walkedtop1 score
common∧common [t0 t1 t5]8.75272,3100.612
mid∧mid [t100 t1000]0.5079,0989.142
common∧rare [t0 t12000]6.3499,9648.975
rare∧rare [t9000 t15000]0.0081599.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

pairAND µsOR µsresult size
t0∧t1 (99888 ∩ 97580)9711697,474
t0∧t5000 (99888 ∩ 172)5281172

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)

questionpredictionactual
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)

  1. Expected gap at df=n/2; why 128-block bitpack beats varint there:
  2. Capped accumulators vs WAND — which is exact, what does the other buy:
  3. Runs/merges ↔ memtable/flush/compaction; tiered-vs-leveled for text:
  4. When M23 needs positions vs cheaper substitutes:
  5. Which cost models survive the BM25+vector hybrid:

BM25 (reading-bm25.md)

  1. tf for 90% of the K1+1 ceiling; keyword-stuffing implication:
  2. idf smoothing at df=0 and df=N:
  3. b=0.75→0 on uniform-length corpus vs tweets+books:
  4. 1-byte fieldnorm worst-case score error; why ranking survives:
  5. Where M23 gets relevance feedback for full RSJ weights:

Block-max WAND (reading-blockmax-wand.md)

  1. θ after heap fills on [t0 t12000]; can t0 alone cross it:
  2. Why block-max helps most on common terms (block-max variance):
  3. Metadata overhead/posting at 128; u8 quantization direction rule:
  4. Hybrid with unbounded vector scores — WAND candidates + RRF vs fused:
  5. Deleted docs holding block maxima — still exact? merge-time fix:

Roaring (reading-roaring.md)

  1. Derive 4096 crossover; when run containers win; doc-id locality:
  2. Predicted bitmap∧bitmap cost for t0∧t1 vs 97 µs measured vec:
  3. Galloping vs container probe — where roaring wins on memory traffic:
  4. Roaring containers ↔ GraphBLAS sparse/bitmap format lattice:
  5. RediSearch→GraphBLAS conversion cost FalkorDB pays; native saving:

tantivy (reading-tantivy.md)

  1. FST vs hash dict — three query types, the sorted-insert cost:
  2. df-in-dictionary makes which WAND input free:
  3. Tail <128 postings — vint fallback vs RediSearch always-varint:
  4. Tiered segments fine for text, bad for LSM point reads — why:
  5. Quickwit on S3: which files fetched, what order:

RediSearch (reading-redisearch.md)

  1. Effective block-size policy; why variable blocks resist block-max:
  2. gc_marker/unique_id ↔ delta-matrix wait/version:
  3. Codec ladder ↔ Lucene positions/doc-values taxonomy:
  4. Varint vs bitpack bytes/posting at delta≈1; where varint wins:
  5. 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):

lanermatuniform
PageRank to 1e-48 iters, 10.8 ms, 1.35 GTEPS-ish6 iters, 7.9 ms
Triangle count15,645,988 in 376 ms5,428 in 158 ms
Dijkstra ×3 sources33.7 ms, 343K pops
CC union-find18,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

Experiments

filestatuswhat it shows
graph.rsprovidedweighted CSR, RMAT (skewed) + uniform generators
sssp.rs dijkstraprovidedheap Dijkstra with pop counter
sssp.rs delta_steppingstubbucketed SSSP, work-vs-ordering dial
bc.rs bfs_sigma+bc_bruteprovidedpath counting + O(n³) definitional oracle
bc.rs brandesstubdependency accumulation, exact + sampled
cc.rs cc_unionfindprovidedexact baseline, all edges
cc.rs afforeststubsampling CC, edges_inspected ≪ m
analytics.rsprovidedpull PageRank, degree-ordered triangle count
bin/algo_bench.rsprovidedrmat-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 wraps LAGr_PageRank in src/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.ccLAGraph LAGr_Betweenness.c
forwardPBFS (:51): CAS on depths, records succ BITMAP (:76) — “is (u,v) a DAG edge” = one bitfrontier/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
backwarddeepest-first over depth_index, reads succtransposed mxm per level with bc_update matrix
samplingk sources, scores scaledsources array — batch size = ns
winsper-edge constants, one bitmap read per edgeno 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

  1. σ 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.
  2. σ 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.
  3. 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).
  4. 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)

  1. Derive the recurrence from the definition (the partition-by- successor argument). Where does the “+1” come from?
  2. 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?
  3. 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?
  4. 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?
  5. FalkorDB has proc_betweenness.c calling LAGraph. M24: what should CALL 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 the succ bitmap 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.ccLAGraph LAGr_SingleSourceShortestPath.c
bucketthread-local vector bins (:32-44), merged at sync pointstmasked sparse vector = current bucket (:100-142)
relaxexplicit RelaxEdges with CAS-free benign races (:69-79)one GrB_vxm with MIN_PLUS semiring (:151-185) per inner iteration
stale entriesleft in old bins; skipped when drained (:44 — redundancy beats bookkeeping)mask + select prune them algebraically
light/heavy splitskipped 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)

  1. 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.
  2. new_dist / delta can exceed the bins vec — grow it lazily; don’t precompute max_dist/Δ (you don’t know max_dist yet).
  3. 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)

  1. 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).
  2. Δ=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)?
  3. Why do thread-local bins + benign write races (gapbs :32) not corrupt distances? What property of min makes the race safe — and which GraphBLAS concept is that (idempotent monoid)?
  4. 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?
  5. M24: FalkorDB’s weighted-shortest-path today is algo.SPpaths/ BFS-flavored. Sketch CALL 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_PLUS GrB_vxm per 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)

filealgorithmthe trick
src/bfs.ccdirection-optimizingtopic 20’s guide covers it — α=15, β=18 here
src/sssp.cc:87DeltaStepthread-local bins (:32 comment); :44: redundant relaxation is CHEAPER than removing stale entries — same lazy-deletion bet as our Dijkstra oracle
src/bc.cc:51BrandesPBFS records a succ BITMAP (:76) so backprop tests “is w my BFS successor” in one bit — no depth recheck
src/cc.cc:95Afforest:106 neighbor_rounds=2 link sweeps, :69 SampleFrequentElement (1024 samples), :129 final sweep skips the giant component
src/pr.cc:31-57pull PRkDamp .85, L1-error stop; pr_spmv.cc is the same as one SpMV per iter — the algebraic identity made explicit
src/tc.cc:52-99ordered TCOrderedCount 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)

  1. 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?
  2. sssp.cc:44 argues redundant relaxations beat precise bucket removal. Under what edge-weight distribution does that bet fail?
  3. 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?
  4. 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)?
  5. 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

anchorwhat
:69-71the state: mngp (min neighbor grandparent), gp, gp_new — SV’s hooking/shortcutting as three vectors
:102hooking = ONE mxv: mngp = min_2nd(A, gp) — every vertex reads its neighbors’ grandparents in one masked matrix op
:145-158shortcutting: parent = min(parent, mngp) via mxv on a PARENT MATRIX + gp_new = parent(parent) (extract = pointer chase as assign)
:335-338sampling: FASTSV_SAMPLES per row, sampling = nvals > n*samples*2 && n > 1024 — Afforest’s idea imported
:231-235built-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.c vs LAGr_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.c exists 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_MatrixLAGr_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)

  1. 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?
  2. 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?
  3. 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?
  4. 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.
  5. 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 calls LAGr_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)

appF(u,v)frontier evolution
apps/BFS.CCAS parent[v]classic expanding→shrinking wave
apps/BC.Cadd σ contributions; TWO passes (forward + Brandes backward, both as edgeMaps)dense mid-BFS — direction switch fires
apps/Components.Clabel-propagation minfrontier = “changed last round”
apps/BellmanFord.CwriteMin diststays dense on low-diameter graphs
apps/PageRank.Csum contributionsALWAYS 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)

  1. 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).
  2. edgeMapDenseForward (:85) pushes from ALL vertices without early exit. When does it beat edgeMapDense (pull with break)?
  3. 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?
  4. 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.
  5. 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

  • ligraligra/ligra.h (:235-272 edgeMapData, the switch) and apps/ (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)

  1. 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?
  2. 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?
  3. Leiden’s refinement merges randomly ∝ exp(ΔQ/θ). What breaks if you make it greedy-deterministic (the paper tells you — §Methods)?
  4. 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?
  5. 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.

lanermatuniform
PageRank (pull, ε=1e-4)8 iters, 10.8 ms, 1.35 GTEPS-ish6 iters, 7.9 ms, 1.59
Triangle count (degree-ordered)15,645,988 in 375.8 ms5,428 in 158.0 ms
Dijkstra ×3 sources33.7 ms, 342,909 pops
CC union-find18,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)

questionpredictionactual
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)

  1. Road-vs-twitter kernel ranking flips; diameter vs degree variance:
  2. When redundant-relaxation (sssp.cc:44) loses:
  3. BC source sampling bias on 18K-component RMAT; stratification:
  4. pr.cc vs pr_spmv.cc on kron — gather cost:
  5. Why no community-detection kernel in GAP:

Delta-stepping (reading-delta-stepping.md)

  1. Relaxations-vs-Δ curve prediction (table above):
  2. Δ=1 integer weights = Dial’s algorithm; why O(1) beats heap:
  3. Benign races + min = idempotent monoid; the GraphBLAS name:
  4. vxm count vs max_dist/Δ; where algebra pays:
  5. CALL algo.sssp over M20: semiring, bucket vector, Δ in API:

Brandes (reading-brandes.md)

  1. Recurrence derivation; where the +1 comes from:
  2. Brute’s O(n²) memory vs oracle-fitness:
  3. succ bitmap vs depth recheck — memory touches per edge:
  4. Batch size ns limits in LAGr_Betweenness; sweet spot at n=65K:
  5. BC under unflushed deltas — flush vs stale-main:

Ligra (reading-ligra.md)

  1. Frontier where m/20 threshold picks wrong:
  2. edgeMapDenseForward vs edgeMapDense (early exit value):
  3. BC.C constructs ↔ LAGr_Betweenness ops; who batches:
  4. Label-prop vs afforest edges touched, 1 vs 18K components:
  5. Callback API vs fixed menu for M24; safe-embedding costs:

Louvain→Leiden (reading-louvain-leiden.md)

  1. 5-vertex disconnection example:
  2. Resolution limit on fraud rings; γ vs CPM:
  3. Greedy-deterministic refinement — what breaks:
  4. Leiden iteration on M20 core: SpGEMM vs SPA steps:
  5. Connectivity property test for algo.community:

LAGraph algos (reading-lagraph-algos.md)

  1. min_2nd semiring rationale; MIN_TIMES failure on weights:
  2. FastSV rounds vs Afforest rounds; why Afforest wins wall-clock:
  3. Sandia_LUT urand exception ↔ dot3-vs-saxpy3:
  4. Dangling-vertex error of our pull PR on 18K components:
  5. 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?

layermessage_and_aggregateanchor
GCNConvspmm(adj_t, x, reduce=sum)gcn_conv.py:273
SAGEConvspmm(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)

laneresult
SBM build (64 blocks x 256)34.4 ms
uniform walks 65,536 x 40 steps61.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/)

stubcontract
walks::node2vec_walksp=q=1 matches degree-stationary distribution; q orders exploration (ring of cliques); p orders backtrack rate
embed::train_skipgramSBM intra-block cosine > inter-block + 0.2
gcn::gcn_norm + gcn_forwardmatches 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:344CALL 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

  • Topic 20: SpMM/semirings — the aggregation kernel is M20’s mxm with 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)

  1. 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)?
  2. Count edge passes per GAT layer vs GCN layer. On our 566K-edge SBM at 21 GFLOP/s SpMM, estimate the forward-time ratio.
  3. 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)?
  4. 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)?
  5. 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 absent message_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-normalized D^-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)

  1. Show A_hat = D^-1/2 (A+I) D^-1/2 has eigenvalues in [-1, 1] and why that matters for stacking (the renormalization trick’s actual job).
  2. 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?
  3. Count FLOPs both association orders for Cora and for our SBM bench config; where’s the crossover h/d ratio?
  4. 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?
  5. 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.wcc three-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.pygcn_norm (:45-71) is the reference A_hat construction our gcn::gcn_norm stub 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

anchorwhat
:344CALL db.idx.vector.queryNodes('{label}', 'embedding', $top_k, vecf32($vector)) — chunk ANN
:378same over __Entity__ — entity ANN
:426queryRelationships('RELATES', ...) — EDGE vectors, with a Cypher cosine-scan fallback (:414) if unsupported
:219,:234,:312SET c.embedding = vecf32($vector) — embeddings computed OUTSIDE, written back as properties
:133full-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:12 expand_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:48 runs 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:19 SemanticRouter picks a strategy per question — a query PLANNER driven by embeddings instead of statistics (topic 9 with vibes).

Systems smells to fix in M25

  1. k+1 round trips: ANN then per-hit expansion — push the join down.
  2. Client-side rerank: cosine in Python over returned vectors — the index already computed distances; return them.
  3. Embedding writes are not transactional with the entities they describe (batch SET after ingest) — staleness window with no read-your-writes story (topic 8).
  4. 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)

  1. 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?
  2. 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?
  3. 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?
  4. 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?)?
  5. 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 separate lin_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)

  1. Why does mean aggregation + separate self-linear (lin_r) approximate concat? What expressiveness is lost vs true concat?
  2. 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?
  3. 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?
  4. 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”?
  5. 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, fused spmm with reduce=mean) and torch_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)

  1. 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?
  2. 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.
  3. The paper evaluates with logistic regression on frozen embeddings. What does that measurement HIDE that an end-to-end GNN shows?
  4. 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)?
  5. 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.pyloss (: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

stopfile:linewhat to see
1torch_geometric/nn/conv/message_passing.py:39the 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/:609the four overridables: message, aggregate, message_and_aggregate, update
4nn/conv/gcn_conv.py:45-71gcn_norm — A_hat construction (our stub’s reference)
5gcn_conv.py:270-274GCN’s two personalities: per-edge message (COO gather-scatter) vs fused spmm(adj_t, x)
6nn/conv/sage_conv.py:146-152SAGE: same fusion, reduce=mean
7nn/conv/gat_conv.py:392-408GAT: why fusion is impossible (per-edge softmax)
8utils/_spmm.py:12the spmm shim — dispatches to torch.sparse CSR, torch_sparse, or EdgeIndex backends
9nn/models/node2vec.py:64,101-160walks as a custom op + SGNS loss
10loader/neighbor_loader.py:10minibatch 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 (propagate introspects the signature to build kwargs). A fixed semiring menu (GraphBLAS) fuses always but expresses less. Same tradeoff, third community.
  • torch.compile support forced a template-generated propagate (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)

  1. 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?
  2. 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.
  3. spmm’s reduce='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)?
  4. NeighborLoader returns a renumbered subgraph per batch — relate to topic 5’s buffer-pool page pinning: what’s the working set, who evicts?
  5. 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, :421 propagate, :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_at maps many heads to one tail → all employees collapse toward z_Acme − z_works_at. TransH/TransR project per-relation; RotatE rotates instead of translates.
  • Symmetric relations: z_r ≈ −z_r forces z_r ≈ 0married_to becomes “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)

  1. Prove the symmetric-relation collapse (score(h,r,t) = score(t,r,h) for all pairs ⟹ what about z_r?).
  2. 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?
  3. 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)?
  4. TransE on our SBM (untyped edges, one relation): what degenerates, and what does that say about when KG embeddings beat node2vec?
  5. 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.

laneresult
uniform walks 65,536 x 4061.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)

questionpredictionactual
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)

  1. Second-order necessity (what first-order bias can’t express):
  2. Rejection-sampling draw count at q=0.25 on a bridge vertex:
  3. What frozen-embedding + logistic-regression evaluation hides:
  4. Edge insert invalidates unboundedly many walks — IVM implications:
  5. CALL algo.node2vec arg surface vs proc_pagerank:

GCN (reading-gcn.md)

  1. A_hat eigenvalues in [-1,1] — renormalization’s job:
  2. 2-hop receptive field on RMAT (low diameter → oversmoothing):
  3. FLOP crossover for (AX)W vs A(XW), Cora vs our SBM:
  4. Graph baked into A_hat: what W survives 10% new edges:
  5. Pending deltas in A_hat = topic 24’s algo.wcc three options:

GraphSAGE (reading-graphsage.md)

  1. mean + lin_r ≈ concat — lost expressiveness:
  2. Nodes-touched B=512 S=(25,10) vs full 2-hop (SBM, RMAT hub):
  3. Sampling for variance vs sampling for work (Afforest):
  4. Lazy embedding refresh semantics on insert (topic 8 words):
  5. Transductive vs inductive as the stored artifact — ship which:

GAT (reading-gat.md)

  1. Softmax over in-edges forces which storage direction:
  2. Edge passes per GAT vs GCN layer; forward-time ratio estimate:
  3. a_src/a_dst split = factor-out-of-join:
  4. Attention as explanation — Cypher surface:
  5. Is GAT worth engine support (kernel inventory argument):

PyG (reading-pyg-message-passing.md)

  1. GCNConv.forward trace; _cached_adj_t’s database name:
  2. COO m x d temp vs CSR spmm memory, bench config + RMAT d=128:
  3. reduce=‘max’ breaks backward — GraphBLAS-GNN constraint:
  4. NeighborLoader subgraph ↔ buffer-pool working set:
  5. The one kernel to expose to Cypher:

TransE (reading-transe.md)

  1. Symmetric-relation collapse proof:
  2. False-negative corruption vs cardinality stats:
  3. Filtered ranking = filtered ANN (topic 14):
  4. TransE degeneracy on untyped SBM:
  5. Per-relation vectors: where they live, DDL transactionality:

GraphRAG-SDK (reading-graphrag-sdk.md)

  1. The one-query replacement for expand_relationships:
  2. Engine-side 3-way score fusion — WAND-able?:
  3. Transactional embedding writes — cost to ingest:
  4. Costing the router (stats for graph-vs-ANN choice):
  5. 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.queryNodes in 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 lookupnsmemory
binary search over sorted vec16776 MB (the data)
BTreeMap218~200 MB
HashSet24224 MB
blocked bloom (stub target)~15-2512 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/)

stubcontract
bloom::BlockedBloomzero false negatives; FPR < 2.5% at 10 bpk (< 4× theory); halves 8→16 bpk; whole-cache-line sizing
cuckoo::CuckooFilterno 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

  • 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

anchorwhat 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

anchorwhat it does
getAltHash :122the involution above
Filter_Find :146check fp in both candidate buckets
Filter_FindAvailable :241first empty slot in either bucket
Filter_KOInsert :307the kicking loop: evict a resident (ii = getAltHash(fp, ii) :321), swap, retry up to maxIterations
CuckooFilter_InsertFP :256try all subfilters’ empty slots first, kick only in the newest, grow a new subfilter when kicking fails
CuckooFilter_Delete :216delete = 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:

  1. 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.
  2. 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/ picksplit heuristics (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 lets picksplit/penalty be 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

  1. 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?
  2. geohashEstimateStepsByRadius takes 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)?
  3. 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?
  4. 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* picksplit reduce (not eliminate) it?
  5. 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.
  6. M26 mapping: sketch GEO.ADD/GEO.SEARCH for 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

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)

anchorwhat it does
:196-198 (header comment area)P=14, 6-bit registers, the dense layout
hllPatLen :467hash, 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 :502the 6-bit pack/unpack shift dance (:354 comment walks it) — we spend a byte per register to skip this
hllDenseRegHisto :528builds reghisto[rank] — count() consumes the histogram, not the registers
hllSigma :1016, hllTau :1033Ertl’s two series (linear-counting-like correction at the low end, saturation correction at the high end)
hllCount :1058the 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-33PGM_SUB_EPS/PGM_ADD_EPS — the window is [pos−ε, pos+ε+2), clamped; the +2 matters (segment boundaries)
pgm_index.hpp:67class PGMIndex; build :88 loops make_segmentation per level
segment_for_key :134the 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 :192predict, widen by ε, return the window — our search_window
piecewise_linear_model.hpp:45OptimalPiecewiseLinearModel — O’Rourke ’81 streaming convex-hull method
add_point :96, hull updates :154-190maintains upper/lower convex hulls of the feasible-slope region; segment closes when hulls cross
make_segmentation :276the 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 :293gapped array + per-node linear model; num_keys_ :325 vs slots = the gap budget
predict_position :1448the model eval
find_key :1456predict, then exponential_search_upper_bound :1462 from the predicted slot — cost is O(log distance-of-model-error), no ε needed
find_insert_position :1497same predict-then-search on the insert path
:28, :474, :1513the 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.h is 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 (brininsert brin.c:349 unions new values into the range’s BrinMemTuple
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

AMgranularityanswer typewrite costshadow in this topic
nbtreerow (TID)exactleaf dirty + WAL per insertthe 167/218 ns baseline lanes
GINkey → TID setexact setpending-list amortizedroaring/postings (topic 23)
BRIN128-page rangeone-sided maybeupdate range summaryzone 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 descent
  • gin/ginpostinglist.c + gin/README — varbyte posting lists
  • brin/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:

containerroaring-rs typewhensize
arrayArrayStore (sorted Vec<u16>)card ≤ 40962 bytes/element
bitmapBitmapStore (1024 × u64)card > 40968 KB flat
runIntervalStore (sorted (start, end) pairs)few runs4 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.rs uses portable std::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:

roaringredis HLL sparsepostgres GIN posting
unit64K chunkregister streamTID list segment
encodingsarray/bitmap/runZERO/XZERO/VALvarbyte deltas
promote whencard > 4096bytes > 3 KB or rank > 32page 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)

lanens/lookupnote
binary search (miss)167~23 dependent cache misses over 76 MB
BTreeMap (miss)218pointer-chasing tax over binary search
HashSet (miss)24the speed target — at 224 MB, the memory anti-target
binary search (hit)169hit ≈ 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 lanepredictionreasoning
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 bpkFPR ~4× aparteach 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 addserr < 1.5%σ = 0.81% at P=14; one seed, so anywhere within ~2σ
learned ε=64~90-120 nssegment descent cached + ~7-step window search, still 2-3 data misses
learned ε=256faster or slower than ε=64?fewer segments (better cached) vs wider window — my bet: within 10%
learned segments, 1M uniform~500-1500cone ≥ 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

lanemeasuredprediction 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 not i1 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_unwind so 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.rs doc comments is transcribed line-by-line from redis hllCount/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 queryfull recompute / batchincremental target
triangle count97.2 ms~µs (stub) — batch·d̄ probes, not m·d̄
2-hop wedge join894.3 ms~µs-ms (stub) — bilinear delta rule
reachability from src24.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/differentialDBSP/FelderaMaterializeRisingWave
theorylattice timestampsabelian-group circuitsdifferential underneathad-hoc deltas + barriers
recursionfull (Naiad loops)nested circuitsWITH RECURSIVE (limited)no
statearrangements in RAMbatch/trace spine, spillablearrangements + persist (S3 log)Hummock LSM on S3
consistencymulti-versioned by designper-tickstrict serializable readsbarrier-aligned snapshots

The stubs (experiments/)

stubcontract
djoin::delta_join + IncrementalJoinequals join(A+ΔA, B+ΔB) − join(A,B) exactly, deletes retract output rows, 30-batch drift-free
tri::IncrementalTrianglestracks 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::SemiNaiveReachmatches 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

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.

  • 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

anchorwhat it is
algebra/zset/the ZSet/IndexedZSet traits — weighted collections as a trait hierarchy over “batch” storage
operator/z1.rs:221Z1 — the delay; DelayedFeedback :37 is how cycles (recursion) are wired
operator/integrate.rs:85integrate — the running trace; integrate_nested :158 for inner circuit clocks
operator/differentiate.rs:38D; note differentiate_with_initial_value :105 for bootstrapping from a snapshot
operator/join.rs:123/:283/:350join, stream_join_generic, join_generic — the ^Δ forms specialized
operator/distinct.rs, aggregate.rsthe nonlinear ops, each carrying its integral
operator/delta0.rsinjects 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’s CommittedZ1 :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.rs per 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; plus examples/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

Kafkadatabase internals
partitionWAL shard / redo stream
offsetLSN
consumer group rebalancereplica assignment (topic 15)
log compactioncheckpoint + WAL truncation, per key
retention windowhow far behind a replica may fall before full resync (PSYNC backlog, topic 15)
topic with schema registrythe 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:

anchorwhat 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/:402half_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.rsthe 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):

anchorwhat it is
common/src/array/stream_chunk.rs:45enum 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:158HashJoinExecutor: 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.rstwo-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

axisMaterializeRisingWaveM27 (FalkorDB standing queries)
delta algebradiffs everywhere (differential)Op enum per chunkdelta matrices (DP/DM)
join stateshared arrangements, RAMper-join Hummock tablesthe graph matrices themselves
consistency unittimestamp + frontierbarrier/epochwriter tick (single writer!)
recoveryrehydrate from persistcheckpoint + replaytopic 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

anchorwhat it is
progress/change_batch.rs:16 ChangeBatchthe (time, ±count) buffer — progress updates are themselves Z-set-shaped
progress/frontier.rs:380 MutableAntichainthe frontier: minimal elements of outstanding times; update_iter :533 applies count changes and reports which minimal times appeared/vanished
progress/reachability.rsthe static could-result-in analysis over the dataflow graph
progress/subgraph.rsscopes: nested dataflow whose inner timestamp adds a coordinate
worker.rs:235 stepthe 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 conceptdatabase concept
timestamp/epochtransaction id / batch boundary
frontier passes twatermark: txn t’s snapshot is complete
could-result-independency tracking for safe truncation
loop counter coordinaterecursive CTE iteration depth
step() cooperative schedulingtopic 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 (:380 MutableAntichain), progress/reachability.rs, progress/subgraph.rs, worker.rs (:235 step)

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 queryfull recompute / batchnotes
triangle count97.2 msO(m·d̄) sorted-intersect sweep; count 1366 after last batch
2-hop wedge join894.3 msfull 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 lanepredictionreasoning
incremental triangles5-30 µs/batch, ~3000-10000×100 changes × d̄=20 probes × ~15 ns/BTreeSet probe
incremental wedge join1-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 lateeach edge relaxed ≤ 2× ever; late batches mostly intra-component = free
relaxations≤ 2m ≈ 1Mfrontier 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

lanemeasuredprediction 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_linear in 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)

tierp50p99vs local
local NVMe read0.10 ms0.12 ms
raw S3 GET14.17 ms112.99 ms140× / 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:4985 branch_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

axisAuroraSocratesNeonSnowflakeSlateDB
what crosses the networkredo log onlylog + pagesWAL to safekeepersmicro-partition filesSSTs + manifest
durability6-way 4/6 quorumXLOG servicesafekeeper quorumS3S3 (+ optional WAL obj)
page/read servicestorage nodes replaypage servers (RBPEX cache)pageserver + walredowarehouse-local cacheblock cache + part cache
branching/clonessnapshotsO(1) LSN brancheszero-copy clonecheckpoint/clone (clone.rs:38)
single-writer fencingepoch in quorumgeneration numberswriter_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.

filewhatstatus
sim.rslatency models (NVMe / S3 lognormal+stragglers / scripted), block store, zipf, percentilesPROVIDED
cache.rsLruBlockCache + TieredReader read-through (slatedb’s CachedObjectStore shape)STUB
hedge.rshedged_get — backup request at p95 deadline (quickwit’s TimeoutAndRetryStorage)STUB
branch.rsBranchStore::get — CoW branch ancestry walk (Neon timelines)STUB
bin/tier_bench.rsthe 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

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)

anchorwhat it is
pageserver/src/pgdatadir_mapping.rs:258get_rel_page_at_lsn — the public question: (relation, block, LSN) → page
pageserver/src/tenant/timeline.rs:1227/:1339Timeline::get / get_vectored — batched key×LSN reads
timeline.rs:4491get_vectored_reconstruct_data — gather image + deltas needed to rebuild each page
timeline.rs:4548the 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/:596LayerMap::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:213DeltaLayer — sorted (key, LSN) → WAL record files
tenant/storage_layer/image_layer.rs:148ImageLayer — materialized pages at one LSN
pageserver/src/walredo.rs:55/:173/:473PostgresRedoManager::request_redoapply_wal_records in a sandboxed Postgres subprocess — topic 5’s REDO on the read path
pageserver/src/tenant.rs:4985branch_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)

anchorwhat it is
safekeeper/src/safekeeper.rs:292AppendRequest — WAL push protocol messages, term-based (Raft-flavored, “Paxos-ish” per their docs)
safekeeper/src/wal_storage.rssegment files on safekeeper disk — the durable landing zone
safekeeper/src/wal_backup.rsoffload of safekeeper WAL to S3 once pageserver has consumed it
safekeeper/src/timeline_eviction.rsevict 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 in safekeeper/src/
  • Neon architecture posts: “Architecture decisions in Neon”, “Get page at LSN” docs in docs/ in-repo (skim docs/pageserver-storage.md & docs/walservice.md equivalents 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 ◄────┘
anchorwhat it is
db.rs:205/:842get_with_options — memtable → L0 → runs, same read path as topic 4; :309 maybe_apply_backpressure
tablestore.rs:37/:348/:797/:835TableStore — SSTs as objects; read_blocks_using_index fetches only needed 4 KiB blocks via ranged GETs
cached_object_store/object_store.rs:34/:198local 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:824writer_epoch / compactor_epoch
fence.rs:105fence() — 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:38checkpoints 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:42the 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

anchorwhat it is
quickwit-storage/src/bundle_storage.rs:40/:131a 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/:89hedged/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:608StorageTimeoutPolicy — the hedge deadline as config
quickwit-storage/src/split_cache/mod.rs:43/:123whole-split local cache with explicit admit/evict policy
quickwit-storage/src/cache/byte_range_cache.rsbyte-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)

pathologyslatedb answerquickwit answerour stub
15 ms GETsRAM+disk block/part cachessplit cache + byte-range cachecache.rs
fat tailretries in object_store clientTimeoutAndRetryStorage hedginghedge.rs
per-request $big SSTs, block-granular ranged GETsone-object bundles + hotcache(block granularity)
no rename/atomicitymanifest CAS + epochsimmutable splits + metastore
cheap copiescheckpoint/clone over shared SSTssplits shared by referencebranch.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:

jobrequirementSocrates tier
durabilitytiny, fast, sequential, SSD/NVMXLOG service (the landing zone)
availabilitybig, warm, random-read, scalablePage 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

AuroraSocratesNeon
durability quorumstorage nodes (4/6)XLOG landing zonesafekeepers (Paxos-ish)
page servingsame nodesseparate page serverspageserver
cold tier(internal)XStore blobsS3 layer files
engine rewrite?storage layer yesminimal (reuse)none (stock Postgres + smgr hook)
cachesstorage-side pagesRBPEX (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)

lanep50p95p99mean
local NVMe0.10 ms0.12 ms0.12 ms0.10 ms
raw S3 (no cache)14.17 ms27.18 ms112.99 ms17.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 lanepredictionreasoning
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 p95zipf(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 p95p99 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 branchingbuild ms-scale; tip reads < 1 µs/read despite 64-hop worst caseHashMap 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

lanemeasuredprediction 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_micros charges cost; Fixed scripts 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.
  • BranchStore LSNs 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.50.3%
0.929.9%
1.186.2%
1.399.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

systemconcurrency controlclockcross-shard atomicityblocking window
Percolator/TiKVoptimistic (lock at prewrite)TSO (central oracle)primary-key commit pointnone — readers resolve
Spanner2PL + 2PCTrueTime (ε-bounded GPS/atomic)2PC over Paxos groupsPaxos-replicated coordinator
CockroachDBMVCC + timestamp orderingHLC + max-offsetparallel commits (STAGING)none — status recoverable
Calvindeterministic executionsequencer batch ordernone needed (order fixed a priori)none — but no interactive txns
FoundationDBOCC (resolver checks read/write sets)sequencer versionstampsproxy makes batch durablerecovery 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

filewhatstatus
kv.rssharded MVCC cluster with Percolator’s data/lock/write columns, TSO, ZipfPROVIDED
tpc.rs2PC coordinator + DST crash points + recovery — the blocking window on displaySTUB
percolator.rsprewrite / commit_primary / commit_secondaries / resolve_lockSTUB
hlc.rshybrid logical clock send/recv rulesSTUB
bin/txn_bench.rsconflict probability (provided) + abort rates vs θ + 2PC crash stormlanes

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

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)

  1. 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.
  2. 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.
  3. 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

  1. Calvin still uses locks (§3.2). Why does deterministic lock ordering eliminate both deadlock and the need for 2PC, when 2PL alone eliminates neither?
  2. 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.)
  3. OLLP’s reconnaissance pass is optimistic. Construct the pathological workload where it livelocks, and relate it to our θ=1.3 row (99.6% collision).
  4. Why is a deterministic database’s replication cheaper than shipping a physical WAL (topic 15), and what does that trade for CPU?
  5. 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?)
  6. 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_old is 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

  1. fdbserver/resolver/ConflictSet.cpp:947ConflictBatch::detectConflicts: the heart. :996 checkReadConflictRanges probes each txn’s read ranges against the version-annotated skip list (SkipList at :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.
  2. fdbserver/commitproxy/CommitProxyServer.cpp:504CommitBatchContext: 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.
  3. 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.
  4. 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

  1. 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 v wait on?
  2. 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.
  3. 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?
  4. 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?
  5. 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?
  6. 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 primaryverdictaction
lock still held (TTL expired)txn never committedroll BACK everywhere
write record at some commit_tstxn committedroll FORWARD secondaries
neitheralready rolled backclean up stray lock

TiKV code walk (in reading order)

  1. src/storage/txn/actions/prewrite.rs:37pub 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) and secondary_keys (async commit: the primary’s lock records all secondaries so the commit point can be computed without the client).
  2. src/storage/txn/actions/commit.rs:64pub 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 returns Ok(None) — commit must be replayable because the client retries.
  3. 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 our resolve_lock: a reader blocked on a lock asks the primary’s shard “did this txn commit?”, with MissingLockAction (:458) encoding the roll-back-vs-error choice when no lock is found.
  4. src/storage/txn/actions/cleanup.rs:24pub fn cleanup: the roll-back arm (write a Rollback record so a late prewrite can’t resurrect the txn — a wrinkle our simulation skips).
  5. 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.
  6. 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

  1. Why must prewrite fail on any lock, even one with start_ts newer than ours? (Hint: what does the lock’s presence say about the write CF’s future?)
  2. 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?
  3. Percolator reads wait on locks (paper: TTL + cleanup); our get returns Locked immediately. What livelock does the TTL prevent that our simulation can’t exhibit?
  4. 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?
  5. 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?
  6. M29 mapping: FalkorDB shards a graph by node id. A 2-hop traversal reads nodes on shards it never prewrites. Does Percolator’s snapshot get suffice 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/ and src/storage/mvcc/ — start at txn/actions/prewrite.rs and commit.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

  1. TrueTime: TT.now() returns an interval [earliest, latest] guaranteed to contain true time. Hardware (GPS + atomic clocks per DC) keeps ε small.
  2. Commit wait: assign commit_ts = TT.now().latest, then wait until TT.now().earliest > commit_ts before 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ε.
  3. 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.
  4. Lock-free snapshot reads: any replica can serve a read at t once its Paxos log is caught up past t — 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

  1. pkg/util/hlc/hlc.go:38type Clock: wall + logical, exactly our Hlc { l, c }. Read the comment at :42-47 on how maxOffset is a promise the deployment makes, not a measurement.
  2. hlc.go:411Now(): the send rule. hlc.go:471Update(): the receive rule (every RPC response carries a timestamp; clocks gossip ambiently). :517UpdateAndCheckMaxOffset: a remote timestamp too far ahead crashes the node rather than silently breaking the promise.
  3. pkg/kv/kvclient/kvcoord/txn_coord_sender.go:113TxnCoordSender: the client-side coordinator, structured as a stack of interceptors.
  4. 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 in STAGING state 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.
  5. 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-168 comments) is the natural endpoint.

Questions to answer while reading

  1. 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?
  2. Derive why HLC’s l <= max pt seen bound holds by induction over the send/recv rules — then find which rule breaks it if you replace max(l, pt) with l+1 (Lamport).
  3. 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.
  4. 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?
  5. Our hlc.rs test asserts two silent nodes at the same pt produce equal timestamps. Where does CRDB inject the tiebreak, and why is it fine for MVCC that two different keys’ writes tie?
  6. 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 at hlc.go:42-47 on 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.50.3%
0.929.9%
1.186.2%
1.399.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)

lanepredictionreasoning
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.330-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 stormcommitted ≈ 19.4K, crashed = 200, invariant holds1 crash per 100 txns × 20K; recovery every 4th crash leaves lock wreckage windows
3: blocked_abortsfew hundredbetween 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_aborts counts 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 allresolve_lock run 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: l never exceeds the largest physical time seen anywhere, so it can’t drift like a Lamport clock under message bursts. The l_is_bounded_by_max_physical_time_seen test hammers 1000 messages through a node whose clock reads ~10 to prove it.
  • A duplicate commit in TiKV returns Ok on 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 CrashPoint enum 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_ts is 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_ts per 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 as todo!() 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, l bounded 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

systemvalue codectime organizationlabel indexout-of-order
GorillaXOR floats, dod ts2h in-memory blocks(delegated to HBase layer)rejected
Prometheussame, xor.gohead + 2h blocks, exponential compactionMemPostings inverted indexbounded OOO window, separate buffer
VictoriaMetricsnearest-delta2 + optional lossy precisionBitsmonthly partitions of LSM partsindex_db + tagFilters cachebuffered in raw-rows shards
InfluxDB 3Parquet encodingsWAL → buffer → Parquet files by timecatalog + last/distinct cachesabsorbed by sort at persist
BtrDBdelta treetime-partitioned octree-ish tree(few, fat streams)version-annotated inserts

3. Experiments (experiments/)

cargo run --release --bin tsdb_bench

filewhatstatus
gen.rsscrape timestamps + gauge/counter/constant/random shapes, OOO arrivals, label setsPROVIDED
bits.rsMSB-first BitWriter/BitReader + sign_extendPROVIDED
baseline.rszigzag varint delta codec — the thing to beat, measured at 11.00 B/samplePROVIDED
gorilla.rsdod timestamp buckets + XOR-float codecSTUB
head.rsin-order fast path + bounded OOO window + LWW merge flushSTUB
index.rsMemPostings-style (name,value)→series inverted index + k-way intersectSTUB
bin/tsdb_bench.rsbaselines (provided) + gorilla ratios + OOO tax sweep + selector latencylanes

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

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 rangeprefixpayload
00
[-63, 64]107 bits
[-255, 256]1109 bits
[-2047, 2048]111012 bits
else111132 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

  1. 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.
  2. writeVDelta (:226) — the XOR path, with the leading/trailing window reuse.
  3. 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.
  4. 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

  1. 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?
  2. The 10 value 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 emit 11 sometimes on purpose?
  3. 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).
  4. 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?
  5. Your random_values_hit_the_entropy_floor test demands >8 B/sample. Where exactly do the extra ~1.6 bytes over raw come from? Count the control bits.
  6. 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

  1. 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)?
  2. 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)?
  3. 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.
  4. 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)?
  5. 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.
  6. M30 mapping: MATCH ... AT TIME t needs 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

  1. head.go:71type Head: the memtable. Series are keyed by a hash of the label set; each memSeries owns its chunk chain. Chunks cut at ~120 samples (see reading-gorilla.md).
  2. head_append.go:436Append: the hot path. In-order → straight into the series’ open chunk. :481 returns storage.ErrOutOfOrderSample; the decision ladder at :688-693 distinguishes ErrTooOldSample (outside the OOO window — refused) from in-window OOO. This is exactly your head.rs contract.
  3. head.go:168OutOfOrderTimeWindow: OOO support is opt-in and bounded. ooo_head.go keeps OOO samples in separate chunks merged at query/compaction time — disorder is quarantined so the in-order path never pays for it.
  4. index/postings.go:60MemPostings: map[label name]map[value][]seriesID, sorted ids. Add (:403) appends under lock. Selector evaluation = sorted-list intersection — your index.rs, and topic 23’s inverted index with labels as terms.
  5. db.go:56DefaultBlockDuration = 2h, and compact.go:41 ExponentialBlockRanges: blocks merge into exponentially larger time ranges. Compare topic 4’s size-tiered compaction — same math, time units instead of bytes.
  6. 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_id label turns 1 metric into 10M series. Your cardinality_bomb_is_visible test counts this directly.
  • Churn: rolling deployments replace pod label 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

  1. Why can prometheus get away with one WAL for all series (no per-series ordering issue), while the chunks must be strictly per-series?
  2. The head holds ~3h but blocks are 2h. Walk through why the overlap exists (what happens to samples arriving during a block cut?).
  3. 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.
  4. 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.)
  5. Retention deletes whole blocks. What query-visible anomaly can that create near the retention boundary, and why is it tolerated?
  6. 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 at head.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:75type 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 — and precisionBits makes 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; the SnapshotTracker decides when accumulated WAL periods become a Parquet snapshot. The landing-zone pattern from topic 28: durable-fast first, columnar-later.
  • queryable_buffer.rs:41QueryableBuffer: the head block, but it’s Arrow record batches, and “flush” means write Parquet + update catalog, with an optional ParquetCacheOracle (: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

VictoriaMetricsInfluxDB 3
codeccustom, tighter, optionally lossyParquet, standard, good enough
storagelocal disks it managesobject store (topic 28 economics)
queryPromQL-compatible engineSQL via DataFusion
ecosystemits own format, its own toolsanything that reads Parquet
betvertical integration wins on costcommodity formats win on leverage

Questions to answer while reading

  1. VM scales floats to int64 via decimal encoding before delta2. What float values break that (hint: mixed magnitudes in one block), and how does precisionBits paper over it?
  2. Monthly partitions (VM) vs 2h blocks (prometheus): derive how each choice follows from the retention story each system sells.
  3. 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?
  4. 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.
  5. 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?
  6. 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 t and 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:

shapedelta+varint B/sampledecode Msamples/s
constant11.00292
gauge11.00268
counter11.00303
random11.00333

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)

lanepredictionreasoning
gorilla constant~0.3 B/samplesteady state 1+1 bits, jitter pushes some ts to 9-bit bucket
gorilla gauge3-5 B/samplerandom walk: XOR shares exponent/sign, mantissa noise costs ~30-45 meaningful bits
gorilla counter4-6 B/samplevalue changes every sample by a varying integer — XOR of shifting mantissas is wide
gorilla random9-10 B/sampleentropy floor + control-bit overhead (test demands >8)
gorilla decode100-200 Msamples/sbit-at-a-time reader; slower than the byte-aligned varint baseline’s ~300
ooo tax 0%→50%ingest barely moves; flush grows ~k log kappend is O(1) either path; sort of the OOO fraction dominates flush
index intersect hot∧rare<1 µsshortest-list-first makes the unique instance label do all the work
index build 100K seriestens of ms400K 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 — exactly latest_write_before from 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 as todo!() 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)
shipwhole state (or delta)individual operations
network needsnothing — any gossip, any dupescausal delivery, exactly-once (or idempotent ops)
mergejoin of semilatticeapply op; concurrent ops must commute
in this cratecounter.rs, orset.rs, lww.rs, graph.rsrga.rs (Insert/Delete ops)
in the wildRiak, Redis Enterprise CRDTsYjs, automerge, loro updates

The zoo you build (src/)

fileCRDTthe one ideastatus
clock.rsVClock + Dot(replica, counter) names every event; pointwise-max join; partial_cmp → None defines “concurrent”PROVIDED
lww.rsLWW register/maptotal order by (ts, replica) — converges by discarding concurrent writesPROVIDED
counter.rsG/PN-Counterper-replica slots + pointwise max; PN = two G-Counters because signed max isn’t a joinstub
orset.rsadd-wins OR-Setadds tag fresh dots; remove kills only observed dots → concurrent add survivesstub
rga.rsRGA sequenceidentity (Dot) not index; insert-after-parent; skip larger-id siblings; tombstonesstub
graph.rsgraph CRDTOR-Set nodes + OR-Set edges + LWW props; dangling edges hidden, not deletedstub

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)

repoanchorwhat to see
automergerust/automerge/src/clock.rs:109, :145our clock.rs, industrial: covers(), the partial order
automergerust/automerge/src/op_set2/op.rs:52succ — deletion as successor ops, not flags
yrs (Yjs)yrs/src/block.rs:160, :1302, :1415ID = our Dot; Item = our Element; integrate() = the rule you implement in rga.rs
diamond-typessrc/listmerge/merge.rs:142, yjsspan.rs:29same integrate, but ops in a run-length time DAG; NOT_INSERTED_YET spans
lorocrates/loro-internal/src/{dag,diff_calc,handler}Fugue + fractional_index + generic-btree crates
cr-sqlitecore/rs/core/src/local_writes/mod.rs:83-133LWW-per-column over SQLite; db_version bookkeeping — multi-master as an extension

Reading guides

  1. reading-shapiro-crdts.md — CRDT foundations: convergence without coordination.
  2. reading-kleppmann-json-crdts.md — JSON CRDTs & the move op: identity beats paths.
  3. reading-sequence-crdts.md — Sequence CRDTs: what a decade of engineering does to RGA.
  4. 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

  1. Implement the four stubs until all tests pass and lanes 2-4 print.
  2. 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.
  3. Delta-CRDTs: lane 1’s sync_every=1 cost is O(writes × map size) because state-based sync ships everything. Sketch the delta-mutator version of your OR-Set.
  4. Garbage: your OR-Set keeps tombstones forever. What’s the causal stability condition that lets you drop one? Who tracks it?
  5. 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?
  6. 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.rs semantics, graph.rs composition) — concurrent CREATE/DELETE of 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::LwwMap is 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/)

anchorwhat to see
local_writes/mod.rs:83-133after_update bookkeeping: bump db_version, write one clock row per changed column — the Lamport-clock spine of the whole design
db_version.rsdb_version = per-database Lamport clock; next_db_version peeks/bumps. Compare topic 29’s HLC: no wall-clock component at all here
compare_values.rsthe tiebreak when col_versions are equal: compare the VALUES by SQLite type ordering. Deterministic convergence with zero clock trust
changes_vtab.rsthe genius move: replication endpoint as a virtual table — sync = SQL
create_crr.rswhat 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

  1. 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?
  2. compare_values.rs breaks version ties by comparing values, not site_id. Your lww.rs uses (ts, replica). Both converge — which gives saner semantics when two sites write the same value, and which when they write different values?
  3. 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?
  4. 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.
  5. 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.)
  6. M31 mapping: design FalkorDB’s crsql_changes equivalent: 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 your graph.rs merge.

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 at local_writes/mod.rs and changes_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

sectionextract
§2the two editors / shopping-list examples — run them mentally against your orset.rs + lww.rs semantics
§3.1-3.2ops address identifiers (Lamport timestamps ≈ Dots), never indices or paths
§4the formal semantics: presence sets, the clear trick for assigning over a subtree
§5the 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

  1. 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).
  2. 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?
  3. 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.
  4. 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.
  5. 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)?
  6. 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 — the succ field 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)

anchorwhat to see
yrs/src/block.rs:160ID { client, clock } — literally your Dot
yrs/src/block.rs:439ItemPtr — pointer-heavy linked structure, the cost of O(1) local edits
yrs/src/block.rs:1302Item — note origin AND right_origin: Yjs (YATA) uses both neighbors at insert time, not just RGA’s single parent
yrs/src/block.rs:984, :995integrate/integrate_item dispatch
yrs/src/block.rs:1415Item::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)

anchorwhat to see
src/listmerge/merge.rs:142integrate() — “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:29INSERTED / 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 standalone fractional_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

  1. Yjs Items carry origin + right_origin; your rga.rs carries only parent. Construct the concurrent scenario where the single-parent rule produces a different (worse) order than YATA’s pair rule.
  2. In Item::integrate (block.rs:1415), when does an insert split an existing Item? What invariant about ID.clock contiguity makes run coalescing sound in the first place?
  3. 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)?
  4. 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.
  5. Define maximal non-interleaving. Show a two-user trace where RGA interleaves but Fugue doesn’t, using (counter, replica) tiebreaks explicitly.
  6. 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, and Item::integrate at :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 standalone fractional_index, generic-btree, rle crates — 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

sectionwhat to extract
SSS’11 §2.1the system model: no rollback, no consensus, updates applied locally first
SSS’11 §2.3 Def. 2.3SEC stated precisely — memorize the three clauses
SSS’11 §3.1-3.2the two sufficient conditions (semilattice / commutativity) and the equivalence proof
Report §3.1counters: G, PN — why PN needs two G-Counters (your counter.rs doc comment)
Report §3.2registers: LWW and MV-register (multi-value: keep both concurrent writes — the honest register LWW isn’t)
Report §3.3sets: G-Set, 2P-Set (remove is forever!), OR-Set (§3.3.5 — your orset.rs)
Report §4graphs! 2P2P-Graph and the remark that concurrent addEdge/removeVertex has no universally right answer — the dangling-edge problem M31 inherits
Report §5garbage 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

  1. 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?
  2. 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 your counter.rs PN design against Report §3.1.)
  3. 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.rs tombstones after bench lane 2), and what lets you ever reclaim it?
  4. 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?
  5. CvRDT and CmRDT are equivalent in theory (§3). Give two engineering reasons Yjs/automerge ship ops while Riak shipped state.
  6. 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.rs choose, 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.

lanemetricpredictionmeasured
1lost% — 10 keys, sync_every=194.98%
1lost% — 1000 keys, sync_every=10088.34%
1lost% — 100K keys, sync_every=1000012.45%
2tombstones per live dot after storm
2rounds of random gossip to converge (8 replicas)
3sequential inserts/s (Vec-backed RGA, 50K chars)
3tombstone bloat after deleting half
4dangling (hidden) edges after 100 node-removes ∥ 500 edge-adds
4edges 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

architectureone copy?freshnessisolationexample
single engine, dual formatyes (delta+main)perfectpoor–okSAP HANA
fork() snapshotvirtual copy (CoW pages)snapshot agegoodHyPer
columnar replica in-consensusno — Raft learnerbounded (learner wait)strongTiDB + TiFlash
CDC-fed separate systemno — changelogsecondstotalF1 Lightning
query offload to attached OLAPno — shared filessnapshotstrongpg_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)

repoanchorwhat to see
tiflashdbms/src/Storages/KVStore/Read/LearnerRead.cpp:35, :61doLearnerRead — freshness as a wait, with a timeout
tiflashdbms/src/Storages/DeltaMerge/DeltaMergeStore.h:107, :668the store; segmentMergeDelta = your merge_delta()
tiflashdbms/src/Storages/DeltaMerge/Segment.h:84, :715Segment = delta over stable; placeUpsert
tiflashdbms/src/Storages/DeltaMerge/Delta/{MemTableSet,DeltaValueSpace}.h, Delta/MinorCompaction.hthe delta layer’s own little LSM
tiflashdbms/src/Storages/DeltaMerge/DeltaIndex/DeltaIndex.h:27index that makes delta+stable merge reads cheap
tidbpkg/planner/core/find_best_task.go:535, :1841, :1878one optimizer, two engines: TiKV vs TiFlash paths priced together

Reading guides

  1. reading-tidb-htap.md — TiDB HTAP: the columnar replica is a Raft learner.
  2. reading-tiflash-deltatree.md — DeltaTree: columnar storage built for writes.
  3. reading-hyper-hana.md — HyPer & HANA: one copy serves both.
  4. 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: apply the log, scan_sum_a merging delta over main, merge_delta compaction.
  • 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

  1. Implement the stubs until all tests pass and lanes 2-3 print.
  2. Lane 1 starves the writer with an unfair lock. Swap in a readers-writer lock — does p99 recover? What did full scans still cost?
  3. 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.
  4. 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.
  5. Your replica applies the log single-threaded. Which parts of apply and merge_delta parallelize safely (topic 20’s rayon), and what does the answer share with LSM compaction scheduling (topic 4)?
  6. 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_delta is minor compaction; the delta index is the memtable’s answer to read amplification.
  • Topic 12 (columnar): the replica’s main_a/main_b are 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:

  1. The safe timestamp is applied_lsn. Lightning serves reads only at-or-below the timestamp it has completely applied — your freshness_is_visible test, productionized. Reads never wait (contrast doLearnerRead); instead they’re served stale but consistent, and the query layer picks a timestamp all touched replicas can serve.
  2. 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 copyseparate copies
single engineHANA delta+mainHyPer fork (logical single)
separate enginespg_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

  1. 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?
  2. 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?
  3. 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?
  4. “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)?
  5. 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?
  6. 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

  1. 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?
  2. 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?
  3. 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_cost measurement understate the problem at scale?
  4. 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_main test.
  5. 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?
  6. 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

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

  1. 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?
  2. read_wait returns None on timeout. What does TiDB do then, and why is falling back to the leader safe but expensive? (LearnerRead.cpp:61.)
  3. 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.)
  4. 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.
  5. 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?
  6. 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.cppdoLearnerRead, 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

  1. DeltaMergeStore.h:107 — the store: a map of key-range → Segment, plus the background merge machinery.
  2. 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.
  3. 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.
  4. Delta/MinorCompaction.h — compaction within the delta (fold small column files together) before the big fold into stable.
  5. DeltaIndex/DeltaIndex.h:27 — the trick your scan_sum_a lacks: a persistent index mapping delta rows into stable’s sort order, so merge reads don’t re-sort the delta every scan.
  6. DeltaMergeStore.h:668 segmentMergeDelta — the fold. Your merge_delta() contract (scans identical before/after, delta emptied) is exactly its correctness condition.

Questions

  1. 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?
  2. 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?)?
  3. merge_delta must 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.)
  4. MinorCompaction inside the delta: why compact the delta at all if segmentMergeDelta will fold everything anyway? What workload makes delta-internal compaction pay?
  5. 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)?
  6. M32 mapping: FalkorDB’s delta matrix flush is segmentMergeDelta for 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 at DeltaMergeStore.h and Segment.h; the delta layer (Delta/) and DeltaIndex/ are the parts your replica.rs deliberately lacks

Topic 32 notes — HTAP architectures

Predictions vs measurements

questionpredictedmeasured
lane 1: write p99, scans on vs off10–100x worse333 ns → 7.49 s (2.2e7x); not slowdown — starvation
lane 1: write throughput hit~2–5x fewer writes11,438,647 → 69 writes in 2 s
lane 1: scans completed in 2 s~1003261 (~0.6 ms/scan; scanner re-grabs the unfair lock back-to-back)
lane 2: merged vs delta-heavy scan5–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.log here, 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_wait analogue 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.md entry 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 build target/release/libfalkordb.dylib — note: working tree had uncommitted changes to graph/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, Python falkordb client.
  • 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)

Benchmarkn=1,000n=100,000derived (100K)
RETURN 1100 µsround-trip floor
unwind range1.79 ms177 ms565 K rows/s produced+returned
create_node555 µs34.1 ms2.9 M nodes/s
create_relationship (2 nodes + 1 edge each)1.01 ms94.4 ms1.06 M edges/s
match_node (return all)5.73 ms667 ms150 K rows/s end-to-end
match_relationship (return n,r,m)14.8 ms1.78 s56 K rows/s end-to-end
delete_node518 µs28.1 ms3.6 M deletes/s
delete_relationship861 µs46.3 ms2.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)

RepoBest for
FalkorDB/FalkorDBSparse-matrix graph engine, redis module architecture
FalkorDB/falkordb-rs-next-genRust graph engine rewrite

From your list

RepoLangBest for
redis/redisCdict incremental rehash, skiplists, event loop, RESP, AOF/RDB, rax
valkey-io/valkeyCio-threads/multithreading evolution vs redis — diff the two!
qdrant/qdrantRustHNSW, filtered ANN, quantization, raft consensus
surrealdb/surrealdbRustmulti-model design, transaction layer over pluggable KV
facebook/rocksdbC++LSM at industrial scale: compaction, block cache, txn utilities
tursodatabase/tursoRustSQLite rewrite: B-tree, pager, WAL, io_uring, DST
neo4j/neo4jJavarecord-store graph layout, Cypher planner
HelixDB/helix-dbRustgraph+vector combined engine (young codebase, easy to read)
memgraph/memgraphC++in-memory graph, skip-list storage, MVCC
ravendb/ravendbC#Voron storage engine (COW B+tree), document DB design
fjall-rs/fjallRustthe readable Rust LSM — small enough to read fully
tidesdb/tidesdbCcompact C LSM, easy first read
duckdb/duckdbC++vectorized execution, optimizer, columnar compression — very readable
postgres/postgresCMVCC, WAL, buffer manager, planner — the canon

Suggested additions

RepoLangBest for
sqlite/sqliteCbtree.c, pager, VDBE — most-deployed DB on earth
LMDB (openldap/mdb)Ccopy-on-write B+tree, single-file mmap design
skyzh/mini-lsmRustguided course — build an LSM step by step (use in topic 4)
cmu-db/bustubC++CMU 15-445 teaching DB: buffer pool, B+tree, txn labs
erikgrinaker/toydbRustreference for the capstone: raft + MVCC + SQL, written to teach
apache/datafusionRustArrow-native query engine — planner + vectorized exec in Rust
pola-rs/polarsRustvectorized columnar engine: lazy optimizer, streaming exec, SIMD kernels — DuckDB’s Rust rival
kuzudb/kuzuC++columnar graph storage, worst-case optimal joins (topic 13)
cberner/redbRustclean embedded COW B-tree in Rust
spacejam/sledRustBw-tree-inspired engine; read its post-mortems too
tikv/tikvRustraft-rs, distributed txn (Percolator) — topic 15
apple/foundationdbC++deterministic simulation testing gold standard — topic 16
ClickHouse/ClickHouseC++columnar OLAP at the extreme; read specific MergeTree parts only
unum-cloud/usearchC++compact single-header HNSW — topic 14
DrTimothyAldenDavis/GraphBLASCSuiteSparse internals — go deeper than the API you already use
GraphBLAS/LAGraphCgraph algorithms as linear algebra — the reference library over GraphBLAS (topics 20, 24)
Z3Prover/z3C++SMT solver: query-equivalence proving, invariant checking (topic 16); also a perf-engineering masterclass
quickwit-oss/tantivyRustinverted index / full-text engine — the readable Lucene (topic 23)
apache/luceneJavathe canon of search: codecs, FSTs, segment merging (topic 23)
elastic/elasticsearchJavadistributed search architecture over Lucene: shards, scatter-gather (topic 23)
RediSearch/RediSearchCsearch as a redis module — your ecosystem’s approach (topic 23)
leanprover/lean4C++/Leantheorem proving + Perceus RC runtime (topic 21)
modular/mojoMojoSIMD-first language on MLIR, CPU+GPU kernels (topics 17, 18)
TimelyDataflow/differential-dataflowRustincremental computation — the real thing (topic 27)
neondatabase/neonRustdisaggregated postgres: pageserver, safekeepers, branching (topic 28)
slatedb/slatedbRustLSM directly on object storage — small, modern (topic 28)
prometheus/prometheusGotsdb/: readable time-series storage (topic 30)
influxdata/influxdbRustIOx: DataFusion+Parquet+object storage combined (topics 11/12/28/30)
RoaringBitmap/CRoaringCcompressed bitmaps: container switching, SIMD set ops (topics 23, 26)
automerge/automergeRustCRDT engine — state/op-based, columnar op storage (topic 31)
loro-dev/loroRustfast modern CRDT engine, great perf blog posts (topic 31)

Tools

Benchmarking

ToolUse
criterion.rsRust microbenchmarks (statistical, fights noise)
divanfaster-iteration Rust benches
hyperfineCLI-level benchmarks
redis-benchmark / memtier_benchmarkRESP server load testing
YCSB (or rust port)standard KV workloads A–F
BenchBase (CMU)TPC-C, TPC-H, and 20+ workloads against SQL DBs
ClickBenchanalytics benchmark (topic 12)
ann-benchmarksrecall/QPS curves for vector indexes (topic 14)
LDBC SNBgraph benchmark standard (topic 13)
pgbenchpostgres load gen

Profiling & observation

ToolUse
cargo flamegraph / samplyCPU flamegraphs on macOS/Linux
perf (Linux) + perf stat -dhardware counters: cache misses, branch misses, IPC
perf c2cfalse sharing detection (topic 9)
Instruments (macOS)time profiler, allocations, syscalls
heaptrack / dhat-rsallocation profiling
bpftrace / eBPFfsync latency, block IO tracing (topic 5)
iostat / fioraw disk characterization — know your hardware baseline
tokio-consoleasync runtime introspection (topic 7)

Correctness

ToolUse
proptest / quickcheckproperty-based testing vs model oracle
cargo-fuzz (libFuzzer)fuzz parsers, SST/page decoders
MiriUB detection in unsafe Rust
loomexhaustive interleaving checks for lock-free code (topic 9)
ThreadSanitizer / ASanC/C++ and FFI sanitizing
SQLancerlogic-bug finding in SQL engines
Jepsen + elledistributed consistency checking (topic 15)
TLA+ / PlusCalmodel checking protocols (optional, topic 15/16)
Z3 (via z3 crate)SMT solving: prove query rewrites equivalent, check invariants (topic 16)
strace / dtrussverify 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)