Skip to content

perf: assemble stable-row-id prefilter allow lists from cached per-fragment pieces - #8880

Open
JaySon-Huang wants to merge 2 commits into
lance-format:mainfrom
JaySon-Huang:perf/row-id-allow-list-pieces
Open

perf: assemble stable-row-id prefilter allow lists from cached per-fragment pieces#8880
JaySon-Huang wants to merge 2 commits into
lance-format:mainfrom
JaySon-Huang:perf/row-id-allow-list-pieces

Conversation

@JaySon-Huang

Copy link
Copy Markdown

Fixes #8849 (assembled-from-pieces variant; see the issue for the whole-dataset allow-list cost analysis)

Problem

DatasetPreFilter::do_create_deletion_mask_row_id builds a whole-dataset allow list by loading every fragment's RowIdSequence and deletion vector and folding them into one RowAddrTreeMap. The cost is proportional to the dataset size, not to what changed. The whole mask is cached under (manifest version, restrict_hash), which amortizes less than it looks:

  • every commit bumps the version and invalidates the entry, so the first prefiltered query after any write reloads every fragment;
  • distinct restrict_to sets get distinct cache entries, so a workload with varying fragment restrictions rebuilds repeatedly at the same version;
  • the resident mask scales with the dataset.

Change

Assemble the allow list from per-fragment pieces. Each piece is one fragment's live stable row ids (row id sequence minus deletion vector, exactly what the old fold OR-ed in for that fragment), cached in the dataset metadata cache under its content identity:

Pieces are fetched concurrently and OR-ed on a blocking thread. The whole-mask cache under (version, restrict_hash) is kept, so repeated identical queries still hit it unchanged.

  • Commits that leave a fragment's row ids and deletions untouched keep its piece warm: a post-commit rebuild only reloads touched fragments.
  • Overlapping restrict_to sets share pieces instead of reloading them.
  • Missing fragments (index bitmaps referencing removed fragments) contribute no piece, as before.

Correctness

A piece's content is fully determined by its key identity: any change to a fragment's row ids or deletions changes row_id_meta or the deletion file identity, so a stale piece cannot be served. mask(F) = OR(pieces(F ∩ manifest)) is identical to the old restricted fold (#6563/#6877 semantics preserved).

Tests: the existing prefilter mask tests (including the #6877 regression test) pass unchanged, plus a new test test_row_id_allow_list_pieces_invalidate_on_new_deletions covering cross-commit invalidation and restrict semantics. Full --lib suite: 3223 passed, 0 failed.

Performance

Local NVMe, 1M rows / 100 fragments / 0.5% scattered deletions / IVF_FLAT index, warm caches:

scenario before after
cold full allow-list build 4.4 ms 3.7–4.0 ms
repeated identical query (whole-mask cache) ~10 µs ~10 µs
rebuild after a commit deleting 1 of 100 fragments 3.5 ms (full rebuild) 348 µs
restrict set overlapping a previous one (50% shared) 1.9 ms 933 µs
any restrict set once all pieces are warm 1.9 ms 71 µs

On object storage the post-commit rebuild drops from ~N fragment loads (one round trip each) to only the touched fragments, which is where this mask cost dominates prefiltered searches on large tables.

Notes / follow-ups

  • Keeping the whole-mask cache means a mask can be resident twice (pieces + composed). If that matters for memory-constrained setups, the composed entry could be dropped and rebuilt from warm pieces in ~71 µs at the 1M-row scale.
  • A further step (not included) is intersecting the index-bitmap union with a filter's fragment coverage for selective scalar-index prefilters; FilterLoader currently does not expose fragment coverage.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: request changes.

The content-keyed per-fragment cache is a sound reuse boundary, but piece construction must preserve the existing async/CPU separation. Load row IDs and deletion vectors asynchronously, then run the clone, deletion masking, and tree construction for each miss on spawn_cpu; the final merge can remain on the CPU pool.

Comment thread rust/lance/src/index/prefilter.rs
…agment pieces

do_create_deletion_mask_row_id builds a whole-dataset allow list by loading
every fragment's row id sequence and deletion vector, so the cost is
proportional to the dataset size rather than to what changed. The result is
cached under (manifest version, restrict set), which amortizes less than it
looks: every commit invalidates the entry, distinct restrict sets each pay
their own full rebuild, and the resident mask scales with the dataset.

Assemble the allow list from per-fragment pieces instead. Each piece is one
fragment's live stable row ids (row id sequence minus deletion vector),
cached under its content identity: the row id generation (the same identity
RowIdSequenceKey uses for generation-safe sequence caching) plus the
deletion file identity. Pieces are OR-ed into the requested mask:

- commits that leave a fragment's row ids and deletions untouched keep its
  piece warm, so a post-commit rebuild only reloads touched fragments
- overlapping restrict sets share pieces instead of reloading them
- content is unchanged: a piece is exactly what the old fold OR-ed in for
  that fragment, and missing fragments contribute nothing

The whole-mask cache under (version, restrict_hash) is kept, so repeated
identical queries still hit it unchanged.
@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026
@JaySon-Huang
JaySon-Huang force-pushed the perf/row-id-allow-list-pieces branch from a32381b to 1647231 Compare August 30, 2026 12:34
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: request changes.

The code tree is unchanged from the prior reviewed revision, so the async/CPU boundary issue remains. The content-keyed per-fragment cache is a sound reuse boundary, but load row IDs and deletion vectors asynchronously, then run the clone, deletion masking, and tree construction for each miss on spawn_cpu; the final merge can remain on the CPU pool.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026
Loading a fragment's row id sequence and deletion vector stays async, but
the sequence clone, deletion masking and tree construction for each piece
are CPU-bound and were running inline on the async runtime. Move them into
spawn_cpu, matching the whole-mask fold's original async/CPU separation
while keeping the per-piece pipelining: each piece loads on the IO path and
builds on the CPU pool independently, so IO of one piece overlaps CPU of
another. The final merge stays on the CPU pool.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

The prior async/CPU boundary issue is fixed: row-ID and deletion-vector loading remains asynchronous, while each piece’s clone, deletion masking, and tree construction now run on the CPU pool. The content-identity cache and restricted-mask semantics remain intact, and the focused cross-commit invalidation test passes.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

K-approved Latest Gatekeeper recommendation permits acceptance. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: prefilter builds a whole-dataset allow list on stable-row-id datasets

1 participant