Skip to content

perf(index): window IVF partition loads during query - #9359

Closed
BubbleCal wants to merge 1 commit into
mainfrom
yang/ivf-query-partition-window-load-d704
Closed

BubbleCal wants to merge 1 commit into
mainfrom
yang/ivf-query-partition-window-load-d704

Conversation

@BubbleCal

@BubbleCal BubbleCal commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What is the performance issue?

PR #9049 made IVF prewarm 30x faster by reading adjacent partitions in 64 MiB windows instead of one read_stream per partition. Query-time loading was left on the old per-partition path. A cold search still issues a decoder setup, page-metadata init, and object-store GET round for every probed partition.

Query nprobes are usually scattered, so a naive copy of prewarm's contiguous windows would almost never fire. The I/O scheduler only coalesces ranges that arrive in the same submit_request, so concurrent single-partition reads do not merge even when pages are nearby.

How does this PR improve performance?

Reuse the prewarm reader for query-time loads:

  • Uncached partitions in a prepare-window chunk (prepare_parallelism, typically the CPU pool size) are sorted and grouped by the same encoded-byte / partition caps as prewarm (LANCE_IVF_PREWARM_WINDOW_SIZE_BYTES, default 64 MiB).
  • Adjacent IDs with a continuous IVF row layout use one Range. Scattered nprobes use one Ranges request so page-metadata init and nearby GETs can coalesce.
  • Single-flight cache inserts stay per-partition (leader + followers), matching prewarm.
  • Search still streams those chunks into the existing global-top-k / streaming scoring batches, so resident prepared-partition memory stays bounded by the prepare window plus scoring chunks — not by nprobes.

load_partition (single ID) is unchanged for search_in_partition and other one-partition callers.

HF retrieval benchmarks (open-index excluded)

4 vCPU / 15 GiB, local disk, pylance 13.0.0-beta.4. Cosine IVF_RQ, no refine, k=100.

Protocol (cold sample): open the dataset and call get_ivf_model / centroids outside the timer (this is 8 IOPS / ~4 MiB). posix_fadvise(DONTNEED) on _indices only. Reset I/O counters. Time one search on that already-open handle. Fresh Session per cold sample so the partition cache is empty. Warm = one session, 5 untimed queries, then measure.

windowed is the default 64 MiB grouper. singleton sets LANCE_IVF_PREWARM_WINDOW_SIZE_BYTES=1 (same new reader, one partition per group).

Build (centroids reused for RQ5):

Dataset Rows Dim nlist RQ1 RQ5
vibe-msmarco-qwen-1024 8,840,823 1024 1024 393s / 1.3 GiB 416s / 5.6 GiB
coyo-ve-qwen3vl-2048 15,380,795 2048 4096 1030s / 4.0 GiB 1958s / 19 GiB

MS MARCO (8.8M × 1024-d, 20 queries)

Index nprobes Mode Cold lat Cold IOPS Cold MiB Warm lat Recall@100
IVF_RQ1 20 windowed 200 ms 214 26 34 ms 0.794
IVF_RQ1 20 singleton 29 ms 217 26 19 ms 0.794
IVF_RQ1 80 windowed 205 ms 512 102 49 ms 0.824
IVF_RQ1 80 singleton 88 ms 518 102 32 ms 0.824
IVF_RQ5 20 windowed 442 ms 273 116 104 ms 0.905
IVF_RQ5 20 singleton 75 ms 278 116 39 ms 0.905
IVF_RQ5 80 windowed 601 ms 750 461 138 ms 0.966
IVF_RQ5 80 singleton 260 ms 758 461 77 ms 0.966

Coyo-VE (15.4M × 2048-d, 15 queries)

Index nprobes Mode Cold lat Cold IOPS Cold MiB Warm lat Recall@100
IVF_RQ1 20 windowed 186 ms 228 23 19 ms 0.852
IVF_RQ1 20 singleton 35 ms 232 23 18 ms 0.852
IVF_RQ1 80 windowed 282 ms 527 87 43 ms 0.887
IVF_RQ1 80 singleton 96 ms 532 87 36 ms 0.887
IVF_RQ5 20 windowed 523 ms 286 107 58 ms 0.920
IVF_RQ5 20 singleton 112 ms 292 107 52 ms 0.920
IVF_RQ5 80 windowed 1153 ms 764 412 156 ms 0.971
IVF_RQ5 80 singleton 329 ms 772 412 129 ms 0.971

What this shows

  • Opening the index is cheap (8 IOPS / 4 MiB) and is no longer in the timed region. The ~210–770 query IOPS are partition loads: about 10 IOPS per probed partition across index.idx + auxiliary.idx.
  • Bytes and recall are identical windowed vs singleton.
  • IOPS drop only ~1–2% with grouping. Scattered nprobes do not become prewarm-style sequential windows.
  • On this local disk, the 64 MiB grouper is slower for cold latency (often 3–7× vs singleton): same bytes, extra decode / larger materialize. Singleton is the fair picture of today's per-partition load cost (RQ1@20 ≈ 29–35 ms, RQ5@80 ≈ 260–330 ms).
  • The perf(index): read IVF prewarm in parallel byte windows #9049 win came from contiguous partition ranges on S3. That shape is not typical nprobe sets.

Tests

  • test_plan_query_load_groups_* and test_selected_partition_row_span_* cover sparse grouping, byte/partition caps, and contiguous vs gapped row spans.
  • test_query_partition_load_uses_windowed_io builds a 16-partition IVF_PQ index, compares grouped-load IOPS to a single-partition load (must be a small multiple, not N×), checks concurrent load_partitions does not deadlock, and asserts a follow-up query issues zero I/O.
  • Existing prewarm and test_global_topk_search_bounds_in_flight_prepared_partitions still pass.

Lint: cargo fmt --all and cargo clippy -p lance --tests --benches -- -D warnings passed. Full-workspace clippy was not rerun; only rust/lance/src/index/vector/ivf/v2.rs changed.

cargo test -p lance --lib -- index::vector::ivf::v2::: 116 passed.

Open in Web Open in Cursor 

Reuse the prewarm byte-window reader for query-time partition loads.
Uncached nprobes in a prepare chunk are grouped by encoded size and
read in one Range/Ranges decoder invocation, so adjacent partitions
share large sequential I/O and scattered probes share page-metadata
scheduling instead of one read_stream per partition.

Keep the prepare-window memory bound: search still streams chunks of
prepare_parallelism partitions and scores them in the existing
global-top-k and streaming batches.

Co-authored-by: Yang Cen <BubbleCal@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants