Repository navigation
perf(vector): speed up IVF_PQ search with prefilters and large partitions - #10828
Conversation
…ions f16 PQ8 codebooks share the quantizer's prepared L2 targets instead of building each partition's distance table with the generic f16 kernel. RowAddrMask::selected_indices filters a partition in bulk: rows are grouped by fragment, the fragment's selection is looked up once per run, and an ascending run of 64+ rows is intersected with the fragment bitmap. The flat index uses it at its three prefilter sites through a QueryScratch buffer, and PQDistCalculator scores the accepted rows with the selected-rows kernels in 256-row chunks, eight rows at a time for PQ8. IVFIndex::score_chunk splits a global-top-k chunk over concurrent spawn_cpu tasks once it holds 2 * LANCE_IVF_SCORING_TASK_BYTES (1 MiB) of prepared partitions and merges their heaps, so one query no longer scores large partitions on a single core. Small partitions keep the single dispatch. PQ partition scans write distances into the query scratch instead of a new vector per partition, and the heap loop rejects rows worse than the current k-th with one plain float compare. Single-client p50 on 4M rows of fsl<f16,768>, 128 partitions, k=10, nprobes=20: plain 13.3 -> 5.1 ms, 5% allowlist mask 25.2 -> 6.4 ms, refine_factor 10 20.3 -> 11.4 ms; 16 clients 756 -> 1400 qps.
…eryScratch's shape An explicit query_parallelism of 1 keeps the single-worker partition scoring of lance-format#6475: score_chunk now takes the task cap from the query, an explicit count caps the split and automatic (0) or -1 use the pool. The prefilter selection no longer needs a new QueryScratch field: the flat index writes the selected offsets into the existing u32 scratch through RowAddrMask::selected_offsets_into (selected_indices keeps its signature on top of it), so no public struct changes shape.
|
Addressed both notes: |
The global top-k scoring loop moved to `ivf/v2/partition_search.rs` (lance-format#10727), so the chunk split over concurrent CPU tasks moves there as a free `score_chunk` over `Box<dyn PreparedPartition>`. The byte-range and heap-merge helpers stay in `v2.rs`.
…rage The public selected_indices wrapper returned positions converted through the u32 buffer used by the internal partition scorers, so a caller with more than u32::MAX rows would silently truncate offsets. Share the run logic through a private usize emitter instead, and keep the u32 scratch method for the three partition hot paths with its documented bound. The scoring-split test assumed an automatic query always splits a large chunk, but a single-worker compute pool correctly keeps one task, so derive the expectation from get_num_compute_intensive_cpus(). The masked PQ partition test now writes two fragments, so a partition offset differs from its row address and the test proves the masked scorers map offsets through storage.row_id.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The public mask path keeps full-width positions, and masked Flat/PQ searches keep fragment row addresses distinct from partition offsets. The explicit query_parallelism=1 limit remains intact. No additional changes are needed.
Xuanwo
left a comment
There was a problem hiding this comment.
Approving at head 13f0315.
All prior findings are resolved: query_parallelism = 1 still pins scoring to one worker (reproducer added as a test), and QueryScratch keeps its original fields, so the withdrawn breaking-change note does not apply. All applicable checks completed at this head; the four skipped jobs are path-filtered for Python/bindings and Arrow-dependency changes that this Rust-only diff does not make.
A squash of this head onto current main (9c0de40) merges cleanly to tree 3503b89e3f8a25cd29235c37e3ffbc311fd65176, which is the candidate tree dfd02e71 plus main's #10826 delta, with every changed-file blob identical on both sides.
Summary
Four hot spots found by profiling IVF_PQ search on an f16 vector column (4M rows,
fsl<f16,768>, 128 partitions × 48 sub-vectors), each fixed where the profile pointed:PQStorage::with_prepared_targetsand theFloat16branch ofdist_calculatornow take the quantizer's widenedl2_targets, so each partition's distance table no longer goes through the generic f16 kernel (8% of a query).RowAddrMaskgroups rows by fragment, looks the fragment's selection up once per run and intersects ascending runs of 64+ rows with the fragment bitmap.selected_indiceskeeps itsVec<u64>signature and enumerates positions asusize;selected_offsets_intowrites theu32offsets the flat index uses at its three prefilter sites through the existingQueryScratch::u32buffer, whose documented bound is one index partition (fewer thanu32::MAXrows).PQDistCalculatoroverridesaccumulate_filtered_topk_with_scratchto score accepted rows with the selected-rows kernels in 256-row chunks: the existing 4-bit kernel, and a newcompute_pq_distance_8bit_rowsthat interleaves eight rows so the 48 dependent adds of one row no longer bound the loop. Per-rowRowAddrMask::selectedplus per-rowdistance()were 62% + 15% of a masked query.IVFIndex::score_chunksplits a global-top-k chunk over concurrentspawn_cputasks when it holds at least twiceLANCE_IVF_SCORING_TASK_BYTES(1 MiB) of prepared partitions, each task with its own heap, merged back to thek * refinebest. The split honors the query'squery_parallelism: an explicit1keeps the single-worker scoring of feat(vector): add partition search parallelism #6475, an explicit count caps the tasks, and automatic (0) or-1use the CPU pool when it has at least two workers; a one-worker pool keeps one task. Small partitions keep the single dispatch. Which of several rows tied exactly at the k-th distance survives can differ from sequential scoring.compute_pq_distance_into), andaccumulate_distances_into_heaprejects rows worse than the current k-th with one plain float compare before the total-order compares.No public struct or trait changes its shape:
QueryScratchkeeps its fields andselected_indicesits signature.Branch state
This branch is the author's head plus an ordinary merge of
mainat d27fd01 and one repair commit. Versus currentmainit touches 9 files, +840/−81.selected_indicesno longer routes its positions through the internalu32buffer, so the public path has no 4-billion-row precondition.selected_offsets_intokeeps that buffer for the three partition hot paths and documents the partition bound.get_num_compute_intensive_cpus() >= 2, so a single-worker pool exercises the production fallback instead of failing. It was run in separate processes with the default 32-CPU pool and withLANCE_CPU_THREADS=1; the explicitquery_parallelism = 1case stays single-task in both.storage.row_id.u32::MAX-row fixture (the public offset width follows from theusizeenumeration, not from a large fixture); the k-th tie winner is still not guaranteed; no new cache, protocol, or scheduler.Measurements
Author-measured on the original feature head and not re-run for this branch update. Same dataset on an EPYC 9354, k=10, nprobes=20, p50 latency of a single client, index cache warm:
recall@10 against a flat scan is unchanged (0.53–0.57 over 20 queries, within sampling noise).
Tests
test_selected_indices_bulk_matches_per_row: bulk, per-row, unsorted and duplicate runs, full and absent fragments, allow and block lists, buffer reuse.test_f16_pq8_uses_prepared_l2_targets: f16 PQ8 partition storage gets the targets and scores within rounding of the generic kernel.test_filtered_accumulate_topk_matches_per_row: filtered scan equals per-rowdistance()bit for bit for PQ4/PQ8 × L2/Dot, from an empty and a prefilled heap, across a chunk boundary.test_compute_pq_distance_8bit_rows_partial_group: a row count that is not a multiple of eight and an output longer than the ids.test_balanced_byte_ranges,test_merge_topk_heaps_keeps_the_best: the split and merge helpers, including the below-two-tasks, one-partition and capped cases.test_batch_partition_prefilter_preserves_bulk_scoring: flat/PQ4/PQ8 partition search under allow, block and empty masks over a two-fragment dataset; masked results equal the unfiltered result filtered per row.test_knn_scores_large_chunk_on_concurrent_tasks: an IVF_FLAT index whose two partitions exceed the split threshold is scored by concurrent tasks under automatic parallelism when the pool has two or more workers, and on one task underquery_parallelism = 1or a one-worker pool; both match a flat scan.cargo fmt --allandcargo clippy --all --tests --benches --profile ci --locked -- -D warningsare clean, as arecargo check --workspace --tests --benches --profile ci --lockedand the targeted test runs above.