Conversation
The bulk conjunction path evaluated the exact `score_sum_cannot_compete` bound 64 times per window to fill the frequency prune table and once more per surviving candidate. Both decisions are monotone in the first clause's partial score, so compute the largest rejected f32 score once per (floor, followers' block max) pair and reduce every later decision to a single f32 comparison. Decisions are bit-identical to the exact bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Once a block's frequency stream has been decoded, `next_doc_id` can fill the candidate's frequency directly instead of leaving `doc()` to re-materialize it for every candidate. Single-clause conjunctions visit every candidate of a block they decode, so they paid that cost on each one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When a clause has no document in the current window, start the next window at that clause's next document instead of the next block boundary. A sparse lead then drives the traversal and dense followers skip whole blocks by metadata rather than being sliced block by block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reuse the previous slice end when a clause stays in the same block, skip the end search when the block ends inside the window, rebuild the frequency prune table only when the floor or the followers' block maxes change, run the generic cursor merge as an AVX2-specialized kernel, and stop preparing score-first windows the bulk path never reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| let others_block_max = | ||
| others_block_max.expect("positive floor should initialize bounds"); | ||
| let key = (self.threshold.to_bits(), others_block_max.to_bits()); | ||
| if first_score_limit_key != Some(key) { |
There was a problem hiding this comment.
From codex: Could this skip initializing freq_cannot_beat after a zero-to-positive floor transition?
If a window starts with threshold == 0, the per-candidate path below can later set first_score_limit_key once the heap fills, while freq_cannot_beat is still the all-false default. If the next window has the same threshold and others_block_max, this key comparison would skip the body, so the frequency LUT would remain disabled until the key changes.
I may be missing an invariant that forces the key to change between those windows. Could we add a focused two-window test where the floor becomes positive mid-window and the follower bounds stay unchanged, and verify that the second window actually applies frequency pruning?
There was a problem hiding this comment.
Good catch, this was real. When a window starts with a zero floor, the heap can fill in pass B and the per-candidate prune caches the limit under (threshold, others_block_max), but the table stays all-false. If the next window has the same floor and the same follower block max, the shared key matched and the table was never built, so kernel-level frequency pruning stayed off until the key changed. Results were not affected (the per-candidate prune uses the same limit), only the pruning was lost.
Fixed in 9834e75 by keying the table separately from the limit, so each is rebuilt only when it is stale. I added the two-window test you described (bulk_and_builds_frequency_prune_table_after_mid_window_floor): the floor turns positive on the first candidate of window 1, the dense clause has the same block max in both blocks so the key is unchanged, and the test asserts that window 2 applies the prune table. It fails on the previous commit (0 pruned windows) and passes now; results and the final floor still match the classic loop.
The per-candidate prune refreshes the cached score limit when the floor turns positive in the middle of a window, but it never touched the frequency prune table. A following window with the same floor and the same follower block max then matched the cached key and kept the all-false table, so kernel-level frequency pruning stayed off until the key changed. Results were unaffected; only the pruning was lost. Key the table separately from the limit so each is rebuilt when stale. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
The current revision fixes the mid-window floor-transition cache gap identified in the review discussion. The cache is now keyed after the transition, and the focused regression plus the WAND suite on the refreshed base pass; scoring and result membership remain unchanged.
The remaining non-blocking risk is rollout performance: the benchmark driver and raw runs are not in the tree, several concurrency-8 latency cells are flat or slightly slower, and 5-token short-list CPU still inherits #9030 routing overhead. Monitor production query-shape buckets; LANCE_FTS_BULK_AND=off remains the bounded rollback for conjunction regressions. No code change is requested.
Performance issue
A production FTS workload that is mostly 1–3-token
MatchQueryAND searches regressed between the 2026-08-14 and 2026-09-14 releases. Bisecting lance with production build flags on long posting lists showed two sources:score_sum_cannot_competeevaluations and re-ran the same exact bound for every surviving candidate. That is +18–22% CPU per 2/3-token query; the AVX2 merge kernels and bitpack decoding were unchanged andindex_comparisonsdid not move.doc()re-materializing the frequency for every candidate, which single-clause queries pay on every candidate of every block they decode (+6% CPU on 1-token queries).How this PR improves it
exclusive_partial_score_limitcomputes, once per (floor, followers' block max) pair, the largestf32partial score thatscore_sum_cannot_competerejects under the exclusive floor. Both the frequency prune table and the per-candidate prune are monotone in that score, so each becomes a singlef32comparison. The decisions are bit-identical to the exact bound; a property test sweeps ULP neighbourhoods of the boundary across floors, remaining bounds and clause counts.next_doc_idcarries the candidate's frequency when the block's frequency stream is already decoded, sodoc()takes its fast path instead of re-materializing per candidate. Frequencies stay lazy for blocks that are never scored.No query API, scoring, or index format changes.
Correctness
f32scores, k=10) for 14 query sets on two corpora are bit-identical betweenmainand this PR, and between this PR and the Sept-14 release wheel (score_bit_diff=0,row_set_diff=0; the synthetic corpus only differs in tie order, exactly as two builds of the same commit do).cargo test -p lance-index --lib scalar::inverted: 788 passed.cargo test -p lance --lib fts: 249 passed. New tests:exclusive_partial_score_limit_matches_predicate,exclusive_partial_score_limit_handles_non_finite_inputs,bulk_and_windows_jump_to_next_lead_document.cargo fmt --allclean;cargo clippy -p lance-index --tests -- -D warningsclean.Benchmark
Environment: AWS c7i.16xlarge (Sapphire Rapids, 64 vCPU, 123 GiB), Ubuntu 24.04, rustc 1.97.0. Each build is a pylance wheel built with the production plan-executor flags (
--release,-C target-cpu=skylake -C target-feature=+avx2,+fma,+f16c -C force-frame-pointers=yes -C debuginfo=1). Baseline ismainat 2602724; this PR is that commit plus these four commits; the Aug-14 release column (v11.0.0-beta.10) is the pre-regression reference. Only the wheel changes between runs.Datasets:
mmlb_10m(10M synthetic ~900-token documents, ~10K vocabulary, long posting lists, V3 index, block size 256, 6 partitions) and MS MARCO passages (8.8M real passages, short posting lists, 1 partition). Queries: 300 per set, k=10,MatchQuerywithoperator=AND, index prewarmed; "mid-df" = tokens with document frequency 1–6%, "sequential" = consecutive tokens from real documents (any frequency), "rare" = df 0.02–0.3%, "rare+common" = at least one rare and one common token.Methodology: closed loop from one Python process, 3 s warm-up then 12 s measured per cell, three passes in forward/reverse/forward order, values are means over the three passes. CPU ms per query is process CPU time divided by completed queries (the metric that maps to plan-executor saturation); p50 is the client-observed latency. Lower is better for both. Concurrency 1 isolates per-query cost; concurrency 8 shows the same under moderate parallelism.
Concurrency 1, CPU ms per query (lower is better; mean of N passes)
main2602724mainConcurrency 1, p50 latency ms (lower is better)
mainmainConcurrency 8, CPU ms per query (lower is better; mean of N passes)
main2602724mainConcurrency 8, p50 latency ms (lower is better)
mainmainLimitations
main(0.96x) while its CPU improves 1.05x.🤖 Generated with Claude Code