diff --git a/examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md b/examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md new file mode 100644 index 000000000..55c58df32 --- /dev/null +++ b/examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md @@ -0,0 +1,154 @@ +# Store backend selection: theory, benchmarks, and the `auto` default + +How the C++ WAM store-backed resolver chooses between the two D43 seek backends — +**indexed** (dependency-free UWFI/UWIX; whole-`.idx`-in-RAM binary search + mmap +in-place record read + shared L1/L2 row cache) and **lmdb** (system `liblmdb`; +keyed B-tree range-scan + the same shared cache). + +`RESULTS.md` is the raw data appendix (full tables, per-cell jsonl). This file is +the decision doc: what we learned, the policy, and how to refine it. + +## TL;DR + +- Across four rounds of optimization, **the storage engine was never the story** — + caching and the read path were. Once the indexed backend got (1) the index in + RAM, (2) the shared L1/L2 row cache, and (3) an mmap in-place record read, it + **matches lmdb** on every workload that fits RAM: within ~1.1-1.4x on reuse and + **~1.0x** on resident pure-miss. +- lmdb only pulls ahead in one regime: **disk-bound (store > RAM) AND + multi-row-per-key**, where its key-clustered leaves save cold seeks. The + asymptotic win there is **≈ rows_per_key**. +- **Default policy (`auto`): pick lmdb iff `store_size > 2 × available_RAM` and + lmdb is usable, else indexed.** Deliberately conservative and **size-only** for + now (a rows-per-key refinement is deferred; see below). + +## Theory: the cost model + +A keyed lookup is a cache hit, or a miss that reads the key's records. Per lookup, +with hit rate `h`, rows-per-key `M`, and store/RAM ratio `r ≥ 1`: + +``` +T(backend) = h·t_hit + (1−h)·cold_reads·[ (1/r)·t_mem + (1−1/r)·t_seek ] + P(a miss's page is evicted) ≈ 1 − 1/r (fraction of store not resident) + indexed: cold_reads = M (records stored in source order → M scattered pages) + lmdb: cold_reads ≈ 1 (records clustered in ~1 B-tree leaf, keyed by key) +``` + +Measured primitives on this box (WSL2; `cost_model.sh` + `cost_model_probe.c`): + +| primitive | value | measured how | +|---|---|---| +| `t_seek` — cold single 4KB page | **~173-180 µs** (p10-p90 ≈ 150-225) | `posix_fadvise(DONTNEED)` + `pread`, ×3000, median | +| `t_mem` — warm single 4KB page | **~0.5 µs** | same pages resident | +| `t_seek / t_mem` | **~340-350×** | — | +| `t_hit` — cache-hit lookup | indexed ~0.3-0.6 µs, lmdb ~0.15 µs | 50k lookups / 100 hot keys | +| `t_miss_resident` — warm zero-reuse lookup | ~2.4-2.6 µs (indexed ≈ lmdb) | `unique` R=1 warm | +| **scatter** = indexed cold-reads/miss | **= rows_per_key** (exact) | distinct 4KB `.data` pages a key spans, from `.idx` | +| lmdb cold-reads/miss | **≈ 1** | structural (key-ordered B-tree) | + +**What the model says:** + +- **Onset ≈ 1× RAM.** When `r ≤ 1` the store fits, `P(evicted) ≈ 0`, every miss is + a resident page (`t_mem`), and indexed ≈ lmdb (measured 1.0x). No benefit below + RAM. The crossover **onset** is where the store stops fitting: ~1× available RAM. +- **Ramps to asymptote by ~2× RAM.** Above RAM, `P(evicted) = 1 − 1/r` climbs: + 0 at r=1, 0.5 at r=2, →1 as r→∞. By r≈2 half the misses hit disk; the speedup is + already most of the way to its asymptote. +- **Asymptotic magnitude = rows_per_key.** Fully disk-bound, `T_indexed/T_lmdb → M` + (M scattered `t_seek`s vs 1). For `M = 1` the ratio is **1.0 at every r** — + lmdb is never worth it. +- **Skew pushes the onset past 1× RAM.** A hot set (real ABI resolution + re-touches a few libraries) keeps the working set resident even when the whole + store exceeds RAM, so misses stay cheap longer. That makes **1× RAM a floor** + for the onset and **2× a conservative trigger**. + +Solving `T_lmdb < T_indexed` for the store/RAM ratio `K` (hit_rate 0.9, measured +primitives) — `K` is a **threshold near 1**, the **magnitude is rows_per_key**: + +| rows_per_key | speedup at store ≫ RAM | K@1.2× | K@1.5× | K@2× | +|---|---|---|---|---| +| **1 (ABI symprov)** | **1.0×** | never | never | never | +| 2 | 2.0× | 1.0 | 1.05 | never | +| 4 | 4.0× | 1.0 | 1.0 | 1.05 | +| 8 | 7.9× | 1.0 | 1.0 | 1.0 | +| 16 | 15.8× | 1.0 | 1.0 | 1.0 | + +## Benchmarks (summary; full tables in RESULTS.md) + +Three-way min-of-3 wall, symbol scale (256k rows), warm: + +| workload | R | idx-nocache | idx+cache | idx+cache+mmap | lmdb | +|---|---|---|---|---|---| +| skewed (reuse) | 10 | 1706 ms | 250 ms | ~250 ms | 213 ms | +| unique (zero-reuse) | 1 | 950 ms | 1284 ms | **593 ms** | 591 ms | + +- The **cache** collapsed the reuse gap from 7-21× to ~1.1-1.4×. +- The **mmap** in-place record read collapsed the resident pure-miss gap from + ~2.2× to **~1.0×** (unique R=1: indexed 593 ms vs lmdb 591 ms warm; 0.92× cold). +- Deterministic parity: after mmap the indexed miss read-count equals lmdb's + (~1 read/record), and indexed's cache reports identical L1/L2/miss to lmdb. +- The disk-bound multi-row advantage is **modeled, not stress-tested** (see + Honesty): the scatter (= rows_per_key) and `t_seek` are measured; the aggregate + disk-bound regime is extrapolated. + +## Default policy: `auto` + +Implemented in `examples/pkg_resolver/store/ensure_lmdb.sh` +(`uw_resolve_store_backend`), wired into `cpp_store/build.sh` as the default when +`UW_STORE_BACKEND` is unset. Test: `store/test_auto_select.sh`. + +``` +choose LMDB iff store_size_bytes > UW_STORE_LMDB_RAM_FACTOR × available_RAM_bytes + AND lmdb is usable (uw_ensure_lmdb succeeds) +else INDEXED +``` + +- `UW_STORE_LMDB_RAM_FACTOR` — the headroom factor, **default 2** (named constant, + tunable). 2× is deliberately conservative: the model puts the *onset* at ~1× + RAM and skew pushes it higher, so 2× only trips lmdb once the store clearly + exceeds RAM and disk-bound misses are unavoidable. +- `available_RAM` — `/proc/meminfo` `MemAvailable`, overridable with + `UW_STORE_AVAIL_RAM_BYTES` (WSL2's `MemAvailable` balloons, so the override + matters for tests/reproducibility). +- `store_size` — the **built** indexed store (`*.data` + `*.idx`) when present, + else the source **P/2 JSONL** (`*.jsonl`, excluding `cases.jsonl`) as a + pre-build estimate. One consistent measure; documented here. +- **Fallback:** if the size rule wants lmdb but lmdb is not usable + (`uw_ensure_lmdb` fails / `MDB_INVALID`), it **WARNs loudly and uses indexed** — + safe because indexed is answer-identical and ~as fast up to the disk-bound + multi-row regime. It prints the chosen backend and the size-vs-`2×RAM` numbers. +- **Policy only, never answers.** Whichever backend is chosen returns identical + rows. Proven: 503-case store differential + 51-case corpus stay **0 + divergences** (corpus verified through the auto path → indexed), and + `test_auto_select.sh` checks the rule returns lmdb above 2× and indexed below + (via `UW_STORE_AVAIL_RAM_BYTES`). + +## Future refinement (deferred, per the owner) + +The size-only rule is conservative but coarse. Two cheap improvements, explicitly +deferred: + +1. **Fold in rows_per_key.** The asymptotic benefit is ≈ rows_per_key, so for + ~1-row-per-key stores lmdb is *never* worth it even above 2× RAM. The ABI + `symprov` store is **1.03 rows/key** (249,097 keys / 256,225 rows) — cheaply + computable from the `.idx` header (keys vs records) with no scan. A refined + selector should require `rows_per_key ≳ 2` in addition to the size trigger. +2. **Key-sort the indexed `.data`.** Indexed's only structural disadvantage is + source-order scatter (M scattered pages per multi-row key). Building `.data` + in **key order** clusters a key's rows into ~1 page (measured: an 8-row/key + store drops from 8 to **1.05** pages/key), erasing lmdb's disk-bound edge + **without the external dependency**. This would make indexed competitive even + in the multi-row disk-bound regime, shrinking `auto`'s lmdb branch further. + +## Honesty + +- Resident costs (`t_hit`, `t_miss_resident`, the ~1.0× pure-miss parity) and the + per-page cold latency `t_seek` are **measured**. The **scatter** (indexed + cold-reads/miss = rows_per_key) is measured exactly from `.idx` offsets. +- The **aggregate disk-bound regime is modeled/extrapolated** from those + primitives, **not stress-tested**: this WSL2 box has no fair memory-cap + mechanism (no cgroup `memory.max` unprivileged; capping the app cache is unfair + because lmdb still gets RAM via mmap/page cache; `MemAvailable` balloons). So the + crossover magnitude and the `K` table are estimates grounded in measured + primitives, and the `2×` factor is a conservative engineering choice, not a + stress-tested optimum. diff --git a/examples/pkg_resolver/abi/bench/RESULTS.md b/examples/pkg_resolver/abi/bench/RESULTS.md new file mode 100644 index 000000000..1bad66b2d --- /dev/null +++ b/examples/pkg_resolver/abi/bench/RESULTS.md @@ -0,0 +1,250 @@ +# ABI store backend crossover — neutralizing the cache: indexed+L1/L2 vs lmdb + +> Data appendix. The decision doc (theory + policy + the `auto` default) is +> [`BACKEND_SELECTION.md`](BACKEND_SELECTION.md). + +Story so far: +1. **First run:** lmdb beat `indexed` by 13-72x — but mostly because the on-disk + binary search re-read every probe (~37 `ifstream` syscalls/lookup). +2. **Fair fight:** optimized the indexed read path (whole `.idx` slurped into RAM + once, in-memory binary search + one `.data` record read). Gap fell to ~1.6-2.2x + on zero-reuse but stayed 7-21x on reuse — because lmdb still had an L1/L2 **row + cache** and indexed had none. +3. **Cache lift:** lifted the L1/L2 row cache into the shared `SeekFactSource` so + BOTH backends cache identically. Caching neutralized: indexed+cache matched + lmdb within ~1.1-1.4x on reuse, leaving only a ~1.9-2.7x edge on pure-miss. +4. **This run:** **(a)** close the pure-miss edge — `mmap` the `.data` so a miss + reads each record in-place (one page access like lmdb) instead of two + positioned reads; **(b)** build a calibrated **cost model** to estimate when + lmdb is worth it (store/RAM ratio × rows-per-key), since real memory pressure + is not creatable on this box. + +**TL;DR of this run:** the pure-miss ~2x **closed to ~1.0x** (mmap). The only +regime where lmdb still wins is **disk-bound + multi-row-per-key**, and the win +is ≈ rows_per_key; for the ABI ~1-row/key store lmdb is **never** worth it at any +store/RAM ratio. + +Harness + entry script: `examples/pkg_resolver/abi/bench/` (drives the C++ WAM +`SeekFactSource` read path directly — not the JS lmdb backend). +Reproduce: `BUILD_OLD=1 bash examples/pkg_resolver/abi/bench/bench_crossover.sh`. + +## The change (shared cpp_wam runtime) + +`templates/targets/cpp_wam/runtime.h.mustache`: the L1 (direct-mapped) + L2 +(FIFO) row cache — key → decoded row list — was **lifted out of the +`WAM_CPP_ENABLE_LMDB` gate** into an engine-agnostic cache in `rows()`, used by +both backends. `rows()` now does: open → `ensure_cache_config()` → L1 probe → L2 +probe (promote on hit) → on miss, backend `fetch_keyed()` (indexed: +`lookup_offsets`+`read_record`; lmdb: `lmdb_range_scan`) → fill both tiers. Full +(unbound-arg1) scans are never cached. Shared sizing env `UW_WAM_FACT_L1_SLOTS` / +`UW_WAM_FACT_L2_CAP` (the `UW_WAM_LMDB_*` names still honored for back-compat). +Cache is orthogonal to storage, so this is a pure code move + one branch. + +## Store sizes + +| scale | indexed | lmdb (v1) | rows | distinct keys | +|---|---|---|---|---| +| **symbol** (ABI `symprov/2`, `/var/lib/dpkg/info`) | 42 MB (24 data + 18 idx) | 128 MB | 256,225 | 249,097 | +| **package** (`gen_scale_catalog` 5k, `pkg/2`) | 320 KB (164 + 156 KB) | 972 KB | 7,522 | 5,007 | + +Benchmark cache sizing: `UW_WAM_FACT_L2_CAP=65536`, L1 default (1<<14 slots) — +identical for indexed+cache and lmdb (fair). + +## Correctness (all guardrails pass — a cache must not change answers) + +1. **Cross-check:** indexed+cache `rows_found` **==** lmdb `rows_found` **==** + nocache, every cell, both scales. And indexed+cache reports **identical + L1/L2/miss counts to lmdb** (e.g. symbol skewed R=10 = 283,810 L1 / 195,733 L2 + / 20,457 miss for both) — proof the shared cache behaves identically per + backend. +2. **Resolver differential/corpus/ABI** (built at `-O0` under memory pressure, + one at a time): `run_differential_cpp_store.sh` = **503 / 0 divergences**; + `run_corpus_cpp_store.sh` = **51 / 0**; `run_abi_verify.sh` = **122 / 0**. +3. **Byte-frozen goldens** re-baselined (plain 91891→92370, lmdb 92132→92611; + +479 each, gate-independent). Suite green. Runtime-source golden unchanged. + +Frozen `resolver.pl` / `resolver_store.pl` / `debian/` untouched (`git diff` clean). + +## Results (min-of-3 wall; WSL2 noisy — spreads in raw jsonl) + +| scale | workload | cache | R | idx-nocache | **idx+cache** | lmdb | cache vs nocache | lmdb vs cache | +|---|---|---|---|---|---|---|---|---| +| package | skewed | warm | 1 | 122 | **19** | 10 | 6.6x | 1.9x | +| package | skewed | warm | 5 | 592 | **40** | 30 | 14.7x | 1.3x | +| package | skewed | warm | 10 | 1167 | **65** | 57 | 18.1x | 1.1x | +| package | uniform | warm | 10 | 1131 | **74** | 62 | 15.3x | 1.2x | +| package | unique | warm | 1 | 12 | **15** | 5 | 0.8x | 2.7x | +| package | unique | warm | 10 | 126 | **21** | 11 | 6.1x | 1.9x | +| symbol | skewed | warm | 1 | 181 | **121** | 62 | 1.5x | 2.0x | +| symbol | skewed | warm | 5 | 811 | **179** | 126 | 4.5x | 1.4x | +| symbol | skewed | warm | 10 | 1706 | **250** | 213 | 6.8x | 1.2x | +| symbol | skewed | cold | 10 | 1748 | **325** | 303 | 5.4x | 1.1x | +| symbol | uniform | warm | 1 | 165 | **198** | 96 | 0.8x | 2.1x | +| symbol | uniform | warm | 10 | 1591 | **353** | 254 | 4.5x | 1.4x | +| symbol | unique | warm | 1 | 950 | **1284** | 586 | 0.7x | 2.2x | +| symbol | unique | cold | 1 | 1196 | **1533** | 685 | 0.8x | 2.2x | + +(Full warm+cold sweep, all R, both scales: `.out/bench/results.symbol.jsonl` + +`results.package.jsonl`. Cold ≈ warm — stores ≪ RAM, no hard cap available +unprivileged on WSL2, so no disk-bound regime; deterministic counters lead.) + +### Deterministic I/O (warm; identical across repeats and warm/cold) + +| scale | workload | R | nocache reads | cache reads | lmdb reads | L1 (cache=lmdb) | L2 (cache=lmdb) | miss | rows | +|---|---|---|---|---|---|---|---|---|---| +| package | skewed | 1 | 142,486 | 12,478 | 6,238 | 45,052 | 981 | 3,967 | 71,242 | +| package | skewed | 10 | 1,424,842 | 12,478 | 6,238 | 480,499 | 15,534 | 3,967 | 712,420 | +| package | unique | 10 | 150,442 | 15,046 | 7,522 | 33,597 | 11,466 | 5,007 | 75,220 | +| symbol | skewed | 1 | 132,190 | 52,910 | 26,454 | 21,280 | 8,263 | 20,457 | 66,094 | +| symbol | skewed | 10 | 1,321,882 | 52,910 | 26,454 | 283,810 | 195,733 | 20,457 | 660,940 | +| symbol | uniform | 10 | 898,482 | 82,208 | 41,103 | 111,415 | 348,829 | 39,756 | 449,240 | +| symbol | unique | 1 | 540,964 | 528,032 | 264,015 | 867 | 2,366 | 252,992 | 270,481 | + +Note: with the cache, indexed's read count is **flat in R** (52,910 at every R, +like lmdb's 26,454) — the linear-in-R re-reads are gone. The residual ~2x reads +vs lmdb is that indexed's `read_record` does two positioned reads per record (len +prefix + payload) where lmdb's mmap cursor returns the value in one op. + +## Answer: does indexed+cache now match lmdb? + +**On reuse — yes, essentially.** The cache neutralized the 7-21x reuse gap: +indexed+cache is now within **1.1-1.4x** of lmdb at moderate/high reuse +(skewed/uniform R≥5, both scales; e.g. symbol skewed R=10 250 ms vs 213 ms = +1.2x; package skewed R=10 65 ms vs 57 ms = 1.1x). The huge wins were **entirely +the cache** — confirmed, because indexed+cache and lmdb now post identical +L1/L2/miss counts and their walls converge. The dependency-free backend gets the +same reuse win. + +**On zero-reuse (pure miss) — lmdb keeps a ~1.9-2.7x edge.** This is the true +engine difference: mmap single-value fetch vs indexed's two positioned reads per +record (`fact_io` shows cache-indexed does ~2x the reads of lmdb on misses). And +because the cache can't help a pure-miss stream, it adds small overhead there — +`unique` R=1 is the one place indexed+cache is *slower than* indexed-nocache +(symbol 1284 vs 950 ms; package 15 vs 12 ms). So the cache is a clear win wherever +there is any reuse and a slight tax on pure-miss. + +**Same conclusion at both scales.** Package (5k, the default's real domain) and +symbol (256k) agree: cache≈lmdb on reuse, lmdb ~2x on pure-miss. Package looks +more lopsided in the nocache column only because its small keyset means higher +reuse. + +## Follow-up: mmap the .data — the resident pure-miss ~2x closes to ~1.0x + +The pure-miss gap was `read_record` doing **two** positioned reads per record +(4-byte length prefix, then payload). Change: `mmap` the `.data` file and read +each record **in place** — one page access, no read syscall, exact byte count, +just like lmdb's mmap value fetch (POSIX; ifstream two-read path kept as a +fallback). Deterministic proof: on symbol `unique` R=2 the indexed read count +dropped to **512,452 ≈ lmdb 512,450** (was ~2x), and `rows_found` stays identical +(cross-check passes: indexed == lmdb == nocache, every cell). + +Zero-reuse wall, symbol `unique` R=1 (min-of-5), before vs after the mmap: + +| variant | warm indexed | warm lmdb | ratio | cold indexed | cold lmdb | ratio | +|---|---|---|---|---|---|---| +| before (two reads) | 1284 ms | 586 ms | 2.2x | 1533 ms | 685 ms | 2.2x | +| **after (mmap)** | **593 ms** | 591 ms | **1.00x** | **624 ms** | 676 ms | **0.92x** | + +So on **resident** access the engines are now equal — indexed matches lmdb on +pure-miss (and is marginally faster cold, being a flat file vs a B-tree). The mmap +also verified the resident multi-row case: an 8-rows/key store, `unique` warm, +indexed 79 ms vs lmdb 83 ms — scatter is free when resident. + +## Backend-selection cost model (calibrated estimate) + +We cannot create fair memory pressure on this WSL2 box (no cgroup `memory.max`; +capping the app cache is unfair because lmdb still gets RAM via mmap/page cache). +So we **measure primitives without pressure and extrapolate** the disk-bound +regime. Resident numbers and the per-page cold latency are **measured**; the +aggregate disk-bound regime is **modeled**, not stress-tested — labeled as such. + +**Measured primitives (this WSL2 host; `cost_model.sh` / `cost_model_probe.c`):** + +| primitive | value | how | +|---|---|---| +| `t_seek` — cold single 4KB page read | **~173-180 µs** (median; p10-p90 ≈ 150-225) | `posix_fadvise(DONTNEED)` a page, `pread`, ×3000 | +| `t_mem` — warm single 4KB page read | **~0.5 µs** | same pages, resident | +| `t_seek / t_mem` | **~340-350x** | — | +| `t_hit` — cache-hit lookup | indexed ~0.3-0.6 µs, lmdb ~0.15 µs | 50k lookups over 100 hot keys | +| `t_miss_resident` — warm zero-reuse lookup | ~2.4-2.6 µs (indexed ≈ lmdb after mmap) | `unique` R=1 warm | +| **scatter** — indexed cold reads per miss | **= rows_per_key** (measured exact) | distinct 4KB `.data` pages a key's records span, from `.idx` | +| lmdb cold reads per miss | **≈ 1** (clustered leaf) | structural (key-ordered B-tree) | + +The scatter is the crux and is **measured exactly** from the `.idx` offsets: +indexed stores records in **source order**, so with realistic key-interleaved +input a key's N rows land on **N distinct pages** (measured: ABI 1.03 rows/key → +1.03 pages/key; synthetic interleaved 2/4/8/16 rows/key → 2/4/8/16 pages/key). +lmdb keys its B-tree by key, so a key's rows cluster in ~1 leaf. **But** building +the indexed `.data` in **key order** clusters it too — measured: an 8-row/key +grouped store spans **1.05 pages/key**, erasing the scatter entirely. + +**Model.** With hit rate `h`, rows_per_key `M`, store/RAM ratio `r ≥ 1`, and +`P(evicted for a miss) ≈ 1 − 1/r`: + +``` +T(backend) = h·t_hit + (1−h)·cold_reads·[ (1/r)·t_mem + (1−1/r)·t_seek ] + indexed: cold_reads = M (scattered) ; lmdb: cold_reads ≈ 1 +``` + +Solving `T_lmdb < T_indexed` for the store/RAM ratio `K` (hit_rate 0.9, measured +`t_seek`/`t_mem`): + +| rows_per_key | lmdb speedup at store ≫ RAM | K@1.2x | K@1.5x | K@2x | +|---|---|---|---|---| +| **1 (ABI)** | **1.0x** | never | never | never | +| 2 | 2.0x | 1.0 | 1.05 | never | +| 4 | 4.0x | 1.0 | 1.0 | 1.05 | +| 8 | 7.9x | 1.0 | 1.0 | 1.0 | +| 16 | 15.8x | 1.0 | 1.0 | 1.0 | + +**Reading of the model.** The store/RAM ratio is a **threshold**, not a knob: +`K ≈ 1` — a benefit appears only once the store **exceeds RAM** (so misses hit +disk); below RAM everything is resident and indexed ≈ lmdb (measured 1.0x). The +**magnitude** of the benefit is set by **rows_per_key**: under pressure lmdb is +≈ `rows_per_key` times faster on the miss path (1 clustered leaf vs N scattered +`t_seek`s). For `rows_per_key = 1` the ratio is 1.0x at **every** store/RAM ratio +— **lmdb is never worth it**. + +**Practical rule:** *Use lmdb only when BOTH (a) the working set exceeds RAM +(store > ~1× RAM, so misses go to disk) AND (b) rows_per_key ≳ 2 with +key-interleaved `.data`; the expected miss-path speedup is ≈ rows_per_key.* For +the ABI symbol store (~1.03 rows/key) neither the ratio nor the scatter ever +favors lmdb. And even for multi-row stores, **sorting the indexed `.data` by key +at build time** clusters the rows (measured ~1 page/key) and removes lmdb's edge +without the dependency. + +## Final default recommendation + +**Make optimized + cached indexed the universal default.** The numbers support it: + +- **Dependency-free** (no `lmdb` npm, no system `liblmdb`, no Symas-vs-vanilla v1 + format dance), **3x smaller on disk**, works out of the box. +- **Matches lmdb on reuse** (within ~1.1-1.4x) — and real ABI/package resolution + is reuse-heavy (a few hot libraries/packages touched constantly), exactly where + the cache wins. At package scale both are effectively instant (sub-100 ms for + 50k lookups). +- **Matches lmdb on resident pure-miss too** now (~1.0x) after the `.data` mmap — + the old ~2x is gone. +- Per the cost model, the **only** regime where lmdb still wins is **disk-bound + (store > RAM) AND multi-row-per-key with key-interleaved data**, where it is + ≈ rows_per_key faster on misses. The ABI store (~1 row/key) never enters that + regime; and sorting the indexed `.data` by key would close it even for + multi-row stores. + +**Keep lmdb opt-in only for that narrow case** — a store larger than RAM with +several rows per key, queried at high volume with a cold working set — where the +external dependency buys ≈ rows_per_key on the miss path. For everything the +resolver actually runs (ABI ~1 row/key; reuse-heavy access; working sets that fit +RAM), dependency-free optimized+cached+mmap indexed is the better default. + +## Honesty caveats + +- No hard memory cap available unprivileged on this WSL2 host (no systemd user + bus / cgroup delegation / root); stores ≪ RAM, so fadvise-cold ≈ warm and a + disk-bound regime is unreachable. Deterministic read/cache counters lead; they + are exact and reproducible. +- Wall is min-of-3 and noisy on WSL2 (spreads in the raw jsonl; a couple of cold + cells show wide tails). The ratios are robust to the noise. +- EXPERIMENT branch. The shared-runtime cache lift is answer-identical and + golden-rebaselined here, but touches every cpp_wam indexed-store consumer, so it + needs its own PR/review if kept. diff --git a/examples/pkg_resolver/abi/bench/bench_crossover.sh b/examples/pkg_resolver/abi/bench/bench_crossover.sh new file mode 100755 index 000000000..ae1ec6195 --- /dev/null +++ b/examples/pkg_resolver/abi/bench/bench_crossover.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT OR Apache-2.0 +# Copyright (c) 2026 John William Creighton (@s243a) +# +# bench_crossover.sh -- reproducible entry point for the lmdb-vs-indexed store +# backend "fair fight" on the UnifyWeaver ABI store, at TWO scales: +# symbol scale -- the 256k-row ABI symprov/2 store (idx ~42MB, lmdb ~128MB) +# package scale -- the ~5k-key gen_scale_catalog pkg/2 store (idx ~320KB) +# +# It drives the C++ WAM SeekFactSource read path DIRECTLY: +# indexed = on-disk UWFI/UWIX; the OPTIMIZED path slurps the whole .idx into +# RAM once and binary-searches it in memory (+1 .data read/record). +# lmdb = the C++ lazy reader with L1 direct-mapped + L2 FIFO row caches. +# NOT the JS/wamjs lmdb backend. See RESULTS.md for the finding. +# +# Binaries (all use the real SeekFactSource; store path via argv, so one binary +# per gate serves any store/scale): +# bench_indexed optimized indexed (current runtime template) +# bench_lmdb lmdb (unchanged path) +# bench_indexed_old BUILD_OLD=1 only: pre-optimization indexed, built from the +# committed (HEAD) runtime template via a save/restore swap, +# for the OLD-vs-OPTIMIZED comparison in RESULTS.md. +# +# Memory pressure: no hard cap is available unprivileged on this WSL2 host +# (systemd-run --user: no bus; system scope: interactive auth; cgroup v2: not +# delegated; no root). The cold variant evicts the store per-run via +# posix_fadvise(DONTNEED) (UW_BENCH_EVICT=1); stores << RAM so cold ~= warm. +# +# Env knobs: N_REPEAT (3), R_LIST ("1 5 10"), WORKLOADS ("skewed uniform unique"), +# L2_CAP (65536), WL_N/WL_HOT/WL_MISS/WL_SEED, SCALES ("symbol package"), +# BUILD_OLD (0). + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../../.." && pwd)" +OUT="$ROOT/examples/pkg_resolver/abi/.out/bench" # symbol scale store + binaries +SCALE_DIR="$ROOT/examples/pkg_resolver/store/.out/scale" # package scale store +PB="$OUT/pkg" # package workloads + lmdb +TPL="$ROOT/templates/targets/cpp_wam/runtime.h.mustache" + +export LANG="${LANG:-C.UTF-8}" LC_ALL="${LC_ALL:-C.UTF-8}" +N_REPEAT="${N_REPEAT:-3}"; R_LIST="${R_LIST:-1 5 10}" +WORKLOADS="${WORKLOADS:-skewed uniform unique}" +L2_CAP="${L2_CAP:-65536}"; SCALES="${SCALES:-symbol package}" +BUILD_OLD="${BUILD_OLD:-0}" +export UW_WAM_LMDB_L2_CAP="$L2_CAP" +cd "$ROOT" + +mkdir -p "$OUT/idx" "$OUT/sym" "$PB" + +# ---------- symbol-scale store ---------- +if [ ! -f "$OUT/symprov.p2.jsonl" ]; then + echo "== ingest ABI symbols -> pack (#-join) ==" + node "$ROOT/examples/pkg_resolver/abi/ingest_symbols.mjs" symbols-dir /var/lib/dpkg/info --out "$OUT/sym" + node -e ' +const fs=require("fs");const rl=require("readline").createInterface({input:fs.createReadStream(process.argv[1])}); +const out=fs.createWriteStream(process.argv[2]); +rl.on("line",l=>{const t=l.trim();if(!t)return;const a=JSON.parse(t);const v=Array.isArray(a[1])?a[1].join("#"):a[1];out.write(JSON.stringify([a[0],v])+"\n");}); +rl.on("close",()=>out.end());' "$OUT/sym/symprov.jsonl" "$OUT/symprov.p2.jsonl" +fi +[ -f "$OUT/idx/symprov.data" ] || node "$ROOT/scripts/js_wam/uw_fact_index.js" build "$OUT/symprov.p2.jsonl" "$OUT/idx/symprov" +if [ ! -f "$OUT/lmdb_v1/symprov/data.mdb" ]; then + export UW_LMDB_DATA_V1=1 + # shellcheck source=../store/ensure_lmdb.sh + source "$ROOT/examples/pkg_resolver/store/ensure_lmdb.sh"; uw_require_lmdb + mkdir -p "$OUT/lmdb_v1" + node "$ROOT/scripts/js_wam/uw_fact_lmdb.js" build "$OUT/symprov.p2.jsonl" "$OUT/lmdb_v1/symprov" +fi + +# ---------- package-scale store ---------- +if [ ! -f "$SCALE_DIR/pkg.data" ]; then + echo "== build 5k package-scale store ==" + mkdir -p "$SCALE_DIR" + node "$ROOT/examples/pkg_resolver/store/gen_scale_catalog.mjs" "$SCALE_DIR" + [ -f "$SCALE_DIR/pkg.jsonl" ] || node "$ROOT/examples/pkg_resolver/store/rich_to_p2.mjs" "$SCALE_DIR/rich.jsonl" "$SCALE_DIR" + bash "$ROOT/examples/pkg_resolver/store/build_stores.sh" "$SCALE_DIR" +fi +if [ ! -f "$PB/lmdb_pkg/data.mdb" ]; then + export UW_LMDB_DATA_V1=1 + source "$ROOT/examples/pkg_resolver/store/ensure_lmdb.sh"; uw_require_lmdb + node "$ROOT/scripts/js_wam/uw_fact_lmdb.js" build "$SCALE_DIR/pkg.jsonl" "$PB/lmdb_pkg" +fi + +# ---------- workloads ---------- +gen_unique() { # jsonl out + node -e ' +const fs=require("fs");const rl=require("readline").createInterface({input:fs.createReadStream(process.argv[1])}); +const seen=new Set();const keys=[];rl.on("line",l=>{const t=l.trim();if(!t)return;try{const k=JSON.parse(t)[0];if(!seen.has(k)){seen.add(k);keys.push(k);}}catch{}}); +rl.on("close",()=>{let s=987654321>>>0;const rnd=()=>{s=(Math.imul(1664525,s)+1013904223)>>>0;return s/4294967296;}; +for(let i=keys.length-1;i>0;i--){const j=Math.floor(rnd()*(i+1));[keys[i],keys[j]]=[keys[j],keys[i]];} +fs.writeFileSync(process.argv[2],keys.join("\n")+"\n");});' "$1" "$2" +} +if [ ! -f "$OUT/wl.skewed.keys" ]; then + node "$HERE/gen_workload.mjs" "$OUT/symprov.p2.jsonl" "$OUT/wl" "${WL_N:-50000}" "${WL_HOT:-0.80}" "${WL_MISS:-0.15}" "${WL_SEED:-1234567}" >/dev/null + gen_unique "$OUT/symprov.p2.jsonl" "$OUT/wl.unique.keys" +fi +if [ ! -f "$PB/wl.skewed.keys" ]; then + node "$HERE/gen_workload.mjs" "$SCALE_DIR/pkg.jsonl" "$PB/wl" "${WL_N:-50000}" "${WL_HOT:-0.80}" "${WL_MISS:-0.15}" "${WL_SEED:-1234567}" >/dev/null + gen_unique "$SCALE_DIR/pkg.jsonl" "$PB/wl.unique.keys" +fi + +# ---------- binaries ---------- +CXX="${CXX:-g++}"; CXXFLAGS="${CXXFLAGS:--std=c++17 -O2}" +echo "== codegen + g++ (optimized indexed | lmdb) ==" +swipl -q -g main -t halt "$HERE/build.pl" -- "$OUT/proj_indexed" "$OUT/idx/symprov" indexed >/dev/null 2>&1 +swipl -q -g main -t halt "$HERE/build.pl" -- "$OUT/proj_lmdb" "$OUT/lmdb_v1/symprov" lmdb >/dev/null 2>&1 +$CXX $CXXFLAGS -I"$OUT/proj_indexed/cpp" -o "$OUT/bench_indexed" "$HERE/bench_main.cpp" +$CXX $CXXFLAGS -I"$OUT/proj_lmdb/cpp" -o "$OUT/bench_lmdb" "$HERE/bench_main.cpp" -llmdb +if [ "$BUILD_OLD" = "1" ]; then + echo "== codegen + g++ (OLD indexed from HEAD template) ==" + cp "$TPL" "$OUT/.runtime.h.CURRENT" + restore_tpl() { cp "$OUT/.runtime.h.CURRENT" "$TPL"; } + trap restore_tpl EXIT + git -C "$ROOT" show HEAD:templates/targets/cpp_wam/runtime.h.mustache > "$TPL" + swipl -q -g main -t halt "$HERE/build.pl" -- "$OUT/proj_indexed_old" "$OUT/idx/symprov" indexed >/dev/null 2>&1 + $CXX $CXXFLAGS -I"$OUT/proj_indexed_old/cpp" -o "$OUT/bench_indexed_old" "$HERE/bench_main.cpp" + restore_tpl; trap - EXIT +fi + +# ---------- sweep ---------- +run_cell() { # results label backend bin store wldir wl R evict + local results="$1" label="$2" backend="$3" bin="$4" store="$5" wldir="$6" wl="$7" R="$8" ev="$9" + local keys="$wldir/wl.$wl.keys" samples="" + for _ in $(seq 1 "$N_REPEAT"); do + samples+="$(UW_BENCH_EVICT="$ev" "$bin" "$backend" "$store" "$keys" "$R")"$'\n' + done + printf '%s' "$samples" | node -e ' +let raw="";process.stdin.on("data",d=>raw+=d).on("end",()=>{ + const rows=raw.split("\n").filter(x=>x.trim()).map(JSON.parse); + const w=rows.map(r=>r.wall_ms).sort((a,b)=>a-b); const o=rows[0]; + o.scale=process.argv[1]; o.label=process.argv[2]; o.workload=process.argv[3]; + o.min_wall_ms=w[0]; o.max_wall_ms=w[w.length-1]; o.n_repeat=w.length; delete o.wall_ms; + console.log(JSON.stringify(o));});' "$SCALE" "$label" "$wl" >> "$results" + tail -1 "$results" | node -e 'let r="";process.stdin.on("data",d=>r+=d).on("end",()=>{const o=JSON.parse(r); +console.log(` ${o.scale.padEnd(7)} ${o.workload.padEnd(8)} ${o.label.padEnd(13)} R=${String(o.R).padEnd(3)} ev=${o.evict} reads=${String(o.fact_io_reads).padStart(9)} l1=${String(o.l1_hits).padStart(7)} l2=${String(o.l2_hits).padStart(7)} miss=${String(o.cache_misses).padStart(7)} rows=${String(o.rows_found).padStart(7)} min_wall=${o.min_wall_ms.toFixed(1)}ms [${o.min_wall_ms.toFixed(0)}-${o.max_wall_ms.toFixed(0)}]`);});' +} + +for SCALE in $SCALES; do + if [ "$SCALE" = "symbol" ]; then IDX="$OUT/idx/symprov"; LMDB="$OUT/lmdb_v1/symprov"; WLDIR="$OUT"; + else IDX="$SCALE_DIR/pkg"; LMDB="$PB/lmdb_pkg"; WLDIR="$PB"; fi + RES="$OUT/results.$SCALE.jsonl"; : > "$RES" + echo "== sweep: $SCALE scale ==" + for wl in $WORKLOADS; do for R in $R_LIST; do for ev in 0 1; do + [ "$BUILD_OLD" = "1" ] && run_cell "$RES" indexed-old indexed "$OUT/bench_indexed_old" "$IDX" "$WLDIR" "$wl" "$R" "$ev" + run_cell "$RES" indexed-opt indexed "$OUT/bench_indexed" "$IDX" "$WLDIR" "$wl" "$R" "$ev" + run_cell "$RES" lmdb lmdb "$OUT/bench_lmdb" "$LMDB" "$WLDIR" "$wl" "$R" "$ev" + done; done; done + echo "== wrote $RES ==" +done +echo "store sizes:"; du -h "$OUT/idx/symprov.data" "$OUT/idx/symprov.idx" "$OUT/lmdb_v1/symprov/data.mdb" \ + "$SCALE_DIR/pkg.data" "$SCALE_DIR/pkg.idx" "$PB/lmdb_pkg/data.mdb" 2>/dev/null | sed 's/^/ /' diff --git a/examples/pkg_resolver/abi/bench/bench_main.cpp b/examples/pkg_resolver/abi/bench/bench_main.cpp new file mode 100644 index 000000000..9422427ea --- /dev/null +++ b/examples/pkg_resolver/abi/bench/bench_main.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2026 John William Creighton (@s243a) +// +// bench_main.cpp -- minimal raw-LOOKUP harness for the ABI store crossover +// benchmark. It drives the C++ WAM SeekFactSource read path DIRECTLY (the same +// wam_cpp::SeekFactSource that the compiled resolver dispatches through for a +// store fact source), NOT the JS/wamjs lmdb backend. The whole point of the +// measurement is the C++ L1 (direct-mapped) + L2 (FIFO) app caches that only +// this backend has; the indexed backend has none and leans on the OS page +// cache, so under memory pressure it does real disk seeks. +// +// It reads a list of query keys from a file, looks each up REPEATED R times in +// ONE process, and prints the D43 deterministic I/O stats (bytes read, read +// count, per-source L1/L2 hits + cache misses) plus wall time as a single JSON +// line on stdout. +// +// Usage: +// bench_main +// store-path: indexed -> the UWFI/UWIX prefix (Prefix.data + Prefix.idx); +// lmdb -> the LMDB env directory (data.mdb + lock.mdb). +// Cache sizing (lmdb only) via env: UW_WAM_LMDB_L1_SLOTS, UW_WAM_LMDB_L2_CAP. + +#include "wam_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace wam_cpp; + +static const char* env_or(const char* name, const char* dflt) { + const char* v = std::getenv(name); + return (v && *v) ? v : dflt; +} + +// Evict one file's pages from the OS page cache (no root needed): +// posix_fadvise(DONTNEED) drops the clean cached pages for the inode, so the +// next read faults from disk. This is the root-free "store not resident under +// memory pressure" proxy used when systemd/cgroup hard caps are unavailable. +static void evict_file(const std::string& p) { + int fd = ::open(p.c_str(), O_RDONLY); + if (fd < 0) return; + struct stat st{}; + if (::fstat(fd, &st) == 0 && st.st_size > 0) { + // Read through it once to ensure it is cached, then drop it, so DONTNEED + // has resident pages to evict (DONTNEED is a no-op on non-resident pages). + ::posix_fadvise(fd, 0, st.st_size, POSIX_FADV_DONTNEED); + } + ::close(fd); +} + +// Evict the store files backing this run so a cold-cache wall time can be +// measured. Enabled by UW_BENCH_EVICT=1. +static void evict_store(const std::string& kind, const std::string& path) { + if (kind == "indexed") { + evict_file(path + ".data"); + evict_file(path + ".idx"); + } else { + evict_file(path + "/data.mdb"); + } +} + +int main(int argc, char** argv) { + if (argc < 5) { + std::fprintf(stderr, + "usage: %s \n", argv[0]); + return 2; + } + const std::string kind = argv[1]; + const std::string path = argv[2]; + const std::string keysFile = argv[3]; + const int R = std::atoi(argv[4]); + + // Load query keys. + std::vector keys; + { + std::ifstream in(keysFile); + if (!in.is_open()) { std::fprintf(stderr, "cannot open keys file %s\n", keysFile.c_str()); return 1; } + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty()) keys.push_back(line); + } + } + if (keys.empty()) { std::fprintf(stderr, "no keys loaded\n"); return 1; } + + const bool evict = std::string(env_or("UW_BENCH_EVICT", "0")) == "1"; + if (evict) evict_store(kind, path); + + SeekFactSource src(kind, path); + reset_fact_io(); + + std::uint64_t rowsFound = 0; + std::uint64_t lookups = 0; + auto t0 = std::chrono::steady_clock::now(); + for (int r = 0; r < R; ++r) { + for (const std::string& k : keys) { + // Mirror WamState::dispatch_foreign_call: a bound atomic A1 is encoded via + // encode_store_key (0x41 atom tag + utf8) before the keyed seek. + auto rows = src.rows(std::optional(encode_store_key(Value::Atom(k)))); + rowsFound += rows.size(); + ++lookups; + } + } + auto t1 = std::chrono::steady_clock::now(); + double wall_ms = std::chrono::duration(t1 - t0).count(); + + std::printf( + "{\"kind\":\"%s\",\"R\":%d,\"nkeys\":%zu,\"lookups\":%llu,\"rows_found\":%llu," + "\"fact_io_bytes\":%llu,\"fact_io_reads\":%llu,\"fact_io_data_size\":%llu," + "\"l1_hits\":%llu,\"l2_hits\":%llu,\"cache_misses\":%llu," + "\"l1_slots\":\"%s\",\"l2_cap\":\"%s\",\"evict\":%d,\"wall_ms\":%.3f}\n", + kind.c_str(), R, keys.size(), + (unsigned long long)lookups, (unsigned long long)rowsFound, + (unsigned long long)fact_io_bytes(), (unsigned long long)fact_io_reads(), + (unsigned long long)fact_io_data_size(), + (unsigned long long)src.l1_hits(), (unsigned long long)src.l2_hits(), + (unsigned long long)src.cache_misses(), + env_or("UW_WAM_LMDB_L1_SLOTS", "default"), + env_or("UW_WAM_LMDB_L2_CAP", "default"), + evict ? 1 : 0, + wall_ms); + return 0; +} diff --git a/examples/pkg_resolver/abi/bench/build.pl b/examples/pkg_resolver/abi/bench/build.pl new file mode 100644 index 000000000..68d3dcb9a --- /dev/null +++ b/examples/pkg_resolver/abi/bench/build.pl @@ -0,0 +1,64 @@ +:- encoding(utf8). +% SPDX-License-Identifier: MIT OR Apache-2.0 +% Copyright (c) 2026 John William Creighton (@s243a) +% +% build.pl -- generate a minimal C++ WAM project whose ONLY purpose is to +% materialise the store-backed seek runtime (wam_runtime.h / wam_runtime.cpp) +% for the ABI symprov/2 store, so bench_main.cpp can drive the C++ +% SeekFactSource read path directly. +% +% The trivial program is: +% sym_lookup(K, V) :- symprov(K, V). +% with symprov/2 declared as a D43 store fact source. sym_lookup/2 is not used +% by the benchmark (bench_main.cpp calls wam_cpp::SeekFactSource::rows() +% directly) but it makes the generated project a well-formed lookup program and +% pins symprov/2 as a fact-source predicate so the seek runtime is emitted. +% +% Declaration-level backend switch (4th argv, mirrors cpp_store/build.pl): +% indexed -- source(symprov/2, indexed(Prefix)) Prefix.data + Prefix.idx +% lmdb -- source(symprov/2, lmdb(Dir)) data.mdb + lock.mdb +% The lmdb build auto-#defines WAM_CPP_ENABLE_LMDB in the generated header +% (compiled with -llmdb); the L1 direct-mapped + L2 FIFO caches live in that +% code path. The indexed read path (positioned seeks, OS page cache only) is +% always compiled. + +:- use_module('../../../../src/unifyweaver/targets/wam_cpp_target', + [write_wam_cpp_project/3]). + +backend_kind(Kind) :- + current_prolog_flag(argv, Argv), + ( Argv = [_, _, Raw|_] + -> downcase_atom(Raw, Kind0) + ; Kind0 = indexed + ), + ( memberchk(Kind0, [indexed, lmdb]) + -> Kind = Kind0 + ; format(user_error, "bench/build.pl: unknown backend ~w (indexed|lmdb)~n", + [Kind0]), + halt(2) + ). + +store_source(indexed, Path, source(symprov/2, indexed(Path))). +store_source(lmdb, Path, source(symprov/2, lmdb(Path))). + +main :- + current_prolog_flag(argv, Argv), + ( Argv = [OutDir, StorePath|_] + -> true + ; format(user_error, + "usage: swipl -g main -t halt build.pl -- OUTDIR STOREPATH [indexed|lmdb]~n", []), + halt(2) + ), + backend_kind(Kind), + % sym_lookup/2 as a real (thin) clause; symprov/2 as a fact source. + assertz((user:sym_lookup(K, V) :- user:symprov(K, V))), + store_source(Kind, StorePath, Source), + Preds = [user:sym_lookup/2, user:symprov/2], + format("bench/build.pl: backend=~w store=~w out=~w~n", [Kind, StorePath, OutDir]), + write_wam_cpp_project(Preds, + [ module_name('uw-abi-bench'), + emit_mode(interpreter), + emit_main(false), + cpp_wam_fact_sources([Source]) ], + OutDir), + format("bench/build.pl: wrote C++ WAM project under ~w/cpp/~n", [OutDir]). diff --git a/examples/pkg_resolver/abi/bench/cost_model.mjs b/examples/pkg_resolver/abi/bench/cost_model.mjs new file mode 100644 index 000000000..f07d3498a --- /dev/null +++ b/examples/pkg_resolver/abi/bench/cost_model.mjs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2026 John William Creighton (@s243a) +// +// cost_model.mjs -- helpers for the backend-selection cost model. +// +// synth +// Synthesize a multi-row-per-key P/2 store (values are scalar strings) so we +// can measure how the indexed vs lmdb cold-read scatter grows with +// rows-per-key. Keys "mrk|kN@v" ... actually keys are "mrk|N", each with +// rowsPerKey distinct values. +// +// scatter +// Parse .idx (UWIX) and report, over all keys: rows-per-key, and the +// average number of DISTINCT 4KB .data pages a key's records span. indexed +// stores records in SOURCE ORDER, so a key with N rows lands at N scattered +// offsets -> ~N distinct pages -> up to N cold reads under memory pressure. +// (lmdb keys its B-tree by key, so a key's rows cluster in ~1 leaf page; that +// is a structural contrast, reported as ~1 in the model.) +// +// K -- print the crossover store/RAM ratio table (see below). + +import fs from 'node:fs'; + +const cmd = process.argv[2]; + +function u16(b, o) { return b[o] | (b[o + 1] << 8); } +function u32(b, o) { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; } + +if (cmd === 'synth') { + const [, , , out, keysArg, rpkArg] = process.argv; + const keys = parseInt(keysArg, 10), rpk = parseInt(rpkArg, 10); + // "grouped" (default) writes a key's rows contiguously; "interleaved" writes + // round-robin (all keys' row-0, then row-1, ...) so a key's rows land at + // scattered source-order offsets -- the realistic multi-row case (real ABI + // dup-keys already scatter this way). Interleaved is the scatter scenario. + const mode = process.argv[6] || 'interleaved'; + const ws = fs.createWriteStream(out); + if (mode === 'grouped') { + for (let k = 0; k < keys; k++) + for (let r = 0; r < rpk; r++) + ws.write(JSON.stringify([`mrk|k${k}`, `v${r}#rel${r}#unproven`]) + '\n'); + } else { + for (let r = 0; r < rpk; r++) + for (let k = 0; k < keys; k++) + ws.write(JSON.stringify([`mrk|k${k}`, `v${r}#rel${r}#unproven`]) + '\n'); + } + ws.end(() => console.error(`synth(${mode}): ${keys} keys x ${rpk} rows = ${keys * rpk} rows -> ${out}`)); +} else if (cmd === 'scatter') { + const prefix = process.argv[3]; + const idx = fs.readFileSync(prefix + '.idx'); + if (idx.slice(0, 4).toString() !== 'UWIX') { console.error('bad idx magic'); process.exit(1); } + const nKeys = u32(idx, 8), keyblobOff = u32(idx, 12), hitsOff = u32(idx, 16), nRecords = u32(idx, 20); + const PAGE = 4096; + let totalRows = 0, totalDistinctPages = 0, maxRows = 0; + const rpkHist = {}; + for (let i = 0; i < nKeys; i++) { + const e = 24 + i * 16; + const nHits = u16(idx, e + 6); + const hitsRel = u32(idx, e + 8); + const pages = new Set(); + for (let h = 0; h < nHits; h++) { + const off = u32(idx, hitsOff + hitsRel + h * 4); + pages.add(Math.floor(off / PAGE)); + } + totalRows += nHits; + totalDistinctPages += pages.size; + if (nHits > maxRows) maxRows = nHits; + rpkHist[nHits] = (rpkHist[nHits] || 0) + 1; + } + const out = { + idx: prefix, n_keys: nKeys, n_records: nRecords, + rows_per_key: +(totalRows / nKeys).toFixed(3), + distinct_data_pages_per_key: +(totalDistinctPages / nKeys).toFixed(3), + max_rows_for_one_key: maxRows, + // indexed cold reads per miss = distinct .data pages the key spans. + indexed_cold_reads_per_miss: +(totalDistinctPages / nKeys).toFixed(3), + lmdb_cold_reads_per_miss_structural: 1, // clustered leaf (see header note) + rows_per_key_hist_top: Object.entries(rpkHist).sort((a, b) => b[1] - a[1]).slice(0, 6), + }; + console.log(JSON.stringify(out, null, 2)); +} else if (cmd === 'K') { + // K(rows_per_key): the store/RAM ratio at which switching to lmdb yields a + // given end-to-end speedup, given measured primitives. Reads a JSON blob of + // {t_seek_ns, t_mem_ns, t_hit_ns, hit_rate, targets:[...]} from argv[3]. + const p = JSON.parse(process.argv[3]); + const { t_seek_ns, t_mem_ns, t_hit_ns, hit_rate } = p; + const h = hit_rate; + // Per-lookup time as a function of store/RAM ratio r (>=1) and rows_per_key M. + // P(evicted for a miss) ~= 1 - 1/r (fraction of store not resident). + // indexed miss touches M distinct .data pages; lmdb touches ~1. + // T(backend) = h*t_hit + (1-h)*cold_reads*[ (1/r)*t_mem + (1-1/r)*t_seek ] + const T = (M, r) => { + const pageCost = (1 / r) * t_mem_ns + (1 - 1 / r) * t_seek_ns; + return h * t_hit_ns + (1 - h) * M * pageCost; + }; + const rows = []; + for (const M of p.rows_per_key_list || [1, 2, 4, 8, 16]) { + // smallest r>=1 where lmdb is >= speedup faster (T_indexed/T_lmdb >= speedup) + let Kmap = {}; + for (const sp of p.targets || [1.2, 1.5, 2.0]) { + let found = null; + for (let r = 1.0; r <= 100.0; r += 0.05) { + const ti = T(M, r), tl = T(1, r); + if (ti / tl >= sp) { found = +r.toFixed(2); break; } + } + Kmap['x' + sp] = found; // null = never reaches that speedup within r<=100 + } + rows.push({ rows_per_key: M, K_for_speedup: Kmap, + ratio_at_r_inf: +(T(M, 1e9) / T(1, 1e9)).toFixed(2) }); + } + console.log(JSON.stringify({ primitives: p, table: rows }, null, 2)); +} else { + console.error('usage: cost_model.mjs synth|scatter|K ...'); + process.exit(2); +} diff --git a/examples/pkg_resolver/abi/bench/cost_model.sh b/examples/pkg_resolver/abi/bench/cost_model.sh new file mode 100755 index 000000000..2b4d9e617 --- /dev/null +++ b/examples/pkg_resolver/abi/bench/cost_model.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT OR Apache-2.0 +# Copyright (c) 2026 John William Creighton (@s243a) +# +# cost_model.sh -- measure the primitives for the backend-selection cost model +# and print the K(rows_per_key) crossover table. NO memory-cap needed: the +# disk-bound seek cost is measured per-page with posix_fadvise(DONTNEED); the +# aggregate disk-bound regime is then MODELED (extrapolated), not stress-tested. +# See RESULTS.md "Backend-selection cost model" for the write-up + honesty notes. +# +# Prereqs (built by bench_crossover.sh): $OUT/{bench_indexed,bench_lmdb}, +# $OUT/idx/symprov.{data,idx}, $OUT/lmdb_v1/symprov. Run bench_crossover.sh first. + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../../.." && pwd)" +OUT="$ROOT/examples/pkg_resolver/abi/.out/bench" +export LANG="${LANG:-C.UTF-8}" LC_ALL="${LC_ALL:-C.UTF-8}" +export NODE_PATH="${NODE_PATH:-/tmp/uw-lmdb-pkg-v1/node_modules}" UW_LMDB_DATA_V1=1 +export UW_WAM_FACT_L2_CAP="${UW_WAM_FACT_L2_CAP:-65536}" + +gcc -O2 -o "$OUT/cost_model_probe" "$HERE/cost_model_probe.c" + +echo "== t_seek / t_mem (single 4KB page, cold via fadvise vs warm) ==" +PROBE="$("$OUT/cost_model_probe" "$OUT/idx/symprov.data" 3000)"; echo "$PROBE" +TSEEK=$(echo "$PROBE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).t_seek_cold_ns.median))') +TMEM=$(echo "$PROBE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).t_mem_warm_ns.median))') + +echo "== scatter: indexed distinct .data pages per key (= cold reads/miss) ==" +echo "-- ABI symprov store (~1 row/key):" +node "$HERE/cost_model.mjs" scatter "$OUT/idx/symprov" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(` rows/key=${o.rows_per_key} indexed_cold_reads/miss=${o.indexed_cold_reads_per_miss} lmdb~1`)})' +mkdir -p "$OUT/mr/idx" +for M in 2 4 8 16; do + node "$HERE/cost_model.mjs" synth "$OUT/mr/mr$M.p2.jsonl" 20000 "$M" interleaved 2>/dev/null + node "$ROOT/scripts/js_wam/uw_fact_index.js" build "$OUT/mr/mr$M.p2.jsonl" "$OUT/mr/idx/mr$M" >/dev/null 2>&1 + node "$HERE/cost_model.mjs" scatter "$OUT/mr/idx/mr$M" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(`-- interleaved M=${o.rows_per_key}: indexed_cold_reads/miss=${o.indexed_cold_reads_per_miss} vs lmdb~1`)})' +done +echo "-- grouped (key-sorted .data) M=8: indexed clusters too:" +node "$HERE/cost_model.mjs" synth "$OUT/mr/mr8g.p2.jsonl" 20000 8 grouped 2>/dev/null +node "$ROOT/scripts/js_wam/uw_fact_index.js" build "$OUT/mr/mr8g.p2.jsonl" "$OUT/mr/idx/mr8g" >/dev/null 2>&1 +node "$HERE/cost_model.mjs" scatter "$OUT/mr/idx/mr8g" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(` grouped M=${o.rows_per_key}: indexed_cold_reads/miss=${o.indexed_cold_reads_per_miss} (clustered ~1)`)})' + +echo "== t_hit (pure cache-hit) & t_miss_resident (warm zero-reuse), per backend ==" +head -100 "$OUT/wl.unique.keys" | awk '{for(i=0;i<500;i++)print}' > "$OUT/wl.hot.keys" +hit_of() { UW_BENCH_EVICT=0 "$1" "$2" "$3" "$OUT/wl.hot.keys" 1 | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log((o.wall_ms*1e6/o.lookups).toFixed(3))})'; } +miss_of() { UW_BENCH_EVICT=0 "$1" "$2" "$3" "$OUT/wl.unique.keys" 1 | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log((o.wall_ms*1e6/o.lookups).toFixed(3))})'; } +echo " (single-shot; ns per lookup)" +echo " indexed t_hit=$(hit_of "$OUT/bench_indexed" indexed "$OUT/idx/symprov")ns t_miss_resident=$(miss_of "$OUT/bench_indexed" indexed "$OUT/idx/symprov")ns" +echo " lmdb t_hit=$(hit_of "$OUT/bench_lmdb" lmdb "$OUT/lmdb_v1/symprov")ns t_miss_resident=$(miss_of "$OUT/bench_lmdb" lmdb "$OUT/lmdb_v1/symprov")ns" + +echo "== K(rows_per_key): store/RAM ratio where lmdb reaches a speedup (hit_rate=0.9) ==" +node "$HERE/cost_model.mjs" K "{\"t_seek_ns\":$TSEEK,\"t_mem_ns\":$TMEM,\"t_hit_ns\":220,\"hit_rate\":0.9,\"rows_per_key_list\":[1,2,4,8,16],\"targets\":[1.2,1.5,2.0]}" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);console.log(` t_seek=${(o.primitives.t_seek_ns/1000).toFixed(1)}us t_mem=${(o.primitives.t_mem_ns/1000).toFixed(3)}us ratio=${(o.primitives.t_seek_ns/o.primitives.t_mem_ns).toFixed(0)}x`);for(const r of o.table)console.log(` rows/key=${String(r.rows_per_key).padStart(2)}: speedup(store>>RAM)=${r.ratio_at_r_inf}x K@1.2x=${r.K_for_speedup["x1.2"]} K@1.5x=${r.K_for_speedup["x1.5"]} K@2x=${r.K_for_speedup["x2"]}`)})' +echo "(K = store/RAM ratio; null = that speedup never reached. K~=1 means the benefit appears as soon as store exceeds RAM; magnitude = rows_per_key.)" diff --git a/examples/pkg_resolver/abi/bench/cost_model_probe.c b/examples/pkg_resolver/abi/bench/cost_model_probe.c new file mode 100644 index 000000000..e67265b61 --- /dev/null +++ b/examples/pkg_resolver/abi/bench/cost_model_probe.c @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2026 John William Creighton (@s243a) +// +// cost_model_probe.c -- measure this machine's single-page read latency for the +// store-crossover cost model, WITHOUT needing any memory-cap mechanism: +// t_seek : COLD single 4KB page read (posix_fadvise(DONTNEED) the file first) +// t_mem : WARM single 4KB page read (page already resident) +// Times N page reads at pseudo-random page-aligned offsets, reports median and +// p10/p90 (ns). Also reports the /proc/self/io read_bytes delta so we can see +// whether this host accounts physical disk reads at all (WSL2 often does not). +// +// Usage: cost_model_probe [N] + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int cmp_ll(const void* a, const void* b) { + long long x = *(const long long*)a, y = *(const long long*)b; + return (x > y) - (x < y); +} +static long long now_ns(void) { + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; +} +static long long read_bytes_now(void) { + FILE* f = fopen("/proc/self/io", "r"); + if (!f) return -1; + char k[64]; long long v, out = -1; + while (fscanf(f, "%63[^:]: %lld\n", k, &v) == 2) { + if (strcmp(k, "read_bytes") == 0) { out = v; break; } + } + fclose(f); + return out; +} + +int main(int argc, char** argv) { + if (argc < 2) { fprintf(stderr, "usage: %s [N]\n", argv[0]); return 2; } + const char* path = argv[1]; + long N = argc > 2 ? atol(argv[2]) : 2000; + int fd = open(path, O_RDONLY); + if (fd < 0) { perror("open"); return 1; } + struct stat st; if (fstat(fd, &st) != 0) { perror("fstat"); return 1; } + off_t size = st.st_size; + long pages = size / 4096; if (pages < 1) pages = 1; + + // Deterministic pseudo-random distinct-ish page offsets. + unsigned int s = 2463534242u; + off_t* offs = malloc(sizeof(off_t) * N); + for (long i = 0; i < N; i++) { + s ^= s << 13; s ^= s >> 17; s ^= s << 5; + offs[i] = ((off_t)(s % pages)) * 4096; + } + char* buf = malloc(4096); + long long* lat = malloc(sizeof(long long) * N); + + // ---- COLD: drop the whole file from cache, then read each page once ---- + posix_fadvise(fd, 0, size, POSIX_FADV_DONTNEED); + long long rb0 = read_bytes_now(); + for (long i = 0; i < N; i++) { + // Re-drop this page right before reading so it is cold even if a neighbor + // read pulled it in (readahead). DONTNEED on a 4KB range is cheap. + posix_fadvise(fd, offs[i], 4096, POSIX_FADV_DONTNEED); + long long t0 = now_ns(); + ssize_t n = pread(fd, buf, 4096, offs[i]); + long long t1 = now_ns(); + (void)n; lat[i] = t1 - t0; + } + long long rb1 = read_bytes_now(); + qsort(lat, N, sizeof(long long), cmp_ll); + long long cold_med = lat[N/2], cold_p10 = lat[N/10], cold_p90 = lat[(N*9)/10]; + + // ---- WARM: read the same pages again (now resident) ---- + for (long i = 0; i < N; i++) (void)pread(fd, buf, 4096, offs[i]); // prime + for (long i = 0; i < N; i++) { + long long t0 = now_ns(); + ssize_t n = pread(fd, buf, 4096, offs[i]); + long long t1 = now_ns(); + (void)n; lat[i] = t1 - t0; + } + qsort(lat, N, sizeof(long long), cmp_ll); + long long warm_med = lat[N/2], warm_p10 = lat[N/10], warm_p90 = lat[(N*9)/10]; + + printf("{\"file\":\"%s\",\"size\":%lld,\"pages\":%ld,\"N\":%ld," + "\"t_seek_cold_ns\":{\"median\":%lld,\"p10\":%lld,\"p90\":%lld}," + "\"t_mem_warm_ns\":{\"median\":%lld,\"p10\":%lld,\"p90\":%lld}," + "\"proc_read_bytes_delta_cold\":%lld}\n", + path, (long long)size, pages, N, + cold_med, cold_p10, cold_p90, warm_med, warm_p10, warm_p90, + (rb0 < 0 || rb1 < 0) ? -1 : (rb1 - rb0)); + free(offs); free(buf); free(lat); close(fd); + return 0; +} diff --git a/examples/pkg_resolver/abi/bench/gen_workload.mjs b/examples/pkg_resolver/abi/bench/gen_workload.mjs new file mode 100644 index 000000000..278aef869 --- /dev/null +++ b/examples/pkg_resolver/abi/bench/gen_workload.mjs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Copyright (c) 2026 John William Creighton (@s243a) +// +// gen_workload.mjs -- generate query-key workloads for the ABI store crossover +// benchmark from the ACTUAL store keys (examples/pkg_resolver/abi/.out/bench/ +// symprov.p2.jsonl, lines = ["soname|sym@node", "scalar"]). +// +// Two workloads (one key per line, in randomized order): +// *.skewed.keys -- a small HOT set (libc.so.6 / libstdc++.so.6 / libm.so.6 +// keys) is hit HOT_FRACTION of the time, the rest is a +// uniform tail over all keys. Models real ABI resolution, +// which re-touches a few libraries constantly. +// *.uniform.keys -- uniform-random over all keys (the contrast workload). +// Both mix in MISS_FRACTION well-formed keys that are NOT in the store (a real +// soname band, a synthetic symbol) so the read path does real work and returns +// nothing. +// +// Usage: +// node gen_workload.mjs [N] [HOT_FRAC] [MISS_FRAC] [SEED] +// Deterministic (seeded LCG) for reproducibility. Writes .skewed.keys, +// .uniform.keys and .manifest.json. + +import fs from 'node:fs'; +import readline from 'node:readline'; + +const [,, jsonl, outPrefix, + nArg = '50000', hotArg = '0.80', missArg = '0.15', seedArg = '1234567'] = process.argv; +if (!jsonl || !outPrefix) { + console.error('usage: node gen_workload.mjs [N] [HOT_FRAC] [MISS_FRAC] [SEED]'); + process.exit(2); +} +const N = parseInt(nArg, 10); +const HOT_FRACTION = parseFloat(hotArg); +const MISS_FRACTION = parseFloat(missArg); +let state = (parseInt(seedArg, 10) >>> 0) || 1; +// Numerical Recipes LCG -> [0,1) +function rnd() { state = (Math.imul(1664525, state) + 1013904223) >>> 0; return state / 4294967296; } +function pick(arr) { return arr[Math.floor(rnd() * arr.length)]; } + +const HOT_SONAMES = new Set(['libc.so.6', 'libstdc++.so.6', 'libm.so.6']); +const MISS_SYM = '__uwbenchmiss__@__UWBENCH__'; + +const keys = []; +const hotKeys = []; +const rl = readline.createInterface({ input: fs.createReadStream(jsonl) }); +for await (const line of rl) { + const t = line.trim(); + if (!t) continue; + let a; + try { a = JSON.parse(t); } catch { continue; } + const k = a[0]; + if (typeof k !== 'string') continue; + keys.push(k); + const so = k.slice(0, k.indexOf('|')); + if (HOT_SONAMES.has(so)) hotKeys.push(k); +} +if (keys.length === 0) { console.error('no keys read'); process.exit(1); } + +// Package-scale (or any store without the ABI hot sonames) fallback: if no key +// belongs to a hot soname, model "a few packages re-touched constantly" with a +// seeded random ~5% hot-key subset. Documented in the manifest as hot_mode. +let hotMode = 'sonames'; +if (hotKeys.length === 0) { + hotMode = 'random-5pct'; + const target = Math.max(1, Math.ceil(keys.length * 0.05)); + const seen = new Set(); + while (seen.size < target) { const i = Math.floor(rnd() * keys.length); if (!seen.has(i)) { seen.add(i); hotKeys.push(keys[i]); } } +} + +// Build a distinct set of well-formed miss keys: real soname band + synthetic sym. +const sonames = [...new Set(keys.map(k => k.slice(0, k.indexOf('|'))))]; +function missKey() { return `${pick(sonames)}|${MISS_SYM}`; } + +function genSkewed() { + const out = []; + let nMiss = 0, nHot = 0, nTail = 0; + for (let i = 0; i < N; i++) { + if (rnd() < MISS_FRACTION) { out.push(missKey()); nMiss++; continue; } + if (rnd() < HOT_FRACTION && hotKeys.length) { out.push(pick(hotKeys)); nHot++; } + else { out.push(pick(keys)); nTail++; } + } + return { out, nMiss, nHot, nTail }; +} +function genUniform() { + const out = []; + let nMiss = 0, nHit = 0; + for (let i = 0; i < N; i++) { + if (rnd() < MISS_FRACTION) { out.push(missKey()); nMiss++; } + else { out.push(pick(keys)); nHit++; } + } + return { out, nMiss, nHit }; +} + +const sk = genSkewed(); +const un = genUniform(); +fs.writeFileSync(`${outPrefix}.skewed.keys`, sk.out.join('\n') + '\n'); +fs.writeFileSync(`${outPrefix}.uniform.keys`, un.out.join('\n') + '\n'); + +const distinctSkewed = new Set(sk.out).size; +const distinctUniform = new Set(un.out).size; +const manifest = { + source: jsonl, + total_store_keys: keys.length, + distinct_sonames: sonames.length, + hot_mode: hotMode, + hot_sonames: [...HOT_SONAMES], + hot_key_count: hotKeys.length, + hot_key_fraction_of_store: +(hotKeys.length / keys.length).toFixed(4), + N, HOT_FRACTION, MISS_FRACTION, seed: parseInt(seedArg, 10), + skewed: { queries: N, miss: sk.nMiss, hot: sk.nHot, tail: sk.nTail, distinct_keys: distinctSkewed }, + uniform: { queries: N, miss: un.nMiss, hit: un.nHit, distinct_keys: distinctUniform }, + note: 'miss keys use a real soname band + synthetic symbol so the seek path does real work and returns 0 rows', +}; +fs.writeFileSync(`${outPrefix}.manifest.json`, JSON.stringify(manifest, null, 2) + '\n'); +console.log(JSON.stringify(manifest, null, 2)); diff --git a/examples/pkg_resolver/cpp_store/README.md b/examples/pkg_resolver/cpp_store/README.md index d307cac81..0377cd6cf 100644 --- a/examples/pkg_resolver/cpp_store/README.md +++ b/examples/pkg_resolver/cpp_store/README.md @@ -74,18 +74,30 @@ called, exposing two mode gaps (each fixed with the store adapter as witness): ## Backends -- **`UW_STORE_BACKEND=indexed`** (default) reads the dependency-free UWFI/UWIX - seek store with positioned `ifstream` reads and NO application cache — it leans - entirely on the OS page cache. +- **`UW_STORE_BACKEND=auto`** (default when unset) is a size-based policy: it + picks `lmdb` when the store is larger than `2 × available RAM` **and** lmdb is + usable, else `indexed` (loud fallback to `indexed` if lmdb was wanted but is + unusable). It prints the choice and the size-vs-RAM numbers, and it never + changes answers. Rule + theory + benchmarks: + [`../abi/bench/BACKEND_SELECTION.md`](../abi/bench/BACKEND_SELECTION.md). + Implemented in [`../store/ensure_lmdb.sh`](../store/ensure_lmdb.sh) + (`uw_resolve_store_backend`); tunable via `UW_STORE_LMDB_RAM_FACTOR` (default 2) + and `UW_STORE_AVAIL_RAM_BYTES` (RAM override). An explicit `indexed`/`lmdb` + overrides `auto`. +- **`UW_STORE_BACKEND=indexed`** reads the dependency-free UWFI/UWIX seek store; + the record read is now an **mmap in-place** access (one page fault per record, + like lmdb) with a positioned-`ifstream` fallback, over a whole-`.idx`-in-RAM + binary search, plus the shared **L1/L2 row cache** (below). No external dep. - **`UW_STORE_BACKEND=lmdb`** (Stage 2) is the lazy + two-level-cached LMDB reader (compiled under `WAM_CPP_ENABLE_LMDB`, auto-#defined when an `lmdb(Dir)` seek source is declared; links system `liblmdb`). Each bound-key lookup is a - keyed range-scan over the a1Range band; results are cached in an **L1** - direct-mapped slot table (mirrors Rust's `L1_CACHE`) and an **L2** FIFO map - (mirrors Rust's `CacheShard`; NOT Haskell's LRU). L2's default cap auto-sizes - from live `/proc/meminfo`; env overrides `UW_WAM_LMDB_L2_CAP` / - `UW_WAM_LMDB_L1_SLOTS` tune it (used by the benchmark sweep). It **fails - loudly**, never silently falling back to indexed. + keyed range-scan over the a1Range band. Results go through the **shared, + engine-agnostic row cache** (also used by `indexed`): an **L1** direct-mapped + slot table (mirrors Rust's `L1_CACHE`) and an **L2** FIFO map (mirrors Rust's + `CacheShard`; NOT Haskell's LRU). L2's default cap auto-sizes from live + `/proc/meminfo`; env overrides `UW_WAM_FACT_L2_CAP` / `UW_WAM_FACT_L1_SLOTS` + (legacy `UW_WAM_LMDB_*` still honored) tune it. It **fails loudly**, never + silently falling back to indexed. **liblmdb format note:** the default `lmdb` npm prebuilt is a Symas fork whose page format vanilla system `liblmdb` rejects (`MDB_INVALID`). Set diff --git a/examples/pkg_resolver/cpp_store/build.sh b/examples/pkg_resolver/cpp_store/build.sh index 14ce5e427..36e174985 100755 --- a/examples/pkg_resolver/cpp_store/build.sh +++ b/examples/pkg_resolver/cpp_store/build.sh @@ -29,7 +29,11 @@ ROOT="$(cd "$HERE/../../.." && pwd)" SRC="$HERE/../resolver_store.pl" PROJ="$HERE/uw_resolve_wam_cpp_store" STORE="${STORE_DIR:-$HERE/../store/.out/corpus}" -BACKEND="${UW_STORE_BACKEND:-indexed}" +# Requested backend: auto (default) | indexed | lmdb. `auto` is a POLICY layer +# that picks a concrete backend by store-size-vs-RAM and NEVER changes answers +# (see ../store/ensure_lmdb.sh:uw_resolve_store_backend and +# ../abi/bench/BACKEND_SELECTION.md). An explicit UW_STORE_BACKEND overrides it. +BACKEND_REQ="${UW_STORE_BACKEND:-auto}" export LANG="${LANG:-C.UTF-8}" export LC_ALL="${LC_ALL:-C.UTF-8}" @@ -41,6 +45,15 @@ if [[ ! -f "$STORE/cases.jsonl" ]]; then swipl -q -g dump_store_data -t halt examples/pkg_resolver/dump_store_data.pl -- "$STORE" fi +# Resolve auto -> concrete. The C++ reader always needs the vanilla-compatible +# v1 lmdb format, so opt in before the usability probe/build. Sourcing the +# helper is harmless for the indexed path. +export UW_LMDB_DATA_V1=1 +# shellcheck source=../store/ensure_lmdb.sh +source examples/pkg_resolver/store/ensure_lmdb.sh +BACKEND="$(uw_resolve_store_backend "$BACKEND_REQ" "$STORE")" +echo "cpp_store/build.sh: requested=$BACKEND_REQ resolved backend=$BACKEND (store=$STORE)" + case "$BACKEND" in indexed) if [[ ! -f "$STORE/pkg.data" ]]; then diff --git a/examples/pkg_resolver/store/ensure_lmdb.sh b/examples/pkg_resolver/store/ensure_lmdb.sh index 6ac9a3960..55126c6a2 100755 --- a/examples/pkg_resolver/store/ensure_lmdb.sh +++ b/examples/pkg_resolver/store/ensure_lmdb.sh @@ -73,3 +73,70 @@ uw_require_lmdb() { echo "indexed(...) is a different format and is not used as a fallback." >&2 exit 1 } + +# --------------------------------------------------------------------------- +# D43 auto store-backend selection (POLICY layer -- never changes ANSWERS; both +# backends return identical rows). Documented in +# examples/pkg_resolver/abi/bench/BACKEND_SELECTION.md. +# +# choose LMDB iff store_size_bytes > FACTOR * available_RAM_bytes AND lmdb +# is usable; else INDEXED. FACTOR (UW_STORE_LMDB_RAM_FACTOR, default 2) is a +# conservative headroom over the ~1x-RAM crossover onset. Size-only for now +# (a future refinement folds in rows_per_key -- see the doc). +# --------------------------------------------------------------------------- + +# Sum the store bytes under DIR: the BUILT indexed store (*.data + *.idx) when +# present, else the source P/2 JSONL (*.jsonl) as a pre-build estimate. +uw_store_size_bytes() { # DIR -> bytes + local dir="${1:?usage: uw_store_size_bytes DIR}" total=0 f had_built=0 + shopt -s nullglob + for f in "$dir"/*.data "$dir"/*.idx; do + had_built=1; total=$(( total + $(stat -c%s "$f" 2>/dev/null || echo 0) )) + done + if [ "$had_built" -eq 0 ]; then + for f in "$dir"/*.jsonl; do + case "$f" in *cases.jsonl) continue;; esac # cases.jsonl is the query set, not the store + total=$(( total + $(stat -c%s "$f" 2>/dev/null || echo 0) )) + done + fi + shopt -u nullglob + echo "$total" +} + +# Available RAM in bytes: UW_STORE_AVAIL_RAM_BYTES override (WSL2 MemAvailable +# balloons, so an override matters for testing/reproducibility) else +# /proc/meminfo MemAvailable. +uw_available_ram_bytes() { + if [ -n "${UW_STORE_AVAIL_RAM_BYTES:-}" ]; then echo "$UW_STORE_AVAIL_RAM_BYTES"; return 0; fi + local kb + kb=$(awk '/^MemAvailable:/{print $2; exit}' /proc/meminfo 2>/dev/null || echo 0) + echo $(( kb * 1024 )) +} + +# Resolve a requested backend (auto|indexed|lmdb) to a CONCRETE one. +# Echoes indexed|lmdb on stdout; all diagnostics go to stderr so callers can +# capture the choice with $(...). auto that wants lmdb but finds it unusable +# WARNS and falls back to indexed (answer-identical, so safe). +uw_resolve_store_backend() { # MODE DIR -> echoes indexed|lmdb + local mode="${1:?usage: uw_resolve_store_backend MODE DIR}" dir="${2:?}" + case "$mode" in + indexed|lmdb) echo "$mode"; return 0 ;; + auto) : ;; + *) echo "uw_resolve_store_backend: unknown backend '$mode' (auto|indexed|lmdb)" >&2; return 2 ;; + esac + local factor="${UW_STORE_LMDB_RAM_FACTOR:-2}" + local store ram threshold + store=$(uw_store_size_bytes "$dir") + ram=$(uw_available_ram_bytes) + threshold=$(( ram * factor )) + if [ "$store" -gt "$threshold" ]; then + if uw_ensure_lmdb >/dev/null 2>&1; then + echo "uw_store auto: store=${store}B > ${factor}x avail_RAM(${ram}B)=${threshold}B -> lmdb" >&2 + echo lmdb; return 0 + fi + echo "uw_store auto: store=${store}B > ${factor}x avail_RAM(${ram}B)=${threshold}B would pick lmdb, but lmdb is NOT usable (uw_ensure_lmdb failed / MDB_INVALID) -- FALLING BACK to indexed (answer-identical)" >&2 + echo indexed; return 0 + fi + echo "uw_store auto: store=${store}B <= ${factor}x avail_RAM(${ram}B)=${threshold}B -> indexed" >&2 + echo indexed; return 0 +} diff --git a/examples/pkg_resolver/store/test_auto_select.sh b/examples/pkg_resolver/store/test_auto_select.sh new file mode 100755 index 000000000..b023e2423 --- /dev/null +++ b/examples/pkg_resolver/store/test_auto_select.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT OR Apache-2.0 +# Copyright (c) 2026 John William Creighton (@s243a) +# +# test_auto_select.sh -- unit test for the D43 auto store-backend policy +# (uw_resolve_store_backend in ensure_lmdb.sh). Proves the SIZE rule via the +# UW_STORE_AVAIL_RAM_BYTES override, independent of any real store or memory: +# - store <= FACTOR x avail_RAM -> indexed +# - store > FACTOR x avail_RAM -> lmdb (or indexed with a loud fallback if +# lmdb is not usable on this box) +# Also checks explicit modes pass through unchanged. Answers are unaffected -- a +# policy that only picks a backend cannot change rows. + +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=ensure_lmdb.sh +source "$HERE/ensure_lmdb.sh" + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +# A store dir whose only sizeable file is a 4096-byte P/2 JSONL (pre-build path). +head -c 4096 /dev/zero | tr '\0' 'x' > "$TMP/pkg.jsonl" +: > "$TMP/cases.jsonl" # must be ignored by the sizer (it is the query set) +SIZE="$(uw_store_size_bytes "$TMP")" + +fail=0 +check() { # desc expected actual + if [ "$2" = "$3" ]; then echo " PASS $1 (=$3)"; else echo " FAIL $1: expected $2 got $3"; fail=1; fi +} + +echo "== auto store-backend policy (store_size=${SIZE}B, FACTOR=${UW_STORE_LMDB_RAM_FACTOR:-2}) ==" +check "sizer ignores cases.jsonl, counts pkg.jsonl" 4096 "$SIZE" + +# Below threshold: avail_RAM huge -> 2x huge >> store -> indexed. +b_lo="$(UW_STORE_AVAIL_RAM_BYTES=1000000000 uw_resolve_store_backend auto "$TMP" 2>/dev/null)" +check "store <= 2x RAM -> indexed" indexed "$b_lo" + +# Above threshold: avail_RAM=1 -> 2x1=2 < 4096 -> size rule wants lmdb. +reason="$(UW_STORE_AVAIL_RAM_BYTES=1 uw_resolve_store_backend auto "$TMP" 2>&1 >/dev/null)" +b_hi="$(UW_STORE_AVAIL_RAM_BYTES=1 uw_resolve_store_backend auto "$TMP" 2>/dev/null)" +echo " info above-threshold decision: resolved=$b_hi" +echo " info reason: $reason" +if echo "$reason" | grep -q -- "-> lmdb"; then + check "store > 2x RAM AND lmdb usable -> lmdb" lmdb "$b_hi" +elif echo "$reason" | grep -q "FALLING BACK to indexed"; then + echo " PASS store > 2x RAM but lmdb unusable -> indexed (loud fallback; answer-identical)" + check " fallback resolved backend" indexed "$b_hi" +else + echo " FAIL above-threshold did not trigger the lmdb size rule"; fail=1 +fi + +# Explicit modes pass through unchanged (override auto). +check "explicit indexed passes through" indexed "$(uw_resolve_store_backend indexed "$TMP" 2>/dev/null)" +check "explicit lmdb passes through" lmdb "$(uw_resolve_store_backend lmdb "$TMP" 2>/dev/null)" + +# Tunable factor: FACTOR=0 makes any non-empty store exceed the threshold. +b_f0="$(UW_STORE_LMDB_RAM_FACTOR=0 UW_STORE_AVAIL_RAM_BYTES=1000000000 uw_resolve_store_backend auto "$TMP" 2>&1 | tail -0; UW_STORE_LMDB_RAM_FACTOR=0 UW_STORE_AVAIL_RAM_BYTES=1000000000 uw_resolve_store_backend auto "$TMP" 2>/dev/null)" +if [ "$b_f0" = lmdb ] || [ "$b_f0" = indexed ]; then echo " PASS FACTOR override honored (resolved=$b_f0)"; else echo " FAIL FACTOR override"; fail=1; fi + +echo "== $([ $fail -eq 0 ] && echo ALL PASS || echo FAILURES) ==" +exit $fail diff --git a/templates/targets/cpp_wam/runtime.h.mustache b/templates/targets/cpp_wam/runtime.h.mustache index 78098dfaa..9019e2756 100644 --- a/templates/targets/cpp_wam/runtime.h.mustache +++ b/templates/targets/cpp_wam/runtime.h.mustache @@ -42,6 +42,17 @@ #include #endif +// POSIX mmap for the indexed .data file (read a record in-place, one page +// access like lmdb, instead of two positioned reads). Falls back to positioned +// ifstream reads where mmap is unavailable. +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#include +#include +#define UW_SEEK_HAVE_MMAP 1 +#endif + namespace wam_cpp { struct Value; @@ -772,6 +783,12 @@ public: ~SeekFactSource() { #ifdef WAM_CPP_ENABLE_LMDB close_lmdb(); +#endif +#ifdef UW_SEEK_HAVE_MMAP + if (data_map_ && data_map_ != MAP_FAILED) + ::munmap(const_cast(data_map_), data_map_size_); + data_map_ = nullptr; + if (data_fd_ >= 0) { ::close(data_fd_); data_fd_ = -1; } #endif } SeekFactSource(const SeekFactSource&) = delete; @@ -784,31 +801,58 @@ public: // All matching rows for the query: a bound-key seek when `key` is set (only // the records that key touches are read), else a full scan (the unbound-arg1 - // provides walk). lmdb sources fail loudly (no reader built in). + // provides walk). Keyed lookups go through the shared L1/L2 row cache so a + // repeat of a hot key pays zero backend I/O, for BOTH backends. lmdb sources + // fail loudly when no reader is built in. std::vector> rows(const std::optional& key) { if (kind_ == "lmdb") { #ifdef WAM_CPP_ENABLE_LMDB - return rows_lmdb(key); + ensure_open_lmdb(); #else throw std::runtime_error(lmdb_seek_missing_error()); #endif + } else { + ensure_open(); } - ensure_open(); std::lock_guard guard(mu_); - g_fact_io_data_size.store(data_size_, std::memory_order_relaxed); - std::vector> result; - if (key.has_value()) { - std::vector offs = lookup_offsets(*key); - result.reserve(offs.size()); - for (std::uint32_t off : offs) { - Value a1, a2; - if (read_record(static_cast(off), a1, a2)) - result.emplace_back(std::move(a1), std::move(a2)); + ensure_cache_config(); + if (kind_ != "lmdb") + g_fact_io_data_size.store(data_size_, std::memory_order_relaxed); + if (!key.has_value()) { + // Unbound arg1: full scan (the provides walk). Never cached. + RowVec out; + scan_backend(out); + return out; + } + const std::string& enc = *key; + // L1 probe (direct-mapped). + std::size_t slot = l1_.empty() ? 0 : (std::hash()(enc) & l1_mask_); + if (!l1_.empty() && l1_[slot].valid && l1_[slot].key == enc) { + ++l1_hits_; + return *l1_[slot].rows; + } + // L2 probe (shared FIFO map); on hit, promote into L1. + auto it = l2_.find(enc); + if (it != l2_.end()) { + ++l2_hits_; + if (!l1_.empty()) { l1_[slot].valid = true; l1_[slot].key = enc; l1_[slot].rows = it->second; } + return *it->second; + } + // Miss: real backend fetch, then fill BOTH tiers. + ++misses_; + RowVecPtr rowsp = std::make_shared(); + fetch_keyed(enc, *rowsp); + if (!l1_.empty()) { l1_[slot].valid = true; l1_[slot].key = enc; l1_[slot].rows = rowsp; } + if (l2_cap_ > 0) { + l2_[enc] = rowsp; + l2_order_.push_back(enc); + while (l2_.size() > l2_cap_) { + const std::string& victim = l2_order_.front(); + if (victim != enc) l2_.erase(victim); + l2_order_.pop_front(); } - } else { - scan_all(result); } - return result; + return *rowsp; } // Cache-attribution counters (D43 scale proof + the two-cache benchmark). @@ -838,6 +882,112 @@ private: std::uint32_t keyblob_off_ = 0; std::uint32_t hits_off_ = 0; std::uint32_t n_records_ = 0; + // The whole .idx (sorted key table + key blob + hits blob) is slurped into + // RAM ONCE at open (idx_blob_). A keyed lookup is then an IN-MEMORY binary + // search over the key table plus exactly one positioned .data record read -- + // no per-probe seek+read syscalls. At symbol scale the .idx is ~18MB, so the + // resident cost is small and constant, and it is answer-identical to the old + // per-probe path. See lookup_offsets(). + std::string idx_blob_; + // .data mmap (indexed backend). When mapped, a record is read in-place with + // one page access (no read syscall, exact byte count) -- so a resident miss + // costs ~one read like an lmdb mmap value fetch, not the two positioned reads + // (length prefix, then payload) of the ifstream fallback. + int data_fd_ = -1; + const unsigned char* data_map_ = nullptr; + std::size_t data_map_size_ = 0; + + // ---- Shared, engine-agnostic row cache (BOTH indexed and lmdb) ---------- + // The L1 (direct-mapped) + L2 (FIFO) caches map a lookup key -> its full + // decoded row list, so a repeat lookup of a hot key pays ZERO backend I/O + // regardless of storage engine. The cache is orthogonal to storage, so it is + // lifted out of the LMDB gate: the dependency-free indexed backend gets the + // same reuse win that was previously lmdb's only remaining advantage on + // skewed/reuse workloads. + // L1 -- fixed-size, direct-mapped, collision-overwrite (intra-query + // locality); env UW_WAM_FACT_L1_SLOTS (back-compat UW_WAM_LMDB_L1_SLOTS). + // L2 -- shared FIFO-bounded map (cross-query reuse); env UW_WAM_FACT_L2_CAP + // (back-compat UW_WAM_LMDB_L2_CAP); default auto-sizes from + // /proc/meminfo. Compose two_level: L1 hit skips L2; L2 hit promotes + // to L1; a miss fills both. Full (unbound-arg1) scans are NOT cached. + using RowVec = std::vector>; + using RowVecPtr = std::shared_ptr; + struct L1Slot { bool valid = false; std::string key; RowVecPtr rows; }; + std::vector l1_; + std::size_t l1_mask_ = 0; + std::unordered_map l2_; + std::deque l2_order_; + std::size_t l2_cap_ = 0; + bool cache_configured_ = false; + + static std::size_t env_size(const char* name, std::size_t fallback) { + if (const char* e = std::getenv(name)) { + char* end = nullptr; + unsigned long v = std::strtoul(e, &end, 10); + if (end && *end == 0 && v > 0) return static_cast(v); + } + return fallback; + } + static std::size_t round_up_pow2(std::size_t n) { + std::size_t p = 1; + while (p < n) p <<= 1; + return p; + } + // Default L2 cap: ~5% of live MemAvailable (mirrors Rust R8b + // resolve_runtime_cache_capacity), floored at 1024, ceilinged at 1<<20. + // Engine-agnostic (was lmdb_default_l2_cap). + static std::size_t default_l2_cap() { + std::size_t mem_avail_kb = 0; + std::ifstream mi("/proc/meminfo"); + std::string tok; + while (mi >> tok) { if (tok == "MemAvailable:") { mi >> mem_avail_kb; break; } } + if (mem_avail_kb == 0) return 4096; + const std::size_t budget = (static_cast(mem_avail_kb) * 1024u) / 20u; + const std::size_t edge_bytes = 256; + std::size_t cap = budget / edge_bytes; + if (cap < 1024) cap = 1024; + if (cap > (1u << 20)) cap = (1u << 20); + return cap; + } + // Configure L1/L2 once (caller holds mu_). Shared env names take precedence; + // the lmdb-specific names remain honored so existing lmdb callers/tests keep + // their exact behavior. + void ensure_cache_config() { + if (cache_configured_) return; + cache_configured_ = true; + std::size_t l1_size = round_up_pow2(env_size("UW_WAM_FACT_L1_SLOTS", + env_size("UW_WAM_LMDB_L1_SLOTS", 1u << 14))); + l1_.assign(l1_size, L1Slot{}); + l1_mask_ = l1_size - 1; + l2_cap_ = env_size("UW_WAM_FACT_L2_CAP", + env_size("UW_WAM_LMDB_L2_CAP", default_l2_cap())); + } + + // Backend-specific fetch primitives, dispatched by the shared cache in rows(). + void scan_backend(RowVec& out) { + if (kind_ == "lmdb") { +#ifdef WAM_CPP_ENABLE_LMDB + lmdb_scan_all(out); +#endif + } else { + scan_all(out); + } + } + void fetch_keyed(const std::string& enc, RowVec& out) { + if (kind_ == "lmdb") { +#ifdef WAM_CPP_ENABLE_LMDB + lmdb_range_scan(enc, out); +#endif + } else { + std::vector offs = lookup_offsets(enc); + out.reserve(offs.size()); + for (std::uint32_t off : offs) { + Value a1, a2; + if (read_record(static_cast(off), a1, a2)) + out.emplace_back(std::move(a1), std::move(a2)); + } + } + } // One positioned read that feeds the D43 counters. On a short read it // returns only the bytes actually read (mirrors Go's factIORead). @@ -870,42 +1020,84 @@ private: data_size_ = static_cast(data_.tellg()); data_.seekg(0, std::ios::beg); g_fact_io_data_size.store(data_size_, std::memory_order_relaxed); - std::string ih = fact_io_read(idx_, 24, 0); - if (ih.size() < 24 || ih.compare(0, 4, "UWIX") != 0) + // Slurp the entire .idx into RAM once (single positioned read; the + // D43 counters see it as one read of the whole index). Every subsequent + // key probe reads from idx_blob_ in memory -- zero syscalls per probe. + idx_.seekg(0, std::ios::end); + std::uint64_t idx_size = static_cast(idx_.tellg()); + idx_.seekg(0, std::ios::beg); + idx_blob_ = fact_io_read(idx_, static_cast(idx_size), 0); + if (idx_blob_.size() < 24 || idx_blob_.compare(0, 4, "UWIX") != 0) throw std::runtime_error("seek store: bad index magic at " + path_ + ".idx"); - n_keys_ = seek_le_u32(ih, 8); - keyblob_off_ = seek_le_u32(ih, 12); - hits_off_ = seek_le_u32(ih, 16); - n_records_ = seek_le_u32(ih, 20); + n_keys_ = seek_le_u32(idx_blob_, 8); + keyblob_off_ = seek_le_u32(idx_blob_, 12); + hits_off_ = seek_le_u32(idx_blob_, 16); + n_records_ = seek_le_u32(idx_blob_, 20); std::string dh = fact_io_read(data_, 16, 0); if (dh.size() < 16 || dh.compare(0, 4, "UWFI") != 0) throw std::runtime_error("seek store: bad data magic at " + path_ + ".data"); +#ifdef UW_SEEK_HAVE_MMAP + // mmap the .data so read_record accesses each record in-place (one page + // fault, no read syscall). The ifstream data_ stays only as the fallback + // path; close it once mapped to avoid holding an extra fd. + data_fd_ = ::open((path_ + ".data").c_str(), O_RDONLY); + if (data_fd_ >= 0 && data_size_ > 0) { + void* m = ::mmap(nullptr, static_cast(data_size_), + PROT_READ, MAP_PRIVATE, data_fd_, 0); + if (m != MAP_FAILED) { + data_map_ = static_cast(m); + data_map_size_ = static_cast(data_size_); + data_.close(); // reads now come from the map; fd no longer needed + } else { + ::close(data_fd_); data_fd_ = -1; // fall back to ifstream reads + } + } else if (data_fd_ >= 0) { + ::close(data_fd_); data_fd_ = -1; + } +#endif opened_ = true; } + // Byte-compare `target` against the key blob region [off, off+len) in + // idx_blob_ WITHOUT copying it out (string_view semantics, identical to + // seek_bytes_compare). Returns <0 / 0 / >0. + int idx_key_compare(std::size_t off, std::size_t len, const std::string& target) const { + if (off + len > idx_blob_.size()) len = (off <= idx_blob_.size()) ? (idx_blob_.size() - off) : 0; + const char* k = idx_blob_.data() + off; + std::size_t n = std::min(len, target.size()); + for (std::size_t i = 0; i < n; ++i) { + unsigned char ca = static_cast(k[i]); + unsigned char cb = static_cast(target[i]); + if (ca != cb) return ca < cb ? -1 : 1; + } + if (len < target.size()) return -1; + if (len > target.size()) return 1; + return 0; + } + // Binary search the sorted key table for `target`; return the .data offsets - // that key hits (empty if absent). Every probe is a positioned read, so the - // D43 counters see exactly the bytes a keyed seek touches. + // that key hits (empty if absent). The whole .idx is resident in idx_blob_ + // (loaded once at open), so every probe is an in-memory read -- NO per-probe + // seek+read syscall. Only the matching .data record read (read_record) still + // touches disk. Answer-identical to the old per-probe path. std::vector lookup_offsets(const std::string& target) { std::vector offs; std::int64_t lo = 0; std::int64_t hi = static_cast(n_keys_) - 1; while (lo <= hi) { std::int64_t mid = (lo + hi) >> 1; - std::uint64_t pos = 24 + static_cast(mid) * 16; - std::string e = fact_io_read(idx_, 16, pos); - std::uint32_t key_rel = seek_le_u32(e, 0); - std::size_t key_len = seek_le_u16(e, 4); - std::size_t n_hits = seek_le_u16(e, 6); - std::uint32_t hits_rel = seek_le_u32(e, 8); - std::string k = fact_io_read(idx_, key_len, - static_cast(keyblob_off_) + key_rel); - int c = seek_bytes_compare(k, target); + std::size_t pos = 24 + static_cast(mid) * 16; + std::uint32_t key_rel = seek_le_u32(idx_blob_, pos); + std::size_t key_len = seek_le_u16(idx_blob_, pos + 4); + std::size_t n_hits = seek_le_u16(idx_blob_, pos + 6); + std::uint32_t hits_rel = seek_le_u32(idx_blob_, pos + 8); + std::size_t key_off = static_cast(keyblob_off_) + key_rel; + int c = idx_key_compare(key_off, key_len, target); if (c == 0) { - std::string hits = fact_io_read(idx_, n_hits * 4, - static_cast(hits_off_) + hits_rel); + std::size_t hoff = static_cast(hits_off_) + hits_rel; offs.reserve(n_hits); - for (std::size_t i = 0; i < n_hits; ++i) offs.push_back(seek_le_u32(hits, i * 4)); + for (std::size_t i = 0; i < n_hits; ++i) + offs.push_back(seek_le_u32(idx_blob_, hoff + i * 4)); return offs; } if (c < 0) lo = mid + 1; else hi = mid - 1; @@ -913,7 +1105,39 @@ private: return offs; } + static std::uint32_t le_u32_ptr(const unsigned char* b) { + return static_cast(b[0]) + | (static_cast(b[1]) << 8) + | (static_cast(b[2]) << 16) + | (static_cast(b[3]) << 24); + } + static std::size_t le_u16_ptr(const unsigned char* b) { + return static_cast(b[0]) | (static_cast(b[1]) << 8); + } + bool read_record(std::uint64_t data_off, Value& a1_out, Value& a2_out) { +#ifdef UW_SEEK_HAVE_MMAP + if (data_map_) { + if (data_off + 4 > data_map_size_) return false; + const unsigned char* rec = data_map_ + data_off; + std::size_t payload_len = le_u32_ptr(rec); + if (payload_len < 4 || data_off + 4 + payload_len > data_map_size_) return false; + const unsigned char* p = rec + 4; // payload + std::size_t a1_len = le_u16_ptr(p); + std::size_t a2_len = le_u16_ptr(p + 2); + if (payload_len < 4 + a1_len + a2_len) return false; + // One logical record read (in-place; mirrors lmdb's per-record + // 1 read + record-bytes accounting -- no read syscall, exact bytes). + g_fact_io_bytes.fetch_add(4 + payload_len, std::memory_order_relaxed); + g_fact_io_reads.fetch_add(1, std::memory_order_relaxed); + a1_out = parse_fact_source_value( + std::string(reinterpret_cast(p + 4), a1_len)); + a2_out = parse_fact_source_value( + std::string(reinterpret_cast(p + 4 + a1_len), a2_len)); + return true; + } +#endif + // Fallback: two positioned ifstream reads (non-POSIX / mmap unavailable). std::string len_buf = fact_io_read(data_, 4, data_off); if (len_buf.size() < 4) return false; std::size_t payload_len = seek_le_u32(len_buf, 0); @@ -932,6 +1156,18 @@ private: void scan_all(std::vector>& out) { out.reserve(n_records_); std::uint64_t pos = 16; +#ifdef UW_SEEK_HAVE_MMAP + if (data_map_) { + for (std::uint32_t i = 0; i < n_records_; ++i) { + if (pos + 4 > data_map_size_) break; + std::size_t payload_len = le_u32_ptr(data_map_ + pos); + Value a1, a2; + if (read_record(pos, a1, a2)) out.emplace_back(std::move(a1), std::move(a2)); + pos += 4 + static_cast(payload_len); + } + return; + } +#endif for (std::uint32_t i = 0; i < n_records_; ++i) { Value a1, a2; if (read_record(pos, a1, a2)) out.emplace_back(std::move(a1), std::move(a2)); @@ -948,106 +1184,10 @@ private: #ifdef WAM_CPP_ENABLE_LMDB // ------------------------------------------------------------------ - // Stage 2: lazy + two-level-cached LMDB backend. - // - // Mirrors the RUST edge-cache variant specifically -- FIFO L2, not - // Haskell's LRU (both are documented per-target choices; this lane picks - // Rust's). The two cache TYPES the WAM_RUST cache design identifies: - // * L1 -- a fixed-size, direct-mapped, collision-overwrite table - // (Rust's per-HEC L1_CACHE: one slot per key hash, no eviction - // bookkeeping; the intra-query locality tier). Rust uses 1<<16 slots per - // thread; the C++ WAM is single-threaded so one modest table suffices - // (env override UW_WAM_LMDB_L1_SLOTS). - // * L2 -- a shared, FIFO-bounded map (Rust's CacheShard: a memory-budget - // cap with FIFO eviction -- push_back on fill, pop_front to evict, NOT - // LRU; the cross-query reuse tier). Default cap auto-sizes from live - // /proc/meminfo (mirrors Rust resolve_runtime_cache_capacity / R8b); env - // override UW_WAM_LMDB_L2_CAP lets a benchmark shrink it to model memory - // pressure -- the memory x scale crossover the owner wants. - // Compose as two_level: L1 hit skips L2; L2 hit promotes to L1; an LMDB miss - // (a real keyed range-scan off disk) fills both. Each cached entry is the - // full row list for one bound key (key -> vector<(a1,a2)>), so a repeat - // lookup of a hot key pays zero LMDB reads. + // Stage 2: lazy LMDB backend (keyed range-scan + full seq-band scan). The + // L1/L2 row cache that used to live here is now the shared, engine-agnostic + // cache above (rows()); this block is just the LMDB storage primitives. // ------------------------------------------------------------------ - using RowVec = std::vector>; - using RowVecPtr = std::shared_ptr; - - std::vector> rows_lmdb(const std::optional& key) { - ensure_open_lmdb(); - std::lock_guard guard(mu_); - if (!key.has_value()) { - // Unbound arg1: full seq-band scan (the provides walk). Not cached. - RowVec out; - lmdb_scan_all(out); - return out; - } - const std::string& enc = *key; - // L1 probe (direct-mapped). - std::size_t slot = std::hash()(enc) & l1_mask_; - if (l1_[slot].valid && l1_[slot].key == enc) { - ++l1_hits_; - return *l1_[slot].rows; - } - // L2 probe (shared FIFO map); on hit, promote into L1. - auto it = l2_.find(enc); - if (it != l2_.end()) { - ++l2_hits_; - l1_[slot].valid = true; - l1_[slot].key = enc; - l1_[slot].rows = it->second; - return *it->second; - } - // Miss: real keyed range-scan off disk, then fill BOTH tiers. - ++misses_; - RowVecPtr rows = std::make_shared(); - lmdb_range_scan(enc, *rows); - l1_[slot].valid = true; - l1_[slot].key = enc; - l1_[slot].rows = rows; - if (l2_cap_ > 0) { - l2_[enc] = rows; - l2_order_.push_back(enc); - while (l2_.size() > l2_cap_) { - const std::string& victim = l2_order_.front(); - if (victim != enc) l2_.erase(victim); - l2_order_.pop_front(); - } - } - return *rows; - } - - // Default L2 cap: size from live /proc/meminfo (mirrors Rust R8b - // resolve_runtime_cache_capacity) so the shipped default adapts to the host - // instead of a bare constant. Rough per-entry cost estimate -- groundwork - // precision per CACHE_COST_MODEL_PHILOSOPHY.md, not second-decimal accuracy. - static std::size_t lmdb_default_l2_cap() { - std::size_t mem_avail_kb = 0; - std::ifstream mi("/proc/meminfo"); - std::string tok; - while (mi >> tok) { - if (tok == "MemAvailable:") { mi >> mem_avail_kb; break; } - } - if (mem_avail_kb == 0) return 4096; // fallback if /proc unavailable - const std::size_t budget = (static_cast(mem_avail_kb) * 1024u) / 20u; // ~5% - const std::size_t edge_bytes = 256; // key string + shared RowVec est. - std::size_t cap = budget / edge_bytes; - if (cap < 1024) cap = 1024; // floor, as Rust floors at 1024 - if (cap > (1u << 20)) cap = (1u << 20); // sane ceiling - return cap; - } - static std::size_t env_size(const char* name, std::size_t fallback) { - if (const char* e = std::getenv(name)) { - char* end = nullptr; - unsigned long v = std::strtoul(e, &end, 10); - if (end && *end == 0 && v > 0) return static_cast(v); - } - return fallback; - } - static std::size_t round_up_pow2(std::size_t n) { - std::size_t p = 1; - while (p < n) p <<= 1; - return p; - } // P2: close a successfully- OR partially-opened LMDB env and reset the // handles so the source is safe to destroy, and so a retry after a failed @@ -1067,12 +1207,8 @@ private: void ensure_open_lmdb() { std::lock_guard guard(mu_); if (lmdb_open_) return; - // Configure caches on first open (env overrides in the UW_WAM_* runtime - // namespace; L2 default auto-sizes from available memory). - std::size_t l1_size = round_up_pow2(env_size("UW_WAM_LMDB_L1_SLOTS", 1u << 14)); - l1_.assign(l1_size, L1Slot{}); - l1_mask_ = l1_size - 1; - l2_cap_ = env_size("UW_WAM_LMDB_L2_CAP", lmdb_default_l2_cap()); + // Cache sizing is handled once by the shared ensure_cache_config() (called + // from rows() under mu_), so both backends configure L1/L2 identically. int rc = mdb_env_create(&lmdb_env_); if (rc != MDB_SUCCESS) throw std::runtime_error("lmdb env_create failed for " + path_); @@ -1162,15 +1298,10 @@ private: mdb_txn_abort(txn); } - struct L1Slot { bool valid = false; std::string key; RowVecPtr rows; }; + // LMDB env handles only; the L1/L2 cache members are shared (declared above). MDB_env* lmdb_env_ = nullptr; MDB_dbi lmdb_dbi_ = 0; bool lmdb_open_ = false; - std::vector l1_; - std::size_t l1_mask_ = 0; - std::unordered_map l2_; - std::deque l2_order_; - std::size_t l2_cap_ = 0; #endif // WAM_CPP_ENABLE_LMDB }; diff --git a/tests/test_wam_cpp_templates.pl b/tests/test_wam_cpp_templates.pl index 792cb5264..4d8424b59 100644 --- a/tests/test_wam_cpp_templates.pl +++ b/tests/test_wam_cpp_templates.pl @@ -37,10 +37,38 @@ % and P2 added SeekFactSource's RAII destructor + close_lmdb() helper + deleted % copy/move ops (env-leak fix). Both variants grew by the SAME +3108 characters % (the additions are gate-independent text), which is the whole header delta. -old_header_digest(plain, 90019, - 'd65645f69ff27e69319a67b43c42399b5d7c1067a804f40faa44247fe430c19d'). -old_header_digest(lmdb, 90260, - '1fcfa56c3eca176cd75b7878b2a30710f6c062a0cbcfaf90fbc3fb27422994e2'). +% +% Re-baselined AGAIN (ABI store crossover "fair fight", from plain 90019 / lmdb +% 90260): the indexed SeekFactSource read path now slurps the whole .idx into +% RAM once at open (idx_blob_) and binary-searches it IN MEMORY (idx_key_compare +% + rewritten lookup_offsets), eliminating the ~37 per-probe seek+read syscalls +% per lookup. Answer-identical (503-case store differential + 51-case corpus: +% 0 divergences). The +1872 characters are outside the WAM_CPP_ENABLE_LMDB gate, +% so BOTH variants grew by the SAME delta (the whole header delta). +% +% Re-baselined AGAIN (crossover row-cache LIFT, from plain 91891 / lmdb 92132): +% the L1 direct-mapped + L2 FIFO row cache was lifted OUT of the LMDB gate into a +% shared, engine-agnostic cache used by rows() for BOTH backends (indexed now +% caches too), with a shared ensure_cache_config() + env (UW_WAM_FACT_L1_SLOTS / +% L2_CAP; lmdb names honored for back-compat). Answer-identical (503 differential +% + 51 corpus + 122 ABI verify: 0 divergences; bench cross-check indexed +% rows_found == lmdb). Net +479 chars, gate-independent (shared members moved +% above the gate; the lmdb block lost its duplicate cache code), so BOTH variants +% grew by the SAME delta. +% +% Re-baselined AGAIN (crossover .data mmap follow-up, from plain 92370 / lmdb +% 92611): the indexed read path now mmaps .data and reads each record in-place +% (one page access like lmdb, not two positioned reads) -- guarded POSIX headers, +% data_map_ members, mmap in ensure_open, an mmap read_record/scan_all path + +% ifstream fallback, and munmap in the destructor. Answer-identical (503 +% differential + 51 corpus + 122 ABI verify: 0 divergences; bench cross-check +% indexed rows_found == lmdb, and the deterministic read count fell to ~1 per +% record = lmdb parity). The +4159 chars are gate-independent, so BOTH variants +% grew by the SAME delta. +old_header_digest(plain, 96529, + '397f1dfb0762915d6ac2368ffb1422811e75a4dfb828ba7d29740848744df7a2'). +old_header_digest(lmdb, 96770, + '7d393ce8e085d607ef7a1d7f0eb8803c62cc0cccbb50a7bf9fd8739efeb4f4d4'). assert_old_header_bytes(Mode, Header) :- old_header_digest(Mode, Length, Digest),