Skip to content

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

Open

Description

@wchwawa

Summary

On stores that exceed the buffer pool, two read paths degrade by two orders of magnitude while plain point gets stay fast:

  1. Bounded prefix scans that fetch values (tree.range().prefix(..), take 100) cost ~530–580 µs/op while a point get on the same store costs 1.6–2.0 µs. LMDB serves the identical scan in 1.8 µs (~300×).
  2. longest_prefix_record is ~19× slower than binary-searching the same answer with tree.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, bench profile, 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 native longest_prefix_record API, 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

  1. wchwawa commented on Oct 23, 2026

    @wchwawa
    MemberAuthor

    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 governs scan_keys(..).delimiter(..):

    op holt @ 256 MiB cache holt @ 2 GiB cache (resident) LMDB sled redb RocksDB SQLite
    page_rollup (1000 prefixes) 20 129 649 ns 927 818 ns 549 134 923 419 967 955 1 285 566 1 580 795
    dir_page (100 objects) 294 863 ns 19 403 ns 5 435 65 395 42 391 18 109 15 876
    obj_stat 9 484 ns 1 001 ns 1 940 4 385 5 578 2 751 2 773

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

    1. 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_stat to 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 with shard_scan and longest_prefix_record in the original report.

    2. 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.rs scenario s3page (knobs HOLT_AI_S3P_DIRS=20000, HOLT_AI_S3P_OBJS=100, HOLT_BENCH_CACHE_MB={256,2048}); harness contribution to follow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions