Skip to content

cpp_wam store backend: in-memory index + shared cache + mmap .data (indexed matches lmdb) + crossover benchmark - #4270

Merged
s243a merged 5 commits into
mainfrom
claude/abi-store-crossover
Sep 16, 2026
Merged

s243a merged 5 commits into
mainfrom
claude/abi-store-crossover

Conversation

@s243a

@s243a s243a commented Sep 16, 2026

Copy link
Copy Markdown
Owner

cpp_wam store backend: in-memory index + shared cache + mmap .data, with a crossover benchmark & cost model

Improves the D43 store-backed seek fact source (SeekFactSource) in the shared cpp_wam runtime so the dependency-free indexed backend matches or beats lmdb across scales, and adds an auto-selection default plus the benchmark/cost-model that motivated it. Grew out of measuring the lmdb-vs-indexed crossover on a real, large store (the ABI symbol store: 256k rows, 42 MB indexed / 128 MB lmdb).

Runtime improvements (templates/targets/cpp_wam/runtime.h.mustache)

  • In-memory .idx: load the sorted key table into RAM once at open; a lookup is an in-memory binary search + one positioned .data read (was ~37 per-probe read() syscalls). ~7–9× faster.
  • Shared L1/L2 row cache: lifted the L1 (direct-mapped) + L2 (FIFO) cache out of the lmdb-only path into the shared SeekFactSource, so both backends cache identically (env UW_WAM_FACT_L1_SLOTS / UW_WAM_FACT_L2_CAP; lmdb names still honored).
  • mmap .data: read each record in place (one page access, like lmdb), closing the pure-miss gap to ~1×; POSIX-guarded, with the two-read ifstream path kept as fallback.

Auto-selection default (store/ensure_lmdb.sh, cpp_store/build.sh)

UW_STORE_BACKEND now defaults to auto: choose lmdb iff store > UW_STORE_LMDB_RAM_FACTOR(=2) × available RAM and lmdb is usable, else indexed (loud, answer-identical fallback to indexed if lmdb is unusable). Explicit indexed/lmdb override. Conservative and size-only for now; rows_per_key refinement documented for later.

Finding (see examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md)

There is no engine crossover — the apparent lmdb advantage was caching + read-path, both fixed here. lmdb's only residual edge is multi-row-per-key stores once store > RAM (magnitude ≈ rows_per_key), and even that is erasable by key-sorting the indexed .data. The disk-bound regime is modeled/extrapolated from measured primitives (resident costs + calibrated cold-seek latency), not stress-tested (no fair memory cap available).

Verification

Store differential 503 / 0, corpus 51 / 0, ABI verify 122 / 0, test_wam_cpp_templates green (goldens re-baselined for the runtime change), store/test_auto_select.sh all pass; the benchmark cross-checks rows_found identical across old/optimized-indexed/lmdb over 256k keys. Frozen resolver.pl / resolver_store.pl / debian/ untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe

s243a and others added 5 commits September 14, 2026 16:33
Add a raw-lookup benchmark that drives the C++ WAM SeekFactSource read path
directly (indexed on-disk UWFI/UWIX seek vs lmdb with the L1 direct-mapped +
L2 FIFO app caches) over the 256,225-row ABI symprov/2 store. Not the JS lmdb.

  build.pl          codegen a minimal wam_cpp project (sym_lookup/symprov) so
                    wam_runtime.h/.cpp expose SeekFactSource for each backend
  bench_main.cpp    lookup harness: encode_store_key -> src.rows(), report the
                    deterministic D43 I/O + L1/L2 cache counters + wall time;
                    optional posix_fadvise(DONTNEED) cold-cache eviction
  gen_workload.mjs  skewed / uniform / miss workloads from real store keys
  bench_crossover.sh  one-shot: build v1 lmdb store, workloads, both binaries,
                    sweep backend x workload x R x {warm,cold}, min-of-N wall
  RESULTS.md        finding: no crossover on this hardware -- lmdb wins at every
                    cell (13-72x). indexed is syscall-bound (~37 read()/lookup,
                    no cache, linear in R); lmdb amortizes to ~1 mmap op/distinct
                    key then serves reuse from L1/L2 (reads flat in R). Hard
                    memory caps unavailable (no systemd bus / cgroup deleg / root
                    on WSL2); store << RAM so cold~=warm; disk-bound regime
                    unreachable. Deterministic I/O attribution is the primary
                    signal and stands independent of memory.

Store artifacts stay under the gitignored abi/.out/. Frozen files untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…ebench

The "fair fight" for the ABI store crossover. Optimize the REAL indexed backend
so lmdb is tested on a level field, at symbol AND package scale.

Runtime (shared cpp_wam seek path, templates/targets/cpp_wam/runtime.h.mustache):
the indexed SeekFactSource now slurps the whole .idx key table into RAM once at
open (idx_blob_) and binary-searches it IN MEMORY (idx_key_compare + rewritten
lookup_offsets) plus one positioned .data record read -- eliminating the ~37
per-probe seek+read syscalls per lookup. Answer-identical.

Correctness: 503-case store differential + 51-case corpus + 122-check ABI verify
all 0 divergences/failures with the optimized runtime; bench cross-check confirms
optimized-indexed rows_found == lmdb rows_found in every cell. Byte-frozen header
goldens re-baselined (plain 90019->91891, lmdb 90260->92132; +1872 chars each,
outside the LMDB gate). Frozen resolver files / debian/ untouched.

Finding (RESULTS.md, OLD vs OPTIMIZED vs lmdb, both scales): the optimization
gives indexed a uniform ~8-9.5x speedup. lmdb STILL wins on a level field at both
scales, but the margin collapses -- to ~1.6-2.2x on zero-reuse (the fairest) and
growing with key reuse (up to 7.6x symbol / 21.5x package at R=10). The residual
edge is lmdb's L1/L2 ROW cache (reuse) + one mmap read/record vs two positioned
reads -- caching, not the storage engine. Recommendation: optimized-indexed as the
dependency-free universal default (within ~2x, 3x smaller, no liblmdb/format
dance); a follow-up row cache on indexed would close the reuse gap; keep lmdb
opt-in for high-volume high-reuse symbol resolution.

bench updates: gen_workload.mjs random-5pct hot-set fallback for package scale;
bench_main.cpp already drives SeekFactSource directly; bench_crossover.sh is now a
two-scale driver (store rebuild + optional BUILD_OLD comparison binary).

EXPERIMENT branch: the shared-runtime change would need its own PR/review if kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…hes too)

Neutralize caching -- the only remaining differentiator -- so the store crossover
becomes a true engine-vs-engine comparison. The L1 direct-mapped + L2 FIFO row
cache (key -> decoded rows) was lmdb-only; it is orthogonal to storage, so lift
it out of the WAM_CPP_ENABLE_LMDB gate into an engine-agnostic cache used by
rows() for BOTH backends. Shared ensure_cache_config() + env UW_WAM_FACT_L1_SLOTS
/ UW_WAM_FACT_L2_CAP (UW_WAM_LMDB_* still honored for back-compat). Full
(unbound-arg1) scans stay uncached; lmdb behavior unchanged.

Correctness: 503 store differential + 51 corpus + 122 ABI verify = 0
divergences/failures (gates built at -O0 under memory pressure, one at a time);
bench cross-check: indexed+cache rows_found == lmdb == nocache in every cell, and
indexed+cache now reports IDENTICAL L1/L2/miss counts to lmdb. Header goldens
re-baselined (plain 91891->92370, lmdb 92132->92611; +479 each, gate-independent).
Frozen resolver/store/debian untouched.

Finding (RESULTS.md, three-way idx-nocache vs idx+cache vs lmdb, both scales):
the cache was the WHOLE reuse differentiator. With it, indexed+cache matches lmdb
within ~1.1-1.4x on reuse (was 7-21x) -- read count now flat in R, same L1/L2/miss
as lmdb. On zero-reuse (pure miss) lmdb keeps a ~1.9-2.7x engine edge (mmap single
fetch vs indexed's two positioned reads/record), and the cache is a slight tax
there. Recommendation: optimized+cached indexed as the dependency-free universal
default (matches lmdb on the reuse-heavy workloads the resolver actually runs, 3x
smaller, no external dep); lmdb opt-in only for pure-miss high-volume symbol scans.

EXPERIMENT branch: the shared-runtime cache lift would need its own PR/review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
Step 1 -- close the resident pure-miss gap. The indexed miss path did TWO
positioned reads per record (length prefix, then payload). mmap the .data file
and read each record IN PLACE (one page access, no read syscall, exact bytes) --
like lmdb's mmap value fetch; the ifstream two-read path stays as a POSIX-guarded
fallback. Result: symbol unique R=1 (zero-reuse) indexed vs lmdb goes 2.2x -> 1.00x
warm (0.92x cold); deterministic read count falls to ~1/record = lmdb parity.
Answer-identical: 503 differential + 51 corpus + 122 ABI verify = 0 (built -O0,
one at a time under memory pressure); bench cross-check indexed rows_found == lmdb.
Header goldens re-baselined (plain 92370->96529, lmdb 92611->96770; +4159 each,
gate-independent). Frozen resolver/store/debian untouched.

Step 2 -- calibrated IO cost model (cost_model.{sh,mjs}, cost_model_probe.c).
Real memory pressure is not creatable on this WSL2 box, so measure primitives
and extrapolate the disk-bound regime (labeled as estimate). Measured: t_seek
(cold 4KB page) ~173-180us, t_mem (warm) ~0.5us -> ~340x; t_hit/t_miss_resident
per backend (indexed ~= lmdb after mmap); and the SCATTER factor exactly from
the .idx -- indexed cold-reads-per-miss = rows_per_key (source-order scatter;
measured 1/2/4/8/16), lmdb ~= 1 (key-clustered leaf). Model T = h*t_hit +
(1-h)*cold_reads*[(1/r)*t_mem + (1-1/r)*t_seek]; solving T_lmdb<T_indexed gives
K(store/RAM) ~= 1 as a THRESHOLD (benefit only once store>RAM) with MAGNITUDE
~= rows_per_key. Practical rule: use lmdb only when store>RAM AND rows_per_key>=2
with key-interleaved data (speedup ~= rows_per_key); for the ABI ~1-row/key store
lmdb is never worth it at any ratio, and sorting the indexed .data by key clusters
it (~1 page/key, measured) to erase even the multi-row edge.

EXPERIMENT branch: the shared-runtime mmap change would need its own PR/review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…+ doc

Add an `auto` backend policy and make it the default when UW_STORE_BACKEND is
unset (explicit indexed|lmdb still override). POLICY layer only -- it picks a
backend, never changes answers (both return identical rows).

Rule (examples/pkg_resolver/store/ensure_lmdb.sh:uw_resolve_store_backend):
  choose LMDB iff store_size_bytes > UW_STORE_LMDB_RAM_FACTOR(=2) * available_RAM
  AND lmdb usable; else INDEXED. If lmdb is wanted but unusable (ensure_lmdb
  fails / MDB_INVALID) it WARNs loudly and falls back to indexed (answer-
  identical, safe). Prints the chosen backend + the size-vs-2xRAM numbers.
  available_RAM = /proc/meminfo MemAvailable, override UW_STORE_AVAIL_RAM_BYTES.
  store_size = built indexed .data+.idx if present, else source P/2 JSONL
  (excludes cases.jsonl). Wired into cpp_store/build.sh (default BACKEND_REQ=auto,
  resolved to a concrete backend before the build; C++ lane opts into v1 lmdb).

Proof it doesn't change answers: 503-case store differential 0 divergences and
51-case corpus 0 divergences (corpus verified through the auto path -> indexed);
store/test_auto_select.sh checks the rule returns lmdb above 2x and indexed below
via the RAM override, explicit modes pass through, and the factor is tunable.
Build-side only -- the runtime template is untouched, so byte-frozen goldens are
untouched (confirmed). Frozen resolver/store/debian untouched.

Docs: examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md -- theory (cost model;
caching+read-path not the engine were the story; onset ~1x RAM, ramps by ~2x,
asymptote = rows_per_key; skew pushes onset past 1x so 2x is a conservative
floor), benchmarks (idx-nocache vs idx+cache vs +mmap vs lmdb; measured t_seek/
t_mem; K(rows_per_key) table), the size-only default policy, and DEFERRED
refinements (fold in rows_per_key -- ABI is 1.03 rows/key so lmdb never wins;
key-sort indexed .data to erase the multi-row edge without the dependency).
Honesty: disk-bound regime is modeled from measured primitives, not stress-tested
(no fair memory cap on this WSL2 box). cpp_store/README points to the doc; RESULTS
is the data appendix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
@s243a
s243a merged commit f0c2ac1 into main Sep 16, 2026
16 checks passed
s243a added a commit that referenced this pull request Sep 16, 2026
…_key gate (#4271)

Fix-forward on the cpp_wam store backend (#4270): make the .idx mmap'd (evictable,
not a non-evictable heap slurp that bad_alloc'd under the memory pressure the auto
policy routes to indexed), a real system-liblmdb + MDB_INVALID probe with loud
fallback to indexed, a rows_per_key gate in the auto rule, plus tellg guards, fd
hygiene, a corrupt-.idx compare guard, a real cold-cache bench, and a content-level
(row_digest) benchmark answer-identity assert.

Reviewed independently by Fable (base) and kimi k3 (fixes, final VERDICT: APPROVE,
verified against the tree incl. a link-order repro and a SHA-256 goldens check).
kimi caught a linker-order bug (-llmdb before the stdin source) that made the
MDB_INVALID probe dead code -> silent under-answering; fixed (source before lib +
real mdb_version symbol) and hand-exercised end-to-end.

Verified: store differential 503/0, corpus 51/0, ABI 122/0, test_auto_select all
pass, byte-frozen goldens intact. Frozen resolver/store/debian untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant