Conversation
Each segment search keeps up to `prepare_parallelism` (CPU count) partitions in flight ahead of scoring, and a query fans out over every index segment on the node while queries run concurrently, so the node-wide prepare window was `segments * queries * CPUs` partitions. Add a process-wide semaphore (default 2 * CPUs, overridable with LANCE_IVF_PREPARE_PARTITION_BUDGET) that every partition load on the global top-k, streaming and multi-query batch paths acquires before loading and releases as soon as the prepared partition leaves the `buffered` window. Scoring chunks and channels never hold permits, so no budget can deadlock, and segments share the budget dynamically. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The process-wide memory contract needs to cover partition-parallel execution, not only the sequential search_partitions pipeline. Applying the same shared budget at the direct per-partition load/score boundary is the minimal complete fix; otherwise supported search modes retain the node-wide fan-out this change is meant to remove.
| } | ||
|
|
||
| let prepare_parallelism = get_num_compute_intensive_cpus().max(1); | ||
| let prepare_budget = self.prepare_budget.load_full(); |
There was a problem hiding this comment.
This process-wide cap is bypassed by partition-parallel searches, so the node can still retain segments * queries * parallelism loaded partitions and hit the same OOM mode. ANNIvfSubIndexExec calls search_partitions only when effective parallelism is at most 1; documented query_parallelism >= 2 instead buffers direct search_in_partition calls, and non-global-heap sub-indexes select that branch automatically. search_in_partition loads and then queues/scores the partition without acquiring this budget. Please acquire the same budget around the direct load/score path (no downstream chunk waits there) and add that branch to the regression.
Reproducer
Against 32b512bb891c8a62495936540eef48371e3345e6, I appended this to test_prepare_budget_of_one_bounds_window_without_deadlock after installing its one-permit budget:
let held_permit = budget.acquire().await.unwrap();
let part_id = probes[0].0.value(0) as usize;
let direct_search = index.search_in_partition(
part_id,
&queries[0],
Arc::new(NoFilter),
&NoOpMetricsCollector,
);
assert!(
tokio::time::timeout(std::time::Duration::from_secs(2), direct_search)
.await
.is_err(),
"partition-parallel search bypassed the process-wide prepare budget"
);
drop(held_permit);Then I ran:
cargo test -p lance --lib index::vector::ivf::v2::tests::test_prepare_budget_of_one_bounds_window_without_deadlock -- --exact --nocapture
Expected the direct search to remain blocked while the only permit was held. It completed instead, and the assertion failed with partition-parallel search bypassed the process-wide prepare budget.
Acquiring inside the buffered futures let later partitions take permits while an earlier one still waited; since `buffered` yields in order, the completed later partitions sat behind the waiting head holding permits, and eight concurrent probe-everything searches held the whole budget with none able to advance. Acquire sequentially in a `then` stage before `buffered` so a window's permit holders are always its oldest entries. Extend the budget test with eight contending searches under a budget of four, which deadlocked before this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The permit-ordering rewrite fixes the contended-search deadlock, but the process-wide coverage finding remains: documented partition-parallel searches still load and queue partitions through search_in_partition without acquiring the shared budget, retaining segments * queries * parallelism memory scaling. Apply the same budget at that direct load/score boundary to complete the node-wide cap.
Problem
Follow-up to #9213. Each
search_partitionscall keeps up toprepare_parallelism(the CPU count) partitions in flight ahead of scoring. A query fans out over every index segment on the node (a distributed table has tens: the incident cluster searched 13 segments per plan executor) and queries run concurrently, so the node-wide prepare window issegments * queries * CPUspartitions. With ~7 MiB IVF_RQ partitions on a 32-vCPU node that is 13 * 32 * 7 MiB ≈ 2.9 GiB per query just for the window, before the per-segment scoring chunks, and it grows with concurrency.Change
PreparePartitionBudget(atokio::sync::Semaphore), default2 * CPUspartitions, overridable withLANCE_IVF_PREPARE_PARTITION_BUDGET.bufferedwindow (.buffered(n).map(release_prepare_permit)), before it enters a scoring chunk or channel. Permits are acquired sequentially in probe order in athenstage ahead ofbuffered(a namedasync fn, since the compiler cannot type anasyncblock as athencallback):bufferedyields in order, so letting the buffered futures acquire on their own allowed later partitions to hold permits behind a waiting head, and eight concurrent probe-everything searches then held the whole budget with none able to advance (reproduced on the benchmark VM and in the new test). With in-order acquisition a window's permit holders are always its oldest entries, scoring never holds permits, and chunk assembly never waits on permits held by its own partitions, so no combination of budget, chunk and channel sizes can deadlock. The chunks stay bounded per segment search as before (GLOBAL_TOPK_CHUNK_BYTES,STREAMING_SEARCH_BATCH_SIZE).IVFIndexas anArcSwappointing at the process-wide instance, so tests can run a search under a tiny budget.Node-wide in-flight prepared partitions become
min(segments * queries * CPUs, 2 * CPUs); for the 32-vCPU, 13-segment case above the window drops from ≈2.9 GiB per query to ≈0.45 GiB shared by all queries.Validation
test_prepare_budget_of_one_bounds_window_without_deadlock: an IVF_PQ index with more partitions than any chunk or channel holds, searched under a budget of one partition through the global-top-k path (two searches concurrently), the streaming path (an early-stop control that never stops) and the multi-query batch path, plus eight concurrent global-top-k searches under a budget of four; all complete, return the same rows as under the default budget, and leave the permits released. The eight-search case deadlocked with in-future permit acquisition.cargo test -p lance --lib index::vector::ivf::v2(114 passed),io::exec::knn(152 passed),scanner::test::test_knn/test_ann(65 passed).cargo clippy --all --tests --benches -- -D warningsandRUSTDOCFLAGS="-D warnings" cargo doc -p lance --no-depsclean.main5d27099, this PR = 9932db3. Ad-hoc harness (not committed): 1M x 768-d random vectors, IVF_RQ8 with 1024 partitions (~780 KiB each), seeded queries after warm-up, 3 repetitions interleaved baseline/PR, means of the per-repetition means.concis the number of queries in flight in one process; peak RSS isru_maxrss. Lower latency / higher QPS / lower RSS is better.The harness has a single index segment, so the budget only bites under concurrency: 8 or 16 concurrent searches want
8 * 64/16 * 64partitions in flight against 128 permits. Cutting the cold-read concurrency 4x costs 3% throughput because the reads saturate the NVMe either way; the 16-query warm case gets faster because far fewer prepared partitions compete for the CPU pool at once. The memory difference is small here because ~780 KiB partitions are 50 MiB per window; on the multi-MiB partitions of a billion-row RQ index the same permit count is GiBs. The repo'svector_throughputbench (IVF_PQ, 1M x 768-d, k=50, nprobes=20, refine 10) run interleaved baseline/PR/baseline/PR: 1 thread 1.867 s / 1.974 s vs 1.883 s / 1.941 s, 16 threads 516 ms / 510 ms vs 512 ms / 516 ms, i.e. no difference beyond its run-to-run spread.🤖 Generated with Claude Code