Skip to content

perf(frag_reuse): cache stable-partition row-map chunks - #9252

Draft
LuQQiu wants to merge 1 commit into
lu/fri-sp-remapfrom
lu/fri-rowmap-cache
Draft

LuQQiu wants to merge 1 commit into
lu/fri-sp-remapfrom
lu/fri-rowmap-cache

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

This PR builds on #9187 and provides the eleventh FRI implementation layer. It remains outside the preceding nine-PR native GitHub stack to avoid extending its cascading rebase chain. It caches stable-partition row-map label chunks used by version-1 FRI query translation. It introduces no protobuf or file-format changes, and the legacy version-0 FRI path remains unchanged.

Problem

The FRI reader already caches the decoded transition history and its StablePartitionMapping. Opening the mapping also loads and validates the small counts matrix. However, RowMapReader::block_labels previously read label blocks directly from _fri/<map_id>/stable_partition.lance and discarded them after translation.

Consequently, different index pages and later queries repeatedly read the same row-map blocks:

index page
    |
    v
request source row IDs
    |
    v
read the corresponding row-map blocks
    |
    v
translate to destination row IDs
    |
    v
discard the decoded labels

On the 20M-row benchmark fixture, this produced approximately 10,000 _fri reads per query. Increasing the index-cache capacity did not change the read count because the mapping object was cached while its label data was not.

Chunk cache

The row map is divided into cache chunks containing 32 logical mapping blocks:

stable_partition.lance
├── chunk 0: blocks 0..31
├── chunk 1: blocks 32..63
├── ...
└── final chunk: remaining blocks

Each mapping block describes 65,536 source rows. A full chunk therefore contains 2,097,152 nullable UInt16 labels and occupies approximately 4 MiB when decoded, plus validity and array overhead.

Chunks are stored in the existing session index cache under the row-map-blocks namespace. The structured cache key contains the transition fingerprint and chunk index, so immutable chunks can be reused across queries and dataset snapshots that share the same transition.

RowMapReader::block_labels now performs:

requested block
    |
    v
locate enclosing chunk
    |
    v
index-cache lookup
    ├── hit: slice the requested block from cached labels
    └── miss: read and decode the chunk once, cache it, then slice the block

The cache's get_or_insert_with_key path also deduplicates concurrent loads of the same chunk. Address translation, counts validation, deletion handling, and destination-offset calculation are unchanged.

Integration

frag_reuse_reader::open_mapping gives each stable-partition mapping an optional RowMapBlockCache. StablePartitionMapping passes it to RowMapReader when the immutable row-map file is opened on first use.

The default behavior enables chunk caching without eagerly reading labels. Two diagnostic controls support benchmarking:

  • LANCE_FRI_ROWMAP_CACHE=0 disables the chunk cache and restores direct block reads.
  • LANCE_FRI_ROWMAP_PREWARM=1 loads all chunks concurrently when the mapping is opened.

These environment variables are diagnostic controls rather than persisted dataset settings.

Relationship to index remapping

This PR optimizes the translating state before an index segment has caught up:

stable-partition rewrite committed
        |
        v
index still stores source addresses
        |
        v
query-time FRI translation uses cached row-map chunks

#9187 handles the later materialized state by rewriting a fully covered index segment onto destination addresses. Once that succeeds, the segment no longer reads the consumed row-map transitions during queries. This cache remains useful for segments that have not been remapped or cannot satisfy the full-coverage requirement.

The cache does not rewrite index files, modify transition metadata, release row-map payloads, or change cleanup behavior.

Memory behavior

The row-map file may be compressed on storage, but cached chunks hold decoded nullable UInt16 labels. A fully resident map therefore requires approximately two bytes per physical source row, plus validity and cache overhead:

20M rows  -> approximately 40 MiB decoded
100M rows -> approximately 200 MiB decoded
1B rows   -> approximately 2 GiB decoded

Row-map chunks share the existing cache budget with scalar and vector index state. A cache smaller than the combined working set may evict row-map chunks and index partitions from each other. Prewarming should therefore be used only when the cache can hold the intended working set.

Benchmark results

The benchmark measures the translating state after a stable-partition rewrite over a 20M-row local NVMe fixture:

Configuration Cold _fri reads Cold _fri bytes QPS at c16 QPS at c64
Cache disabled 10,440 419 MB 1,514 3,484
Chunk cache 17 12.9 MB 1,448 3,366
Prewarm 37 13.0 MB 1,496 3,369
No FRI translation 0 0 1,592 3,476

Chunk caching reduced row-map requests by roughly 600 times and storage bytes by roughly 32 times. Local steady-state throughput remained similar because the uncached range reads were served cheaply by the operating-system page cache and query execution was compute-bound. The request reduction is intended primarily for object storage, where each uncached range read adds a network round trip.

A 64 MiB index cache thrashed when it could not hold the decoded row-map working set together with the IVF partitions, confirming that cache capacity remains part of the operating tradeoff.

Validation

Tests verify that cached point translation, batch translation, full sweeps, and prewarmed translation produce the same results as the uncached reader, including multi-chunk files and a short final chunk. Existing row-map validation and FRI reader suites remain unchanged in behavior. The read benchmark accepts an index-cache capacity through SPBENCH_INDEX_CACHE_MB for cache-size experiments.

@github-actions

Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@github-actions github-actions Bot added the A-index Vector index, linalg, tokenizer label Sep 15, 2026
@LuQQiu LuQQiu changed the title exploration(frag_reuse): cache stable-partition row-map chunks in the index cache perf(frag_reuse): cache stable-partition row-map chunks Sep 15, 2026
@LuQQiu
LuQQiu force-pushed the lu/fri-rowmap-cache branch from 2442bd1 to 8ff878a Compare September 15, 2026 22:07
…cache

A query that translates addresses through a tagged fragment-reuse history
re-read the row-map label blocks on every call. Cache the labels in the
session index cache in ~4 MiB chunks (32 blocks, keyed by transition
fingerprint + chunk index), so each part of the row map is read at most once.
The chunk unit keeps the entry count small (~48 at 100M rows, ~477 at 1B)
while making each cold miss one large sequential range read. On a 20M-row
fixture this cuts cold _fri reads from ~10,440 to ~17 per query. Cache-off
behavior is byte-identical (regression test compares cached vs uncached
point/batch/sweep translations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgAshYD7wVzPVdjXRuWPPs
@LuQQiu
LuQQiu force-pushed the lu/fri-rowmap-cache branch from 8ff878a to 20b91b2 Compare September 15, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant