Repository navigation
Value-fetching prefix scans and longest_prefix_record fall off a cliff beyond the buffer pool (full 512 KiB frame reads on the walker path) #78
Description
Summary
On stores that exceed the buffer pool, two read paths degrade by two orders of magnitude while plain point gets stay fast:
- Bounded prefix scans that fetch values (
tree.range().prefix(..), take 100) cost ~530–580 µs/op while a pointgeton the same store costs 1.6–2.0 µs. LMDB serves the identical scan in 1.8 µs (~300×). longest_prefix_recordis ~19× slower than binary-searching the same answer withtree.get()(130 µs vs 6.5–7.0 µs) on a 540 k-key chain index.
Both symptoms reproduce identically on main (36fb295) and on the PR #76 head (d42da83), so this is not a #76 regression — it looks like a shared property of the scan/LPM walker path.
Environment
- Apple M3 Pro, macOS 26.6, APFS SSD; single-threaded bench,
benchprofile, rustc 1.97.1 TreeConfig::new(dir),Durability::Wal { sync: false }, buffer pool 512 frames (256 MiB), background checkpointer on- Preload settled with an explicit
checkpoint()barrier before timing
Reproduction shapes
Symptom 1 — prefix scan under cache pressure (training-dataset manifest shape):
- 2 M keys
ds/laion/shard-{s:05}/sample-{i:08}(200 shards × 10 k), 24-byte packed values; on-disk store well above the 256 MiB pool - Timed op:
tree.range().prefix(b"ds/laion/shard-{s:05}/"), consume 100 entries, random shard per op - Result: ~530–580 µs/op. Point get on the same store: 1.6–2.0 µs. RocksDB: 13.8 µs, SQLite: 16.5 µs, LMDB: 1.8 µs for the same scan under the same cache budget
- A 200 k-key variant of the same shape (fits the pool) scans in ~17 µs, so the cliff is cache-miss behavior, not per-entry cost
Symptom 2 — longest_prefix_record vs app-layer bisect (chained KV-cache index shape):
- 30 k chains, 4–32 levels each (~540 k keys), keys are hash-segment paths
kv/llama70b/<h0>/<h1>/.../<hi>(9 bytes/level), 40-byte values; prefix-closed key set - Timed op: longest stored prefix of a full-depth query (query extends a stored chain by 2 phantom levels; 80 % hit / 20 % miss mix)
- Native
longest_prefix_record(query): 130 µs/op - Binary search over the ~34 candidate depths using plain
tree.get(query[..len]): 6.5–7.0 µs/op — ~5 point gets, each on the indexed read path - At 2 k chains (~36 k keys, fits warm cache) both approaches cost ~5 µs, so the gap grows with blob count
Hypothesis
Point gets stay fast because they use the page-granular indexed read path (~18 KiB per cold read, per the README). The range/LPM walker appears to pin and read full 512 KiB blob frames on every cache miss instead, so any operation routed through the walker (bounded scans, delimiter-less range reads, longest_prefix_record) pays full-frame I/O and evicts the pool under pressure. bm_scan_full_blob_reads vs bm_read_page_hits during these workloads should confirm quickly.
Notably, delimiter rollup listings (scan_keys(..).delimiter(b'/'), take 20) stay excellent on the same stores (~220 ns), as do point gets and CAS — the cliff is specific to value-fetching scans and LPM.
Impact
- Any list/scan endpoint over a store larger than the buffer pool (manifest readers, restore-manifest scans) drops to ~2 kops/s single-threaded
- Until fixed, applications get strictly better chain-lookup latency by bisecting
get()calls than by calling the nativelongest_prefix_recordAPI, which defeats its purpose
Numbers (ns/op, 256 MiB pool, async WAL)
| op | holt | rocksdb | sqlite | sled | lmdb | redb |
|---|---|---|---|---|---|---|
| manifest point get | 1 647 | 2 450 | 3 510 | 4 289 | 1 910 | 3 191 |
| manifest shard scan (take 100) | 583 143 | 13 845 | 16 451 | 30 985 | 1 772 | 5 401 |
| chain LPM, native | 129 736 | — | — | — | — | — |
chain LPM, bisected get() |
6 778 | 6 814 | 10 485 | 5 581 | 4 765 | 6 643 |
The cross-engine harness (benches/ai_meta.rs, shared engine adapters incl. LMDB/redb/PostgreSQL) is ready locally and will be contributed separately; happy to attach it to this issue meanwhile.
Activity
wchwawa commentedon Oct 23, 2026
Third symptom, same mechanism: delimiter rollup inverts under cache pressure
A new issue-reproduction scenario (
s3page, modeled on minio/minio#17472: one bucket, 20 k directories × 100 objects = 2 M keys, timed op = a ListObjectsV2-style page of 1000 CommonPrefix rollups) shows the full-frame read path also governsscan_keys(..).delimiter(..):(Same environment as the original report: Apple M3 Pro, single thread, async WAL; comparators at the same 256 MiB private-cache budget, with the usual LMDB caveat that its mmap working set lives in the OS page cache.)
Two observations:
The inversion is purely residency-dependent. At 256 MiB the rollup page costs 20.1 ms — worst of the embedded engines, 37× behind LMDB's app-layer seek loop. Raising only holt's cache to 2 GiB (store fully resident, nothing else changed) recovers it to 928 µs (~930 ns/prefix) and
obj_statto 1.0 µs. So the delimiter walker's skip-subtree algorithm is winning per-prefix when frames are warm; the loss is entirely the cold path pinning full 512 KiB frames per descent, exactly as withshard_scanandlongest_prefix_recordin the original report.Contrast with the small-store result: on a cache-resident 205 k-key store the same rollup op is 217 ns/entry and 50× ahead of every comparator — which is the behavior minio#17472-scale namespaces need, and currently don't get, at matched memory.
So the three reported symptoms (value-fetching bounded scans, native LPM, delimiter rollup) now share one confirmed mechanism and one fix surface: routing the walker/scan/LPM descent through the page-granular indexed read path instead of full-frame pins. Rollup is arguably the highest-value of the three to fix, since it is the engine's headline semantic.
Reproduction:
benches/ai_meta.rsscenarios3page(knobsHOLT_AI_S3P_DIRS=20000,HOLT_AI_S3P_OBJS=100,HOLT_BENCH_CACHE_MB={256,2048}); harness contribution to follow.bench: add YCSB and AI-metadata harnesses over a workload-test engine…