Skip to content

MB-72489: standalone query hot-path optimizations#2381

Merged
Thejas-bhat merged 14 commits into
masterfrom
perf-standalone-opts
Jul 22, 2026
Merged

MB-72489: standalone query hot-path optimizations#2381
Thejas-bhat merged 14 commits into
masterfrom
perf-standalone-opts

Conversation

@capemox

@capemox capemox commented Jul 13, 2026

Copy link
Copy Markdown
Member

Five standalone performance optimizations on query hot paths, each independently benchmarked (Apple M4 Pro, benchstat). No functional/API changes.

Changes

  • Fast-path prepareDocumentMatch + guard adjustDocumentMatch for non-KNN queries — skip KNN adjustment and the always-false prepare branches on simple score-sorted queries. Top{K}of{N}Scores geomean -1.8%.
  • Close orphaned sub-searchers after unadorned disjunction optimization (correctness fix)newDisjunctionSearcher never closed the original N sub-searchers after the disjunction:unadorned optimization, so their TermFieldReaders were never released and search-accounting was skipped. Now closed immediately after the optimization succeeds.
  • Skip empty-bitmap work in disjunction:unadorned Finish() — 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.
  • Specialized score-descending comparator for the top-N collector — detect the common single-score-descending sort at construction time and compare Score directly instead of walking the generic sort-order path. Collector geomean -11.6% for score-sorted queries.
  • MergeFieldTermLocations fast-path + DocumentMatch.Reset nil-guard — early-return when no match carries field-term locations (-49%), and skip clear() on a nil score breakdown (-13%). Both run per scored candidate.

Benchmarks for each change are included in the diff. Marked draft pending review.

steveyen and others added 5 commits July 10, 2026 11:44
…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.
@capemox capemox changed the title perf: standalone query hot-path optimizations [WIP] perf: standalone query hot-path optimizations Jul 16, 2026
@capemox
capemox marked this pull request as ready for review July 16, 2026 06:01

Copilot AI 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.

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.

Comment thread search/collector/topn.go Outdated
Comment thread search/search.go Outdated
Comment thread search/util.go Outdated
Comment thread search/merge_reset_bench_test.go Outdated
Comment thread search/searcher/search_disjunction.go Outdated
Comment thread index/scorch/disj_unadorned_bench_test.go Outdated
Comment thread index/scorch/optimize.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
@coveralls

coveralls commented Jul 16, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 52.288% (-0.03%) from 52.321% — perf-standalone-opts into master

@capemox

capemox commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@CascadingRadium still WIP, will add some more commits and resolve your comments later.

Comment thread search/collector/topn.go Outdated
@capemox
capemox force-pushed the perf-standalone-opts branch from 79032ce to ac637f5 Compare July 17, 2026 03:39
Comment thread index/scorch/optimize.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go
}
}

hc.basicPrepare(d)

@CascadingRadium CascadingRadium Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i dont think we should be adding this line here. Put it outside like how we prepare the regular doc matches.

basicPrepare()
knnPrepare()

@CascadingRadium

Copy link
Copy Markdown
Member

hey @capemox, maybe the fuzzy stuff can be put into a different PR?
Since this PR mostly deals with minor refactors and micro optimizations, it would be beneficial to save the fuzzy PR for later.

capemox and others added 6 commits July 17, 2026 19:12
…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>
@Thejas-bhat Thejas-bhat changed the title [WIP] perf: standalone query hot-path optimizations MB-72489: standalone query hot-path optimizations Jul 21, 2026
Comment thread index/scorch/unadorned.go Outdated
Comment thread index/scorch/unadorned.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search/collector/topn.go
}
}

hc.basicPrepare(d)

@CascadingRadium CascadingRadium Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i dont think we should be adding this line here. Put it outside like how we prepare the regular doc matches.

basicPrepare()
knnPrepare()

@Thejas-bhat
Thejas-bhat force-pushed the perf-standalone-opts branch from f47b673 to 2a4e02a Compare July 21, 2026 20:23
Comment thread search/collector/topn.go Outdated
@Thejas-bhat
Thejas-bhat merged commit 991a450 into master Jul 22, 2026
10 checks passed
@Thejas-bhat
Thejas-bhat deleted the perf-standalone-opts branch July 22, 2026 06:12
ns-codereview pushed a commit to couchbase/cbft that referenced this pull request Jul 22, 2026
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>
Thejas-bhat added a commit that referenced this pull request Jul 24, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants