Skip to content

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

Merged
LuQQiu merged 4 commits into
lance-format:mainfrom
LuQQiu:lu/stream_pipeline
Jul 24, 2026
Merged

perf(fts): search partitions in pipelined chunks#7950
LuQQiu merged 4 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 in one CPU task. Tests cover global scoring across impact and legacy partitions.

Changes

Chunked FTS search

Layer / File(s) Summary
Chunk configuration
rust/lance-index/src/scalar/inverted/index.rs
LANCE_FTS_SEARCH_CHUNK configures 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 concurrently loads postings per chunk, skips empty partitions, and scores loaded partitions in one CPU task. Empty PartitionCandidates construction is removed.
Threshold regression coverage
rust/lance-index/src/scalar/inverted/index.rs
Global-scoring tests support configurable impact metadata and verify threshold behavior for impact, mixed, and legacy partitions.

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 PartitionCandidates
Loading

Possibly related PRs

Suggested reviewers: bubblecal, xuanwo, jackye1995

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly captures the main change: searching partitions in pipelined chunks for FTS performance.
Description check ✅ Passed The description is detailed and directly matches the chunked BM25 search changes in the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 95.07042% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/scalar/inverted/index.rs 95.07% 0 Missing and 7 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

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Chunked dispatch addresses a real performance problem, but I found one exactness blocker in the legacy/no-impact path. The reproducer and candidate fixes are attached inline.

let partition_threshold = if use_global_scorer {
impact_shared_threshold
} else {
legacy_shared_threshold

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sharing legacy_shared_threshold across the partitions searched sequentially in a chunk can drop the true global top-k result for legacy/no-impact indexes.

Those partitions use IndexBM25Scorer, whose IDF and document normalization are partition-local, but publish their local kth score into the same threshold. The next partition may therefore prune against a score from a different scale before the surviving candidates are rescored globally. With the reproducer below, the correct global winner is row 200 with score 2.0739653, but the default chunked path returns row 30000 with score 1.3931961.

This is an existing schedule-sensitive threshold bug that the new sequential chunk loop makes stable: on this fixture, the current head with the default chunk failed 20/20 runs, while the exact base and the current head with LANCE_FTS_SEARCH_CHUNK=1 passed 20/20. The latter is therefore a mitigation, not a root fix.

A minimal fix could preserve the batching work while giving each legacy partition a private threshold, reserving the shared threshold for paths that use the globally comparable scorer. Alternatively, legacy pruning could use the global scorer and bounds if those bounds can be made sound. In either case, it would be useful to keep this as a two-legacy-partition regression that asserts both the row id and global score.

If the broader scheduling shape is revisited later, a query-global bounded prepare stage followed by greedy ready micro-batches could retain the dispatch reduction while avoiding the static chunk load barrier. That is a separate tradeoff and is not required to close this correctness issue.

Reproducer run on 8282923f

Add this test next to the existing global-scoring tests:

#[tokio::test]
async fn test_chunked_legacy_threshold_keeps_global_winner() {
    let tmpdir = TempObjDir::default();
    let store = Arc::new(LanceIndexStore::new(
        ObjectStore::local().into(),
        tmpdir.clone(),
        Arc::new(LanceCache::no_cache()),
    ));

    let first_num_docs = 20_001_u32;
    let mut first = InnerBuilder::new_with_format_version(
        0,
        false,
        TokenSetFormat::default(),
        InvertedListFormatVersion::V1,
    );
    first.tokens.add("alpha".to_owned());
    first.tokens.add("beta".to_owned());
    first
        .posting_lists
        .push(PostingListBuilder::new_with_posting_tail_codec(
            false,
            PostingTailCodec::Fixed32,
        ));
    first
        .posting_lists
        .push(PostingListBuilder::new_with_posting_tail_codec(
            false,
            PostingTailCodec::Fixed32,
        ));
    for doc_id in 0..first_num_docs {
        if doc_id.is_multiple_of(2) {
            first.posting_lists[0].add(doc_id, PositionRecorder::Count(1));
        }
        if !doc_id.is_multiple_of(2) || doc_id + 1 == first_num_docs {
            first.posting_lists[1].add(doc_id, PositionRecorder::Count(1));
        }
        first.docs.append(10_000 + u64::from(doc_id), 5_000);
    }
    write_test_partition_with_optional_impacts(
        &store,
        0,
        first,
        TokenSetFormat::default(),
        false,
    )
    .await;

    let mut second = InnerBuilder::new_with_format_version(
        1,
        false,
        TokenSetFormat::default(),
        InvertedListFormatVersion::V1,
    );
    second.tokens.add("alpha".to_owned());
    second.tokens.add("beta".to_owned());
    second
        .posting_lists
        .push(PostingListBuilder::new_with_posting_tail_codec(
            false,
            PostingTailCodec::Fixed32,
        ));
    second
        .posting_lists
        .push(PostingListBuilder::new_with_posting_tail_codec(
            false,
            PostingTailCodec::Fixed32,
        ));
    second.posting_lists[0].add(0, PositionRecorder::Count(1));
    second.posting_lists[1].add(0, PositionRecorder::Count(1));
    second.docs.append(200, 1_000);
    for row_id in 201..301 {
        second.docs.append(row_id, 1);
    }
    write_test_partition_with_optional_impacts(
        &store,
        1,
        second,
        TokenSetFormat::default(),
        false,
    )
    .await;

    write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
    let cache = LanceCache::with_capacity(64 * 1024 * 1024);
    let index = InvertedIndex::load(store, None, &cache).await.unwrap();
    index
        .prewarm_with_options(&FtsPrewarmOptions::default())
        .await
        .unwrap();

    let tokens = Arc::new(Tokens::new(
        vec!["alpha".to_owned(), "beta".to_owned()],
        DocType::Text,
    ));
    let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
    let global = index
        .bm25_base_scorer(tokens.as_ref(), params.as_ref())
        .await
        .unwrap();
    let first_partition = index
        .partitions
        .iter()
        .find(|partition| partition.id() == 0)
        .unwrap();
    let second_partition = index
        .partitions
        .iter()
        .find(|partition| partition.id() == 1)
        .unwrap();
    let first_local = IndexBM25Scorer::new(std::iter::once(first_partition.as_ref()));
    let second_local = IndexBM25Scorer::new(std::iter::once(second_partition.as_ref()));
    let first_local_score = ["alpha", "beta"]
        .into_iter()
        .map(|token| first_local.query_weight(token) * first_local.doc_weight(1, 5_000))
        .sum::<f32>();
    let second_local_score = ["alpha", "beta"]
        .into_iter()
        .map(|token| second_local.query_weight(token) * second_local.doc_weight(1, 1_000))
        .sum::<f32>();
    let first_global_score = ["alpha", "beta"]
        .into_iter()
        .map(|token| global.query_weight(token) * global.doc_weight(1, 5_000))
        .sum::<f32>();
    let second_global_score = ["alpha", "beta"]
        .into_iter()
        .map(|token| global.query_weight(token) * global.doc_weight(1, 1_000))
        .sum::<f32>();
    assert!(first_local_score > second_local_score);
    assert!(second_global_score > first_global_score);

    let (row_ids, scores) = index
        .bm25_search(
            tokens,
            params,
            Operator::And,
            Arc::new(NoFilter),
            Arc::new(NoOpMetricsCollector),
            None,
        )
        .await
        .unwrap();
    assert_eq!(
        row_ids,
        vec![200],
        "scores={scores:?}, first_local={first_local_score}, \
         second_local={second_local_score}, first_global={first_global_score}, \
         second_global={second_global_score}"
    );
}

Run:

cargo test -p lance-index \
  scalar::inverted::index::tests::test_chunked_legacy_threshold_keeps_global_winner \
  -- --exact --nocapture

Current-head failure:

left:  [30000]
right: [200]
scores=[1.3931961]
first_local=1.3861945
second_local=0.2211894
first_global=1.3931961
second_global=2.0739653

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch — fixed in b537edf with the minimal approach you suggested: the shared floor is now reserved for the impact (globally comparable) scorer, and each legacy partition seeds a private -inf floor, so cross-partition pruning never sees a score from a different scale. Added the two-legacy-partition regression you asked for (test_two_legacy_partitions_keep_private_thresholds): both partitions in one chunk, asserting the global winner's row id (200) and its globally-rescored BM25 score. Verified it bites — reintroducing the shared legacy floor makes it fail exactly as your reproducer does (returns partition 0's row 100).

Legacy (no-impact) partitions score with partition-local BM25 statistics,
so their scores are not comparable across partitions. Publishing them
into a threshold shared by the whole query lets one partition's local
k-th score prune another partition's true global winner before global
rescoring. The sequential chunk loop made this pre-existing
schedule-sensitive bug deterministic. Keep the shared floor only for the
globally-comparable impact scorer and seed each legacy partition with a
private floor. Regression: two legacy partitions in one chunk, asserting
the global winner's row id and score.

@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-111: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clamp zero to one instead of reverting to the default.

LANCE_FTS_SEARCH_CHUNK=0 is filtered out and becomes 16, not 1; this violates the stated “clamped to at least one” behavior and can unexpectedly increase concurrent work. Use map(|n| n.max(1)), and add parser-level tests for unset, zero, and a positive value.

Proposed fix
-            .filter(|&n| n >= 1)
+            .map(|n: usize| n.max(1))
             .unwrap_or(16)

As per coding guidelines, “Every bugfix and feature must have corresponding tests.”

🤖 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 - 111, Update
fts_search_chunk so parsed values are clamped with n.max(1), causing zero to
resolve to one while preserving the default of 16 only when the variable is
unset or unparsable. Add parser-level tests covering an unset variable, zero,
and a positive value.

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-111: Update fts_search_chunk so parsed values are clamped with
n.max(1), causing zero to resolve to one while preserving the default of 16 only
when the variable is unset or unparsable. Add parser-level tests covering an
unset variable, zero, and a positive value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: d028f762-f346-4a03-af98-820593194e1a

📥 Commits

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

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

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice work, let's move!

@LuQQiu
LuQQiu merged commit d148e42 into lance-format:main Jul 24, 2026
34 checks passed
LuQQiu added a commit that referenced this pull request Jul 24, 2026
)

## Problem

moka records every cache **hit** into a single global read-op channel
(input for its LRU/TinyLFU policy), and readers trigger inline
housekeeping when the channel passes its flush point. Each warm hit
therefore touches globally-shared cache lines (`record_read_op`: channel
length check + housekeeper try-lock CAS + try_send). At index-cache read
rates this collapses:

Pure `get` microbenchmark, 50K entries x 4KB, 100% hits, 320-core host:

| readers | moka | quick_cache |
|---|---|---|
| 1 | 2.87 Mops/s (348 ns/op) | 12.05 Mops/s (83 ns/op) |
| 64 | 0.74 (1345 ns) | 11.03 (91 ns) |
| 256 | **0.31 (3266 ns)** — negative scaling | **13.80 (72 ns)** — flat
|

A warm fts query performs ~2000 cache reads (posting groups + row-id
columns across ~350 partitions), so moka's ceiling imposes a hard qps
cap. Background `run_pending_tasks` draining was tested and does not
help (~+7%): at these rates the 384-slot channel refills past its
64-entry flush point every ~0.13 ms, and the per-read atomics remain.
This is the known, still-open moka issue moka-rs/moka#498.

## Change

A `CacheBackend` backed by
[quick_cache](https://crates.io/crates/quick_cache), whose
S3-FIFO/CLOCK-family policy records a hit by setting one reference bit
**on the entry itself** — no read log, no reader-triggered housekeeping.
Single-flight `get_or_insert` maps to `get_value_or_guard_async`;
weighted capacity maps to `Weighter` (u64 weights).

**Scope: the session index cache uses this backend; the metadata cache
and other general caches stay on moka.** The index cache is where the
read-rate wall bites (thousands of cache reads per query on the search
path), while moka's richer feature set remains available everywhere
else. Custom backends via `Session::with_index_cache_backend` are
unaffected.

## Results on a real workload

100M-doc 42-language corpus, 5-term `match_any` (OR), k=100, warm +
prewarmed, 320-core node, exact scoring, identical results; same binary
(with #7950's chunked search), only the backend differs:

| concurrency | moka | quick_cache |
|---|---|---|
| 16 | 427.6 qps / 37 ms / 22% CPU | 489.3 qps / 33 ms / 25% CPU |
| 64 | 220.8 qps / 290 ms / 29% CPU | **1238.7 qps / 52 ms / 79% CPU** |
| 128 | 180.7 qps / 710 ms / 47% CPU | **1340.6 qps / 96 ms / 93% CPU**
|
| 192 | 167.7 qps / 1148 ms / 66% CPU | **1193.9 qps / 161 ms / 86%
CPU** |

The moka column inverts with concurrency (contention); the quick_cache
column peaks at 93% CPU doing scoring work.

## Semantics and scope

- Correctness does not depend on eviction order anywhere in lance:
entries may be evicted at any time and reload via single-flight (covered
by existing fts eviction/reload tests).
- Feature parity for what lance actually uses: weighted capacity, async
single-flight, iteration, prefix invalidation (implemented via
iteration; it has no production call sites today). moka-only features
lance does not use: TTL, time-to-idle, eviction listeners.
- Eviction policy differs (S3-FIFO vs TinyLFU): comparable hit ratios on
published traces, and scan-resistant. Flipping the default should follow
a hit-rate comparison under forced memory pressure on representative
workloads; until then this is opt-in.

## Tests

New backend test exercises roundtrip, single-flight load dedup,
weighted-capacity enforcement under overfill, and clear, through the
standard `LanceCache` API. `cargo test -p lance-core` green.


## Shard sizing (second commit)

quick_cache splits its weight budget evenly across shards with **no
cross-shard borrowing**, and an entry heavier than ~its shard's share is
silently refused admission (maintainer-confirmed behavior,
arthurprs/quick-cache#55). Its default shard count
(`available_parallelism × 4`, rounded up to a power of two — 2048 on a
320-core host) is tuned for many-small-entry workloads; index-cache
entries are MB-to-hundreds-of-MB posting groups, so small shares risk
silent rejection and per-shard imbalance waste.

The backend now derives the shard count as `min(cpus / 2, capacity / 4
GiB)`, rounded down to a power of two and clamped to [1, 1024]. The cpu
term bounds lock contention (more shards than concurrent threads buys
nothing), and the capacity term keeps every shard's share >= 4 GiB so
the largest index-cache entries stay admissible — the same shape as
RocksDB's block-cache default (`capacity / min_shard_size, capped`).

Shard-count sweep (pure get, 50K × 4KB entries, 320-core host, 256
readers) shows contention is a non-issue at low shard counts — even a
**single-shard** quick_cache is 10x moka, and throughput plateaus from
~64 shards:

| shards | 1 | 4 | 16 | 64 | 128 | 256-2048 | (moka) |
|---|---|---|---|---|---|---|---|
| Mops/s @ 256 readers | 6.1 | 11.3 | 11.9 | 12.2 | 13.5 | ~13.4-14.2 |
0.63 |

On a 320-core host with 960 GiB capacity this yields 128 shards (7.5 GiB
share, on the throughput plateau); a 4-core host with 8 GiB yields 2
shards (4 GiB share) — entries up to ~3.9 GiB stay admissible
everywhere. A standalone matrix bench across machine profiles (workers
1-320, capacities 2 GiB-1.25 TiB, cache/working-set ratios 0.1-2.0,
lance-style 90% 1 MiB + 10% 128 MiB entry mix) confirms every landing
point clears the measured demand (~3 Mops of cache gets at 1300 qps FTS)
by 2-10x under eviction pressure and 20-100x warm, while moka baselines
sit at 0.2-4.7 Mops and degrade as workers grow.

End-to-end validation (real 100M-doc 42-language FTS index, prewarmed,
c128, 90s/arm, 960 GiB cache): shard counts 1 / 16 / 64 / 128 / 512 /
2048 all measure 1178-1323 qps — inside run-to-run noise — versus ~180
qps on moka. Warm hits take no lock (atomic CLOCK bit), so shard count
only affects admission and eviction-phase contention; sizing by
admission margin is free. The formula also auto-sized the co-resident 36
GiB metadata cache to 8 shards.
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.

2 participants