MB-72489: standalone query hot-path optimizations#2381
Conversation
…non-KNN queries
For score-sorted queries with no field loading, the collector's per-doc
hot path carried dead overhead:
- adjustDocumentMatch is a no-op when hc.knnHits == nil; guard both call
sites with knnHits != nil so the call is skipped entirely for non-KNN
queries.
- prepareDocumentMatch has three branches that are always false for simple
score queries (isKnnDoc / neededFields / sort-compute). Add a fastPrepare
bool, set once in Collect after needDocIds is known; the fast path runs
only the four necessary statements (total++, HitNumber, maxScore,
Sort=sortByScoreOpt).
Benchmark (BenchmarkTop{K}of{N}Scores, search/collector), parent vs this
commit, Apple M4 Pro, count=10 via benchstat:
Top10of10000 -1.1%
Top100of10000 -1.4%
Top10of100000 -2.5%
Top100of100000 -2.3%
geomean -1.8% (p<0.05 all)
Small but consistent win across sizes, no allocation change, no
regressions.
…ation optimizeCompositeSearcher() returns a new merged TermSearcher wrapping a combined bitmap, but newDisjunctionSearcher never closed the original N sub-searchers once the "disjunction:unadorned" optimization succeeded. Their TermFieldReaders were therefore never released through Close(): the TotTermSearchersFinished accounting was skipped and the TFRs (holding Dictionary + vellum FST Reader references) lingered until GC instead of being returned to the snapshot per-field pool. Close the original qsearchers immediately after the optimization succeeds. This is safe because OptimizeTFRDisjunctionUnadorned.Finish() clones/creates all bitmaps before returning, so the originals share no state. The close loop lives in newDisjunctionSearcher (not optimizeCompositeSearcher) because optimizeMultiTermSearcher already calls cleanup() on its batch — closing inside would double-close. The TFR-pool-warmth benefit is realized only when TFR recycling is enabled (DefaultFieldTFRCacheThreshold > 0), which is currently disabled (MB-64669); so this lands as a lifecycle/accounting correctness fix. Verified with -race on ./search/searcher and the scorch suite.
Two changes to OptimizeTFRDisjunctionUnadorned.Finish(): 1. Remove a dead first loop that scanned every (segment x TFR) to compute a per-segment cMax that was never used — a full O(segments x terms) pass for nothing on every unadorned-disjunction Finish(). 2. Add empty/1-hit fast paths per segment: reuse the zero-alloc anEmptyPostingsIterator singleton when a segment has no hits, and newUnadornedPostingsIteratorFrom1Hit for a lone 1-hit doc, instead of allocating roaring.New() + a bitmap iterator. Also make emptyPostingsIterator implement segment.OptimizablePostingsIterator (ActualBitmap->nil, DocNum1Hit->false, ReplaceActual->noop) so the empty sentinel composes in nested disjunction/conjunction optimizations instead of aborting them (ok=false) and forcing the slow non-optimized path. Benchmark (BenchmarkDisjunctionUnadornedFinish, added here: 8-segment index, 4-term disjunction with 4 empty segments), Apple M4 Pro, count=8 via benchstat: Finish() 4.04us -> 3.96us -2.0% (p=0.000) The measured win here is the dead-loop removal; allocs are unchanged in this shape because the fast paths only fire for genuinely-empty iterators (sparse or nested disjunctions), which this scenario doesn't produce. On sparse multi-field entity workloads the alloc fast paths additionally cut Finish() allocations (the original change measured -14% to -20% allocs there).
SortOrder.Compare is called on every heap comparison and on every
lowestMatchOutsideResults / searchAfter check in the top-N collector. The
generic path iterates the sort-field slice and checks two bool flags
(cachedScoring, cachedDesc) per call, adding overhead to what is usually a
single float64 comparison.
Detect the overwhelmingly common case at collector-construction time — a
single score-descending sort — and store a specialized comparator (hc.cmp)
that compares Score directly, with HitNumber as the tie-break. Share it
across the heap store, the lowestMatchOutsideResults fast-path, and
searchAfter pagination. Falls back to the generic SortOrder.Compare for any
other sort order.
Extracted from a larger change; the companion collectStoreHeap.Final
sort.Slice optimization depended on the ternary-heap rework and is not
included here.
Benchmark (BenchmarkTop{K}of{N}Scores, search/collector, score-descending),
parent vs this commit, Apple M4 Pro, count=10 via benchstat:
Top100of10000 -15.0%
Top1000of100000 -13.5%
Top1000of10000 -13.1%
Top100of100000 -11.2%
Top10of10000 -10.5%
Top10of100000 ~ (noisy, not significant)
geomean -11.6%
Consistent ~11-15% collector speedup for score-sorted queries (the common
case), no meaningful allocation change.
Two micro-optimizations on per-scored-candidate hot paths: - MergeFieldTermLocations (search/util.go): return early when no constituent match carries any FieldTermLocations (n == len(dest)). This is the common case with no highlighting / location tracking, and skips the second iteration and its per-match merge calls entirely. - DocumentMatch.Reset (search/search.go): guard clear(scoreBreakdown) with a nil check. clear(nil) still dispatches through the Go map runtime (~1ns), and ScoreBreakdown is nil on the common path (no KNN, no score-breakdown retrieval). Benchmark (search package, added here), Apple M4 Pro, count=12 via benchstat: MergeFieldTermLocations (no locations) 5.28ns -> 2.68ns -49% (p=0.000) DocumentMatch.Reset (nil ScoreBreakdown) 6.62ns -> 5.76ns -13% (p=0.000) Absolute per-call costs are small, but both run per scored candidate (many millions of calls per query), so they trim steady per-candidate overhead. No allocation change.
There was a problem hiding this comment.
Pull request overview
This PR targets query hot paths in Bleve’s search pipeline, adding a set of micro-optimizations to reduce per-hit and per-segment overhead while keeping behavior/API stable (plus one correctness/resource-release fix around disjunction optimization).
Changes:
- Add fast paths in the TopN collector for common score-sorted queries (specialized comparator + branch skipping in
prepareDocumentMatch, and avoid KNN adjustment when not applicable). - Fix resource lifecycle in disjunction optimization by closing original sub-searchers once an optimized composite searcher is created.
- Optimize “unadorned disjunction” finishing work by skipping empty-segment work and reusing empty/1-hit iterator fast paths; add benchmarks for key optimizations.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| search/util.go | Adds a fast-path early return for no-op field term location merges. |
| search/searcher/search_disjunction.go | Closes original sub-searchers when the unadorned disjunction optimization succeeds. |
| search/search.go | Adds a nil-guard around clear() for the common ScoreBreakdown == nil case in DocumentMatch.Reset(). |
| search/merge_reset_bench_test.go | Adds benchmarks for the merge fast-path and DocumentMatch.Reset() nil-guard. |
| search/collector/topn.go | Adds specialized comparator + fast preparation path; guards KNN adjustment for non-KNN queries. |
| index/scorch/optimize.go | Removes dead work and adds empty/1-hit iterator fast paths in unadorned disjunction Finish(). |
| index/scorch/empty.go | Extends the empty postings iterator to implement the optimizable iterator interface for composition. |
| index/scorch/disj_unadorned_bench_test.go | Adds a benchmark focused on OptimizeTFRDisjunctionUnadorned.Finish(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@CascadingRadium still WIP, will add some more commits and resolve your comments later. |
79032ce to
ac637f5
Compare
| } | ||
| } | ||
|
|
||
| hc.basicPrepare(d) |
There was a problem hiding this comment.
i dont think we should be adding this line here. Put it outside like how we prepare the regular doc matches.
basicPrepare()
knnPrepare()
|
hey @capemox, maybe the fuzzy stuff can be put into a different PR? |
…eries OptimizeTFRConjunctionUnadorned.Finish (and now the disjunction Finish's single-1-hit fast path) place a unadornedPostingsIterator1Hit into the resulting termFieldReader's per-segment iterators. But that type did not implement segment.OptimizablePostingsIterator — only the bitmap variant did. So when such a termFieldReader fed into a *nested* unadorned conjunction/ disjunction, the type assertion in Finish failed and the outer optimization aborted to the slow path. Implement OptimizablePostingsIterator on unadornedPostingsIterator1Hit: DocNum1Hit returns (docNum, true), ActualBitmap returns nil, and ReplaceActual is a no-op (only ever called on iterators whose ActualBitmap is non-nil). This lets a 1-hit result compose into nested AND/OR optimizations instead of disabling them. Addresses review feedback on empty.go asking whether all iterator types placed into the optimized reader satisfy the interface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback on the score-sort fast path, which duplicated the per-hit bookkeeping inline and was flagged as fragile: - Extract basicPrepare (hit count, hit number, max score) shared by the fast and slow paths, and canFastPrepare (the score-sort/no-fields/no-docID predicate previously inlined where fastPrepare is set). - Scaffold the fast-path decision at the call sites: basicPrepare, then either set the shared score sort value or call prepareDocumentMatch for the rest. - Split the KNN branch out into prepareKnnDocumentMatch instead of threading an isKnnDoc bool through prepareDocumentMatch. - Move the specialized comparator to search.CompareScoreDescending alongside SortOrder.Compare, selected via a new getOptimalCollectorCompare helper (mirroring getOptimalCollectorStore), and expand the newTopNCollector struct literal to set cachedScoring/cachedDesc/needDocIds up front. No functional change; search/collector suite passes under -race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…marks) - Restore the concise "clear out the score breakdown map" comment in DocumentMatch.Reset (keep the nil-guard). - Drop the redundant fast-path comment in MergeFieldTermLocations. - Split the combined err/rv check after the unadorned disjunction optimization into separate error and success returns, per review suggestion. - Remove the standalone micro-benchmark files (merge_reset_bench_test.go, disj_unadorned_bench_test.go); the measurements live in the commit messages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rfaces Verifying the reviewer's interface-completeness concern, add compile-time assertions that every postings-iterator type placed into an optimized termFieldReader's per-segment slice implements OptimizablePostingsIterator (empty, unadorned bitmap, unadorned 1-hit) — the interface a nested conjunction/disjunction optimization type-asserts against, whose absence silently aborts the optimization (as was the case for the 1-hit iterator, fixed earlier on this branch). The two stateful iterators additionally assert ResetablePostingsIterator so termFieldReader reuse rewinds them. The empty iterator is stateless and intentionally does not implement it: ResetIterator is only ever invoked via an optional type assertion, so there is nothing to reset and no reason to add it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The method only adjusts a hit when a corresponding KNN hit exists (it is called solely from the knnHits != nil branches). Rename it to make that scope explicit, per review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f48c92f to
206c0ff
Compare
| } | ||
| } | ||
|
|
||
| hc.basicPrepare(d) |
There was a problem hiding this comment.
i dont think we should be adding this line here. Put it outside like how we prepare the regular doc matches.
basicPrepare()
knnPrepare()
f47b673 to
2a4e02a
Compare
bleve PR: blevesearch/bleve#2381 Change-Id: I18662347916957a26feb669b545620cdee132924 Reviewed-on: https://review.couchbase.org/c/cbft/+/249247 Tested-by: <thejas.orkombu@couchbase.com> Well-Formed: Build Bot <build@couchbase.com> Reviewed-by: Rahul Rampure <rahul.rampure@couchbase.com>
Split out from #2381 per review — the fuzzy/regexp candidate-collection optimizations, as their own PR. No functional/API changes. ### Changes - **Trim fuzzy candidate-collection overhead** (`search/searcher/search_fuzzy.go`): skip the per-candidate dedup map when the field has no synonyms (the dictionary iterator already yields each term once; the map only exists to de-dup synonym terms), replace the O(n²) `prefixTerm` rune-concat with a zero-alloc slice, and hoist repeated `ctx.Value` lookups. - **Omit postings-count read for fuzzy/regexp candidate collection** (`index/scorch/snapshot_index*.go`): fuzzy/regexp collectors use only the term + edit distance and discard `DictEntry.Count`, yet the segment iterator read each candidate's postings list to compute it. A new opt-in `AutomatonIteratorOmitCount` path skips that read, wired via a graceful type-assertion fallback. `FieldDict`/`FieldDictPrefix`/`FieldDictRange` still populate counts. **Depends on** blevesearch/zapx#436 — the omit-count optimization activates once the `zapx/v17` dependency is bumped to a release that includes `AutomatonIteratorOmitCount`. Until then the wiring falls back to the count-reading path (the wiring test skips), so this is safe to merge independently. ### Benchmark `BenchmarkFuzzyCandidateCollection` (scorch index, 1000 terms / 3000 docs, fuzziness-2 query matching 280 candidates), original → both commits, Apple M4 Pro, count=12 via benchstat: | | sec/op | B/op | allocs/op | |---|---|---|---| | micro-opts | −5.9% | −3.8% | −1.3% (988→975) | | omit-count wiring | −9.7% | −1.1% | −29.1% (975→691) | | **combined** | **−15.0%** | **−4.8%** | **−30.1%** (988→691) | All p=0.000, n=12. Verified with the full fuzzy + synonym search suites and `-race`. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Thejas-bhat <thejas.orkombu@couchbase.com>
Five standalone performance optimizations on query hot paths, each independently benchmarked (Apple M4 Pro, benchstat). No functional/API changes.
Changes
prepareDocumentMatch+ guardadjustDocumentMatchfor non-KNN queries — skip KNN adjustment and the always-false prepare branches on simple score-sorted queries.Top{K}of{N}Scoresgeomean -1.8%.newDisjunctionSearchernever closed the original N sub-searchers after thedisjunction:unadornedoptimization, so their TermFieldReaders were never released and search-accounting was skipped. Now closed immediately after the optimization succeeds.disjunction:unadornedFinish()— remove a dead O(segments × terms) loop and reuse empty/1-hit iterator singletons instead of allocating. Also lets the empty sentinel compose in nested disjunction/conjunction optimizations.Scoredirectly instead of walking the generic sort-order path. Collector geomean -11.6% for score-sorted queries.MergeFieldTermLocationsfast-path +DocumentMatch.Resetnil-guard — early-return when no match carries field-term locations (-49%), and skipclear()on a nil score breakdown (-13%). Both run per scored candidate.Benchmarks for each change are included in the diff. Marked draft pending review.