Repository navigation
perf: assemble stable-row-id prefilter allow lists from cached per-fragment pieces - #8880
JaySon-Huang wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
❌ 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.
…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.
a32381b to
1647231
Compare
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.
wjones127
left a comment
There was a problem hiding this comment.
This is a good fix, but the test needs some improvement to show what it is claiming.
| // The allow list is assembled from per-fragment pieces cached by content | ||
| // identity (row id generation + deletion file). A commit that deletes rows | ||
| // must invalidate exactly the touched fragments' pieces: a mask rebuilt in | ||
| // the same session must reflect the new deletions while untouched | ||
| // fragments are served from cache. | ||
| #[tokio::test] | ||
| async fn test_row_id_allow_list_pieces_invalidate_on_new_deletions() { |
There was a problem hiding this comment.
issue(blocking): the test claims that "while untouched fragments are served from cache", but that isn't asserted. Could you use the assert_io_eq!() macro to assert the amount of IO each of the operations does, and thus prove that caching is happening?
There was a problem hiding this comment.
Addressed in 0c0698e222980708f1a788bf5325838799680b76. The focused regression now creates a real RowIdMeta::Column fragment with an inline deletion vector, proves the cold build performs storage reads, and then asserts the restricted rebuild performs read_iops == 0 with an exact metadata-cache delta of one piece hit and one composed-mask miss while the RowIdSequenceKey and DeletionFileKey entries remain resident. A subsequent unrelated commit reuses the same piece with zero reads. Could you take another look?
…anwo/pr-8880-merge-20261101 # Conflicts: # rust/lance/src/index/prefilter.rs
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The added I/O and cache-counter assertions demonstrate reuse across restrictions and commits, addressing the cache-reuse test request. The prefilter and cache-identity tests pass. The merged implementation preserves restricted-mask semantics and CPU-pool separation while accounting for spilled lineage files and inline deletion payloads.
wjones127
left a comment
There was a problem hiding this comment.
Thanks for the update. The new test counts reads and cache hits/misses, so it shows the reuse directly, which is what I asked for.
I'm holding off on approving the piece cache for now. We're planning to move indices to the address domain (#10960), which removes the need for this allow list, so I'd like a benchmark showing the cache is worth it in the meantime. I also left a question about sharing the fragment row-id identity with #10800, and a note on the DeletionFileKey identity change.
| pub deletion_file: &'a DeletionFile, | ||
| } | ||
|
|
||
| fn write_deletion_file_identity(builder: &mut KeyBuilder, deletion_file: &DeletionFile) { |
| dataset | ||
| .metadata_cache | ||
| .as_ref() | ||
| .get_or_insert_with_key(piece_key, || { |
There was a problem hiding this comment.
question: Our plan is to move indices to the address domain (#10960). Once they return row addresses, the prefilter only needs a block list of deleted addresses, like the non-stable-row-id path, so this whole-dataset allow list goes away for index search. merge_insert reaches it through the BTREE join-key lookup (#6877), so that use goes away too.
Given that, I'd want a benchmark showing a meaningful interim win before we add another cache layer here. The row-id sequence and deletion vector are already cached under RowIdSequenceKey/DeletionFileKey, so on a warm session a piece hit only saves the clone + mask + RowAddrTreeMap::from per fragment, and the final OR is still O(live rows).
Could you share before/after numbers for the case this targets: the first prefiltered query after a small commit on a large stable-row-id table?
| pub lineage_file: Option<&'a DataFile>, | ||
| } | ||
|
|
||
| fn write_row_id_identity( |
There was a problem hiding this comment.
question: #10800 also keys per-fragment row-id state by row-id source + deletion identity, but builds that identity differently. On top of the RowIdSequenceKey bytes it adds physical_rows, the resolved base path, and the row-id column index:
lance/rust/lance/src/dataset/rowids/cache.rs
Lines 192 to 206 in b5ee153
The base-path part comes from its keys living in the root namespace rather than the dataset-prefixed cache. The column index and physical_rows aren't explained by that, though. If they're needed there, are they needed here, and in RowIdSequenceKey? If not, could both PRs use this write_row_id_identity helper, so there is one definition of a fragment's row-id identity?
| } else { | ||
| builder.write_none(); | ||
| } | ||
| if let Some(inline) = &deletion_file.inline { |
There was a problem hiding this comment.
suggestion: This changes the identity of the existing DeletionFileKey (and bumps its schema), not only the new piece key. #10800 independently adds the inline payload to its deletion identity too:
lance/rust/lance/src/dataset/rowids/cache.rs
Lines 111 to 123 in b5ee153
So two inline deletion files with the same (read_version, id, type) and different bytes look like a real case. Could the description call this out as a fix to the deletion-vector cache? Once this lands, #10800's extra inline write becomes redundant.
Fixes #8849 (assembled-from-pieces variant; see the issue for the whole-dataset allow-list cost analysis)
Problem
DatasetPreFilter::do_create_deletion_mask_row_idbuilds a whole-dataset allow list by loading every fragment'sRowIdSequenceand deletion vector and folding them into oneRowAddrTreeMap. 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:restrict_tosets get distinct cache entries, so a workload with varying fragment restrictions rebuilds repeatedly at the same version;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:
RowIdSequenceKey: the inline digest, or the lineage data file'spath/base_idforRowIdMeta::Column;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.restrict_tosets share pieces instead of reloading them.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_metaor 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. Regression coverage now includes cross-commit invalidation, multi-fragment restrictions, spilled
RowIdMeta::Columnlineage, inline deletion vectors, and typed cache/I/O evidence. The cache test warms the underlying row-id and deletion caches, then verifies that a restricted rebuild performs zero reads and records exactly one piece-cache hit plus one miss for the newly composed mask. A subsequent commit that leaves that fragment untouched reuses the piece without I/O.Performance
No wall-time benchmark was rerun for the integrated head. The original PR described an author-reported local NVMe benchmark from an earlier revision, but the measured commit and harness are not identified here, so those numbers are omitted. The cache/I/O regression verifies behavior, not throughput.
Cost and scope
The final composition still ORs the selected pieces and remains O(dataset live rows); this change does not make a full allow-list build proportional only to the restriction. Pieces and composed masks may both remain resident in the existing metadata-cache budget.
The piece cache avoids rebuilding each fragment's live-row set and avoids repeated
RowIdSequenceKey/DeletionFileKeylookups. When those underlying caches are already warm, the previous path could also avoid storage I/O. This change therefore does not universally reduce object-store round trips; it can avoid lower-tier loads when those caches are cold, evicted, or contended.Notes / follow-ups
FilterLoadercurrently does not expose fragment coverage.