Skip to content

fix(index): batch IVF streaming partition search off the CPU pool#7680

Open
a-agmon wants to merge 4 commits into
lance-format:mainfrom
a-agmon:fix/ivf-streaming-batched-search
Open

fix(index): batch IVF streaming partition search off the CPU pool#7680
a-agmon wants to merge 4 commits into
lance-format:mainfrom
a-agmon:fix/ivf-streaming-batched-search

Conversation

@a-agmon

@a-agmon a-agmon commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7642. Supersedes #7673 — the first commit is @wjones127's original
implementation from that PR, unchanged; the second applies the review
follow-ups and makes the batch size measurable.

Problem

The streaming partition search ran its whole recv/search/send loop inside one
spawn_cpu closure with blocking channel ops. If the prefilter's row mask wasn't
ready when that closure parked the pool thread, the mask's own spawn_cpu work
queued behind it and the query hung at 0% CPU — same class as #7423.

Fix

Channel recv/send move to the async task; only the pure-CPU search runs in
spawn_cpu, over a batch of partitions per dispatch (from #7673). On top of that:

  • Greedy batching: take one prepared partition, then drain whatever else is
    already ready, up to the batch size — instead of waiting for a full batch,
    which delayed the first search behind up to a batch of prepare I/O. Batch size
    adapts to producer speed: slow producer → batches of ~1 (old latency behavior),
    fast producer → full batches.
  • Cancellation: a dropped output receiver now stops the search within ~1
    partition (checked inside the CPU closure via Sender::is_closed(), since
    spawn_cpu work isn't cancellable from the async side).
  • Batch size is tunable via LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE (default 16).

Performance

Swept the batch size with prefiltered IVF_PQ late-search queries (100K×64d,
64 partitions, 0.2% filter selectivity, k=100 — each query streams ~30+
partitions), 512 queries at concurrency 16, on a 4-CPU box (2-thread pool):

config QPS p50 p99
old blocking single worker (3 runs) 372–379 38.0–39.1ms 58.7–64.2ms
new, batch=1 382 38.0ms 59.3ms
new, batch=4 366 40.3ms 61.2ms
new, batch=16 (default) 364–375 39.0–40.3ms 59.3–61.4ms
new, batch=64 365 39.6ms 60.6ms

No measurable regression vs the blocking baseline, and throughput is insensitive
to the batch size — with greedy draining, actual batches track producer speed
rather than the cap, so the env var is an escape hatch, not a required tuning.

Testing

  • Existing sequential-search tests pass, including the multi-batch test from
    fix(index): batch IVF streaming partition search off the CPU pool #7673, under any valid batch size (including 1).
  • New e2e smoke test: prefiltered IVF_HNSW_SQ search in a re-executed child
    process with LANCE_CPU_THREADS=1, asserting completion under a deadline.
  • On a deterministic deadlock repro: I tried, and it isn't practical — the hang
    is a race against prefilter-mask readiness that local filesystems always win
    (mask I/O is sub-millisecond, and local reads bypass the ObjectStore trait
    so latency can't be injected). Real object-storage latency is what loses the
    race. The smoke test documents this and guards the path end to end.

Summary by CodeRabbit

  • New Features

    • Streaming vector search now processes prepared partitions in configurable batches, which can improve throughput for larger searches.
  • Bug Fixes

    • Better handles early cancellation and closed result streams, helping searches stop promptly instead of doing unnecessary work.
    • Added safer fallback behavior when chunked search work spans multiple CPU dispatches.

wjones127 and others added 3 commits July 8, 2026 07:40
The streaming/sequential partition search ran its whole recv/search/send
loop inside a single `spawn_cpu` closure, using `blocking_recv` and
`blocking_send` on capacity-1 channels. Both park the CPU-pool thread, and
the producer feeding the input channel does object-store I/O whose partition
decode can itself need the CPU pool. On hosts whose CPU pool collapses to a
single thread (`<= 3` CPUs), or under enough concurrent queries, the parked
search thread and the work that would unblock it can starve each other and
deadlock the pool with a silent 0% hang — the same class as lance-format#7423.

Move the channel `recv`/`send` back into the async task and hand only the
pure-CPU search to `spawn_cpu`, over a batch of `STREAMING_SEARCH_BATCH_SIZE`
partitions at a time. No CPU-pool thread ever parks on a channel, so the
deadlock is structurally gone, while batching amortizes the per-dispatch
overhead the single-worker design in lance-format#6475 was avoiding (fan-out paid N/K
times instead of N). `should_stop` is still checked per partition, so
early-stop granularity is unchanged.

The `PreparedThreadCapturingIndex` test mock is updated to mirror the new
non-blocking structure. Adds a regression test that a search spanning
multiple batches still prepares and searches every partition in order.

Closes lance-format#7642

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the batched streaming search (lance-format#7673) with review follow-ups:

- Form batches greedily: wait for one prepared partition, then drain
  whatever else is already prepared, up to the batch size. Waiting for a
  full batch delayed the first search (and the early-stop feedback it
  produces) behind up to a whole batch of prepare I/O, which is
  significant when prepare parallelism is low. The prepared channel now
  holds a full batch so partitions prepared while the previous batch is
  searching are ready for the next drain.
- Stop pulling and searching as soon as the output receiver is dropped
  (cancelled queries no longer search out their whole batch first).
- Make the batch size tunable via LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE
  (default 16, must be > 0) so the perf sweep lance-format#7642 asks for can run
  without recompiles.
- Correct the comment on the defensive spawn_cpu error arm.
- Add an end-to-end smoke test that runs the streaming search path
  (HNSW sub-index, prefiltered stable-row-id query) in a child process
  with LANCE_CPU_THREADS=1. The old deadlock itself is a race against
  prefilter-mask readiness that local filesystems always win (mask I/O
  is sub-millisecond and local reads bypass the ObjectStore trait, so
  latency cannot be injected); the test documents that honestly and
  guarantees the path completes end to end on a single-thread pool.
…ellation

- Adapt test_sequential_initial_search_prepares_all_then_searches_on_one_cpu_thread
  to the configured batch size (min(3, batch)) instead of asserting
  3 <= batch, so every valid LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE,
  including 1, passes.
- Probe Sender::is_closed() per partition inside the spawn_cpu search
  closure (via a sender clone; it is synchronously callable), so a
  cancelled query stops after at most one more partition search instead
  of searching out the whole in-flight batch. A select! on closed()
  would not achieve this: spawn_cpu closures are not cancellable, so
  abandoning the await leaves the CPU work running.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@a-agmon a-agmon changed the title Fix/ivf streaming batched search fix(index): batch IVF streaming partition search off the CPU pool Jul 8, 2026
@github-actions github-actions Bot added the bug Something isn't working label Jul 8, 2026
@a-agmon

a-agmon commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Hi @wjones127 , @BubbleCal - would be happy for your review

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.89583% with 29 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/index/vector/ivf/v2.rs 78.26% 17 Missing and 3 partials ⚠️
rust/lance/src/io/exec/knn.rs 91.00% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@BubbleCal BubbleCal 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.

Overall good to me, we need to verify the performance deeper, could you try this:

  1. 1M x 1024d f32 vectors
  2. creating IVF_RQ index with the default index params(just not specify any index params)
  3. use prewarm to warm the index before benchmarking
  4. run with concurrency=1 and concurrency=(num_cores-2) to get the numbers

@a-agmon

a-agmon commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Sounds good @BubbleCal
I run the benchmark as follows:

  • 1M × 1024d f32 random vectors, IVF_RQ with default index params (1024 partitions = √N, num_bits=1, fast rotation), L2
  • Index prewarmed via prewarm_index before measuring
  • k=10, 1024 queries per run
  • 11 cores → concurrency = 1 and 9 (num_cores − 2)
  • 3 interleaved rounds of main vs this PR against the same dataset, to cancel out machine drift
  • Results (ranges across the 3 rounds)

      main this PR
    concurrency=1 95.7–97.9 QPS · p50 ≈ 10.1ms 93.2–99.0 QPS · p50 10.1–10.7ms
    concurrency=9 459–517 QPS · p50 16.7–18.6ms 485–520 QPS · p50 16.6–18.0ms

    No measurable difference — per-round deltas are under ~2% and flip sign between rounds. p90/p99 behave the same way.

    One note for context: unfiltered IVF_RQ queries take the collect-all/global-top-k path, which this PR doesn't modify — so this run is a no-regression check on the mainline query path. The code this PR actually changes (the batched streaming loop) runs for prefiltered late-search and for sub-indices without global-top-k support; the batch-size sweep in the PR description benchmarks that path directly against main and also shows parity.

    benchmark harness available here : https://gist.github.com/a-agmon/d22e1cf7045112b45bf6f619636af1ed (it's a small standalone example that builds the dataset on first run).

    @wjones127 wjones127 self-requested a review July 8, 2026 14:47

    @wjones127 wjones127 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.

    I think I'd like that test removed, but otherwise this fix looks good.

    Comment thread rust/lance/tests/ivf_stream_deadlock/mod.rs Outdated
    @coderabbitai

    coderabbitai Bot commented Jul 9, 2026

    Copy link
    Copy Markdown

    Review Change Stack

    📝 Walkthrough

    Walkthrough

    This PR reworks the streaming IVF partition search path to batch prepared partitions per spawn_cpu dispatch instead of processing them one at a time via blocking channel operations. A configurable batch size constant/static is added, the prepared-partition channel is resized, and the consumer loop is rewritten to greedily accumulate batches, checking early-stop and channel-closed conditions. Corresponding test mocks and cases are updated to mirror the new batching behavior.

    Changes

    Batched streaming partition search

    Layer / File(s) Summary
    Batch size configuration
    rust/lance/src/index/vector/ivf/v2.rs
    Adds LazyLock import, DEFAULT_STREAMING_SEARCH_BATCH_SIZE constant (16), and STREAMING_SEARCH_BATCH_SIZE lazy static read from LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE with parse/assert validation.
    Batched consumer rework in search_partitions
    rust/lance/src/index/vector/ivf/v2.rs
    Resizes the prepared-partition mpsc channel to the batch size, and rewrites the consumer to drain up to the batch size via recv/try_recv, dispatching one spawn_cpu per batch while handling early-stop, closed-channel, and error propagation.
    Test mock and coverage for batched search
    rust/lance/src/io/exec/knn.rs
    Imports the batch size constant, rewrites the PreparedThreadCapturingIndex mock to chunk and dispatch via spawn_cpu, adjusts the single-CPU-thread test to derive partition counts from the batch size, and adds a regression test for search spanning multiple batches.

    Estimated code review effort: 4 (Complex) | ~45 minutes

    Sequence Diagram(s)

    sequenceDiagram
      participant Producer
      participant PreparedChannel
      participant Consumer
      participant SpawnCpu
      participant BatchTx
    
      Producer->>PreparedChannel: send prepared partition
      Consumer->>PreparedChannel: recv first prepared partition
      Consumer->>PreparedChannel: try_recv additional partitions up to batch size
      Consumer->>SpawnCpu: dispatch single batch search task
      SpawnCpu->>SpawnCpu: check search_control.should_stop()
      SpawnCpu->>BatchTx: check is_closed() for cancellation
      SpawnCpu->>BatchTx: forward per-partition results/errors
      Consumer->>Consumer: repeat until producer done and batches handled
    
    Loading
    🚥 Pre-merge checks | ✅ 5
    ✅ Passed checks (5 passed)
    Check name Status Explanation
    Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
    Title check ✅ Passed The title clearly summarizes the main change: batching IVF streaming partition search off the CPU pool.
    Linked Issues check ✅ Passed The changes move channel ops off the CPU pool, preserve sequential CPU reuse via batching, and add the requested single-thread regression coverage for #7642.
    Out of Scope Changes check ✅ Passed The added batching, cancellation, config, and test updates all support the deadlock fix and stay within the linked issue scope.
    Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
    ✨ Finishing Touches
    🧪 Generate unit tests (beta)
    • Create PR with unit tests

    Comment @coderabbitai help to get the list of available commands.

    @a-agmon

    a-agmon commented Jul 9, 2026

    Copy link
    Copy Markdown
    Contributor Author

    I think I'd like that test removed, but otherwise this fix looks good.

    Good point. Thanks @wjones127.
    Test removed.

    @coderabbitai coderabbitai Bot left a comment

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

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

    🧹 Nitpick comments (1)
    rust/lance/src/index/vector/ivf/v2.rs (1)

    145-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

    Avoid .expect()/assert!() panics in library init; log and fall back instead.

    A malformed or non-positive LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE will panic on first search rather than degrade gracefully. Note the assert!(batch_size > 0) is load-bearing (a 0 would reach mpsc::channel(0) at Line 1664, which panics in tokio), so the invariant must stay — but both can be enforced without a panic.

    As per coding guidelines: "Never use .unwrap(), .expect(), panic!(), or assert!() in library code for fallible operations."

    ♻️ Log a warning and fall back to the default
     pub(crate) static STREAMING_SEARCH_BATCH_SIZE: LazyLock<usize> = LazyLock::new(|| {
    -    let batch_size = std::env::var("LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE")
    -        .map(|value| {
    -            value
    -                .parse()
    -                .expect("failed to parse LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE")
    -        })
    -        .unwrap_or(DEFAULT_STREAMING_SEARCH_BATCH_SIZE);
    -    assert!(
    -        batch_size > 0,
    -        "LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE must be greater than 0, got {batch_size}"
    -    );
    -    batch_size
    +    match std::env::var("LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE") {
    +        Err(_) => DEFAULT_STREAMING_SEARCH_BATCH_SIZE,
    +        Ok(value) => match value.parse::<usize>() {
    +            Ok(n) if n > 0 => n,
    +            _ => {
    +                warn!(
    +                    "invalid LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE={value:?}; \
    +                     falling back to {DEFAULT_STREAMING_SEARCH_BATCH_SIZE}"
    +                );
    +                DEFAULT_STREAMING_SEARCH_BATCH_SIZE
    +            }
    +        },
    +    }
     });

    Requires a warn! import if not already in scope.

    🤖 Prompt for AI Agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    In `@rust/lance/src/index/vector/ivf/v2.rs` around lines 145 - 158, Replace the
    panic-based initialization in STREAMING_SEARCH_BATCH_SIZE with fallible parsing
    and validation that logs a warning and falls back to
    DEFAULT_STREAMING_SEARCH_BATCH_SIZE instead of using .expect() or assert!().
    Update the LazyLock closure in v2.rs so malformed, missing, zero, or negative
    LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE values are handled gracefully, while still
    enforcing the > 0 invariant before the value is used by the search path that
    leads to mpsc::channel in the IVF v2 flow. Use the existing
    STREAMING_SEARCH_BATCH_SIZE symbol and add a warn! message that explains the
    invalid env var and the fallback.
    

    Source: Coding guidelines

    🤖 Prompt for all review comments with AI agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    Nitpick comments:
    In `@rust/lance/src/index/vector/ivf/v2.rs`:
    - Around line 145-158: Replace the panic-based initialization in
    STREAMING_SEARCH_BATCH_SIZE with fallible parsing and validation that logs a
    warning and falls back to DEFAULT_STREAMING_SEARCH_BATCH_SIZE instead of using
    .expect() or assert!(). Update the LazyLock closure in v2.rs so malformed,
    missing, zero, or negative LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE values are
    handled gracefully, while still enforcing the > 0 invariant before the value is
    used by the search path that leads to mpsc::channel in the IVF v2 flow. Use the
    existing STREAMING_SEARCH_BATCH_SIZE symbol and add a warn! message that
    explains the invalid env var and the fallback.
    

    ℹ️ Review info
    ⚙️ Run configuration

    Configuration used: defaults

    Review profile: CHILL

    Plan: Pro Plus

    Run ID: 4f91e922-380b-4a0c-88aa-2b598844b53f

    📥 Commits

    Reviewing files that changed from the base of the PR and between d581bb9 and 04bed2b.

    📒 Files selected for processing (2)
    • rust/lance/src/index/vector/ivf/v2.rs
    • rust/lance/src/io/exec/knn.rs

    @a-agmon

    a-agmon commented Jul 9, 2026

    Copy link
    Copy Markdown
    Contributor Author

    🧹 Nitpick comments (1)

    rust/lance/src/index/vector/ivf/v2.rs (1)> 145-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

    Avoid .expect()/assert!() panics in library init; log and fall back instead.
    A malformed or non-positive LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE will panic on first search rather than degrade gracefully. Note the assert!(batch_size > 0) is load-bearing (a 0 would reach mpsc::channel(0) at Line 1664, which panics in tokio), so the invariant must stay — but both can be enforced without a panic.
    As per coding guidelines: "Never use .unwrap(), .expect(), panic!(), or assert!() in library code for fallible operations."

    ♻️ Log a warning and fall back to the default

     pub(crate) static STREAMING_SEARCH_BATCH_SIZE: LazyLock<usize> = LazyLock::new(|| {
    -    let batch_size = std::env::var("LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE")
    -        .map(|value| {
    -            value
    -                .parse()
    -                .expect("failed to parse LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE")
    -        })
    -        .unwrap_or(DEFAULT_STREAMING_SEARCH_BATCH_SIZE);
    -    assert!(
    -        batch_size > 0,
    -        "LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE must be greater than 0, got {batch_size}"
    -    );
    -    batch_size
    +    match std::env::var("LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE") {
    +        Err(_) => DEFAULT_STREAMING_SEARCH_BATCH_SIZE,
    +        Ok(value) => match value.parse::<usize>() {
    +            Ok(n) if n > 0 => n,
    +            _ => {
    +                warn!(
    +                    "invalid LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE={value:?}; \
    +                     falling back to {DEFAULT_STREAMING_SEARCH_BATCH_SIZE}"
    +                );
    +                DEFAULT_STREAMING_SEARCH_BATCH_SIZE
    +            }
    +        },
    +    }
     });

    Requires a warn! import if not already in scope.

    🤖 Prompt for AI Agents

    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    In `@rust/lance/src/index/vector/ivf/v2.rs` around lines 145 - 158, Replace the
    panic-based initialization in STREAMING_SEARCH_BATCH_SIZE with fallible parsing
    and validation that logs a warning and falls back to
    DEFAULT_STREAMING_SEARCH_BATCH_SIZE instead of using .expect() or assert!().
    Update the LazyLock closure in v2.rs so malformed, missing, zero, or negative
    LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE values are handled gracefully, while still
    enforcing the > 0 invariant before the value is used by the search path that
    leads to mpsc::channel in the IVF v2 flow. Use the existing
    STREAMING_SEARCH_BATCH_SIZE symbol and add a warn! message that explains the
    invalid env var and the fallback.
    

    Source: Coding guidelines

    🤖 Prompt for all review comments with AI agents

    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    Nitpick comments:
    In `@rust/lance/src/index/vector/ivf/v2.rs`:
    - Around line 145-158: Replace the panic-based initialization in
    STREAMING_SEARCH_BATCH_SIZE with fallible parsing and validation that logs a
    warning and falls back to DEFAULT_STREAMING_SEARCH_BATCH_SIZE instead of using
    .expect() or assert!(). Update the LazyLock closure in v2.rs so malformed,
    missing, zero, or negative LANCE_IVF_STREAMING_SEARCH_BATCH_SIZE values are
    handled gracefully, while still enforcing the > 0 invariant before the value is
    used by the search path that leads to mpsc::channel in the IVF v2 flow. Use the
    existing STREAMING_SEARCH_BATCH_SIZE symbol and add a warn! message that
    explains the invalid env var and the fallback.
    

    📥 Commits
    Reviewing files that changed from the base of the PR and between d581bb9 and 04bed2b.

    📒 Files selected for processing (2)

    • rust/lance/src/index/vector/ivf/v2.rs
    • rust/lance/src/io/exec/knn.rs

    Deliberate, following the predominant pattern for lance's compute knobs — LANCE_CPU_THREADS, LANCE_FTS_WRITE_QUEUE_SIZE, and LANCE_FTS_POSTING_BATCH_ROWS all fail loudly on malformed values rather than falling back. For a tuning knob used in perf sweeps, silently substituting the default on a typo'd value seems worse than failing fast with a clear message (you'd unknowingly benchmark the wrong config). Happy to switch to warn-and-default if maintainers prefer the LANCE_FILE_WRITER_MAX_PAGE_BYTES convention here.

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    bug Something isn't working

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    spawn_cpu deadlock risk in IVF streaming partition search (deferred from #7637)

    3 participants