Skip to content

Commit 8fd0ead

Browse files
committed
perf: specialized score-descending comparator for top-N collector
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.
1 parent 82e20dd commit 8fd0ead

1 file changed

Lines changed: 36 additions & 13 deletions

File tree

search/collector/topn.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type TopNCollector struct {
6666
facetsBuilder *search.FacetsBuilder
6767

6868
store collectorStore
69+
cmp collectorCompare // specialized or generic; shared by heap + dmHandler
6970

7071
needDocIds bool
7172
neededFields []string
@@ -132,9 +133,38 @@ func NewNestedTopNCollectorAfter(size int, sort search.SortOrder, after []string
132133
func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.NestedReader) *TopNCollector {
133134
hc := &TopNCollector{size: size, skip: skip, sort: sort}
134135

135-
hc.store = getOptimalCollectorStore(size, skip, func(i, j *search.DocumentMatch) int {
136-
return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j)
137-
})
136+
// Compute once up-front; comparator and store creation need them.
137+
hc.neededFields = sort.RequiredFields()
138+
hc.cachedScoring = sort.CacheIsScore()
139+
hc.cachedDesc = sort.CacheDescending()
140+
141+
// Specialize for the common case: single score-descending sort.
142+
// SortOrder.Compare iterates a slice and checks two bool flags per call,
143+
// adding ~40% overhead to every heap comparison. The direct float64 path
144+
// eliminates that overhead; measured at ~18% of total CPU for k=1000 queries.
145+
if len(sort) == 1 && hc.cachedScoring[0] && hc.cachedDesc[0] {
146+
hc.cmp = func(i, j *search.DocumentMatch) int {
147+
if i.Score < j.Score {
148+
return 1 // i is worse (lower score → closer to heap root)
149+
}
150+
if i.Score > j.Score {
151+
return -1
152+
}
153+
if i.HitNumber > j.HitNumber {
154+
return 1 // tie-break: earlier hit is better
155+
}
156+
if i.HitNumber < j.HitNumber {
157+
return -1
158+
}
159+
return 0
160+
}
161+
} else {
162+
hc.cmp = func(i, j *search.DocumentMatch) int {
163+
return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j)
164+
}
165+
}
166+
167+
hc.store = getOptimalCollectorStore(size, skip, hc.cmp)
138168

139169
if nr != nil {
140170
descAdder := func(parent, child *search.DocumentMatch) error {
@@ -163,13 +193,9 @@ func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.Nested
163193
hc.nestedStore = newStoreNested(nr, search.DescendantAdderCallbackFn(descAdder))
164194
}
165195

166-
// these lookups traverse an interface, so do once up-front
167196
if sort.RequiresDocID() {
168197
hc.needDocIds = true
169198
}
170-
hc.neededFields = sort.RequiredFields()
171-
hc.cachedScoring = sort.CacheIsScore()
172-
hc.cachedDesc = sort.CacheDescending()
173199

174200
return hc
175201
}
@@ -561,7 +587,7 @@ func MakeTopNDocumentMatchHandler(
561587
// exact sort order matches use hit number to break tie
562588
// but we want to allow for exact match, so we pretend
563589
hc.searchAfter.HitNumber = d.HitNumber
564-
if hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, d, hc.searchAfter) <= 0 {
590+
if hc.cmp(d, hc.searchAfter) <= 0 {
565591
ctx.DocumentMatchPool.Put(d)
566592
return nil
567593
}
@@ -571,9 +597,7 @@ func MakeTopNDocumentMatchHandler(
571597
// with this one comparison, we can avoid all heap operations if
572598
// this hit would have been added and then immediately removed
573599
if hc.lowestMatchOutsideResults != nil {
574-
cmp := hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, d,
575-
hc.lowestMatchOutsideResults)
576-
if cmp >= 0 {
600+
if hc.cmp(d, hc.lowestMatchOutsideResults) >= 0 {
577601
// this hit can't possibly be in the result set, so avoid heap ops
578602
ctx.DocumentMatchPool.Put(d)
579603
return nil
@@ -585,8 +609,7 @@ func MakeTopNDocumentMatchHandler(
585609
if hc.lowestMatchOutsideResults == nil {
586610
hc.lowestMatchOutsideResults = removed
587611
} else {
588-
cmp := hc.sort.Compare(hc.cachedScoring, hc.cachedDesc,
589-
removed, hc.lowestMatchOutsideResults)
612+
cmp := hc.cmp(removed, hc.lowestMatchOutsideResults)
590613
if cmp < 0 {
591614
tmp := hc.lowestMatchOutsideResults
592615
hc.lowestMatchOutsideResults = removed

0 commit comments

Comments
 (0)