Skip to content

perf(fts): search partitions in pipelined chunks#7950

Open
LuQQiu wants to merge 2 commits into
lance-format:mainfrom
LuQQiu:lu/stream_pipeline
Open

perf(fts): search partitions in pipelined chunks#7950
LuQQiu wants to merge 2 commits into
lance-format:mainfrom
LuQQiu:lu/stream_pipeline

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

InvertedIndex::bm25_search spawns one cpu-pool task per partition (~350 per query on a 100-shard index). Under concurrent load this has two costs:

  1. Dispatch flood: at a few hundred qps this is ~100K tiny task dispatches/sec through the blocking pool.
  2. Locality loss: tens of thousands of in-flight tiny tasks interleave across cores, so the scoring loops (WAND/MAXSCORE, posting decompression) run with cold caches. Measured on a 320-core node: the same per-query scoring work costs ~5x more CPU at c64 than the batched layout, with identical index_comparisons (ANALYZE-verified — the comparison count is unchanged; the cost per comparison is what inflates).

Change

Partitions are searched in chunks of LANCE_FTS_SEARCH_CHUNK (default 16):

  • each chunk loads its postings and scoring DocSets concurrently (async, cache-backed),
  • then one cpu-pool task scores the chunk's partitions back-to-back on one thread,
  • chunks pipeline against each other via buffer_unordered, so chunk N scores while chunk N+1 loads.

A secondary benefit: partitions scored consecutively on one thread observe each other's published shared top-k floor immediately.

LANCE_FTS_SEARCH_CHUNK=1 restores the old one-task-per-partition behavior.

Results

320-core node, 100M-doc 42-language corpus, 5-term match_any (OR), tier-100-200 words, k=100, warm + prewarmed, exact scoring, 180s windows, bench returns _rowid + _score only:

concurrency per-partition tasks (before) chunked (after)
16 227 qps / 71 ms 428 qps / 37 ms
64 224 qps / 286 ms / 78% CPU 221 qps / 290 ms / 29% CPU

At c64 throughput is capped by a separate cache-read bottleneck (a follow-up PR swaps the backend); the chunked layout cuts the CPU burned per query ~5x (1.11 → 0.20 core-seconds), which that follow-up then converts into throughput: with the contention-free read path the chunked layout reaches 1340 qps at c128 vs 345 for per-partition tasks.

Tests

cargo test -p lance-index scalar::inverted — 332 passed. The multi-partition tests (41 partitions = 3 chunks) exercise the multi-chunk merge path with exact row_id assertions.

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer performance labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

BM25 inverted-index searches now process partitions in configurable chunks. Postings load concurrently within each chunk, while scoring runs sequentially in one CPU task and omits partitions without postings hits.

Changes

Chunked FTS search

Layer / File(s) Summary
Chunk configuration
rust/lance-index/src/scalar/inverted/index.rs
LANCE_FTS_SEARCH_CHUNK configures the partition chunk size, defaulting to 16 and clamping values to at least 1.
Chunked BM25 pipeline
rust/lance-index/src/scalar/inverted/index.rs
bm25_search groups partitions into chunks, concurrently loads postings, skips empty partitions, and sequentially scores loaded partitions in one CPU task. Empty PartitionCandidates construction is removed.

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

Sequence Diagram(s)

sequenceDiagram
  participant InvertedIndex
  participant AsyncLoader
  participant CpuScorer
  InvertedIndex->>AsyncLoader: Load postings for each partition in a chunk
  AsyncLoader-->>InvertedIndex: Return non-empty partition data
  InvertedIndex->>CpuScorer: Score loaded partitions sequentially
  CpuScorer-->>InvertedIndex: Return non-empty PartitionCandidates
Loading

Possibly related PRs

Suggested reviewers: bubblecal, xuanwo, westonpace

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: pipelined chunked partition search for FTS performance.
Description check ✅ Passed The description directly matches the code changes and explains the chunked BM25 search behavior and rationale.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.05941% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/scalar/inverted/index.rs 94.05% 0 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

One tiny cpu-pool task per partition (~350 per query) interleaves so
heavily under concurrent load that scoring loses cache locality and the
dispatch rate floods the blocking pool. Partitions are now searched in
chunks of LANCE_FTS_SEARCH_CHUNK (default 16): each chunk loads its
postings and scoring DocSets concurrently, then a single cpu task scores
the chunk's partitions back-to-back on one thread; chunks pipeline
against each other through buffer_unordered.

On a 320-core node against a 100M-doc 42-language corpus (5-term OR,
k=100, warm), per-query scoring CPU drops ~5x and c16 throughput
doubles (227 -> 428 qps at half the latency); combined with a
contention-free cache read path the chunked layout reaches 1340 qps at
c128 versus 345 for per-partition tasks.
@LuQQiu
LuQQiu force-pushed the lu/stream_pipeline branch from 01ca66d to 34e98d5 Compare July 23, 2026 19:51

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@rust/lance-index/src/scalar/inverted/index.rs`:
- Around line 1000-1004: Replace the unbounded try_join_all fan-out in the chunk
loading path with a bounded unordered stream using self.store.io_parallelism()
as the concurrency limit, while preserving error propagation and flattening all
loaded posting lists into the existing loaded collection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 60bc4dba-548e-4233-bf90-c85d39ea88f3

📥 Commits

Reviewing files that changed from the base of the PR and between 01ca66d and 34e98d5.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/inverted/index.rs

Comment thread rust/lance-index/src/scalar/inverted/index.rs Outdated
@LuQQiu

LuQQiu commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the arithmetic — the chunked layout does raise the number of pending load futures. We benchmarked the concern directly and added a bound; data below (320-core node, 100M-doc 42-language index, ~350 partitions/query, contention-free cache backend so cache effects don't mask load effects).

1. Cold path (empty cache, fresh process) — chunking helps, doesn't flood:

chunk=1 (per-partition tasks) chunk=4 chunk=16
c1 mean latency 210.3 ms 204.4 200.6
c16 95.4 qps / 168 ms 126.5 / 126 134.4 qps / 119 ms (+41%)

The reason pending futures don't translate into storage pressure: actual in-flight IO is bounded by the store's scheduler (DEFAULT_CLOUD_IO_PARALLELISM = 64 plus a byte budget), partitions are opened with io_priority = partition ordinal so earlier partitions' reads are strictly served first, and concurrent loads of the same entry collapse via the cache's single-flight get_or_insert.

2. Chunk-concurrency bound sweep (warm, the bound you suggested):

bound c1 c16 c128
4 34.6 qps / 28.9 ms 477.6 / 33.5 1289
8 40.7 / 24.6 498.4 / 32.1 1195
16 32.9 / 30.4 479.0 / 33.4 1266
32 (≡ unbounded here: ~23 chunks/query) 35.1 / 28.5 476.5 / 33.6 1174
unbounded (cpus) 35.7 / 28.0 479.3 / 33.4 1321

The 32-vs-unbounded row is identical by construction, so its 11% delta bounds run-to-run noise at ~±6% — every difference in the table is inside that band. The bound is effectively free, so the code now uses buffer_unordered(get_num_compute_intensive_cpus().min(32)): no behavior change for realistic partition counts, and a hard ceiling of 32 × chunk_size in-flight partition loads per query for pathological many-partition indexes. Memory held by a pending chunk is small in practice — loaded postings are Arcs into shared cache entries, not copies.

Chunk size itself was swept too (4/8/16/32 at c16 and c128): 16 is the knee (4 costs 24% of peak throughput, 32 is flat vs 16), hence the default.

@LuQQiu
LuQQiu force-pushed the lu/stream_pipeline branch from a5678ca to 84d8fe5 Compare July 23, 2026 23:39

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance-index/src/scalar/inverted/index.rs (1)

105-113: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject invalid LANCE_FTS_SEARCH_CHUNK values instead of silently defaulting.

Malformed values and 0 are silently replaced with 16, hiding configuration mistakes and making the effective concurrency differ from the requested value. Keep the default only for an unset variable; reject present invalid values with Error::invalid_input that includes the variable name and raw value, then propagate the error from bm25_search.

🤖 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-index/src/scalar/inverted/index.rs` around lines 105 - 113, Update
fts_search_chunk to return a Result and preserve 16 only when
LANCE_FTS_SEARCH_CHUNK is unset; for malformed or zero values, return
Error::invalid_input including the variable name and raw value. Propagate this
Result through bm25_search and update its callers as needed.

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.

Outside diff comments:
In `@rust/lance-index/src/scalar/inverted/index.rs`:
- Around line 105-113: Update fts_search_chunk to return a Result and preserve
16 only when LANCE_FTS_SEARCH_CHUNK is unset; for malformed or zero values,
return Error::invalid_input including the variable name and raw value. Propagate
this Result through bm25_search and update its callers as needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 8147fe44-0f0f-47c8-9793-6fee2b491e63

📥 Commits

Reviewing files that changed from the base of the PR and between 34e98d5 and 84d8fe5.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/inverted/index.rs

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

Labels

A-index Vector index, linalg, tokenizer performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant