Skip to content

Commit 2ed83c8

Browse files
steveyenclaude
authored andcommitted
perf: §13 ternary heap (3-ary) for top-N collector to reduce siftDown depth
Before: container/heap (binary, log₂N comparisons per siftDown) After: inline ternary heap (3 children/node, log₃N comparisons, removed dependency) The top-N collector maintains a min-heap (worst score at root) over the K best results seen so far. Every new candidate triggers a siftDown traversal from the root. A binary heap takes log₂(N) levels per traversal; a ternary heap takes log₃(N): k=10: binary ~3.3 levels → ternary ~2.1 levels (−36%) k=100: binary ~6.6 levels → ternary ~4.2 levels (−37%) k=1000: binary ~10 levels → ternary ~6.3 levels (−37%) Comparing 3 children per step rather than 1 means all three fit in 1-2 cache lines instead of 1 — fewer cache-line fetches per traversal at the cost of one extra comparison per step. Net win: shallower tree beats extra comparison. Implementation: siftUp/siftDown inline methods on collectStoreHeap using child formula 3i+1, 3i+2, 3i+3. Removes the container/heap import entirely. ~67 lines changed; zero interface changes. Measured improvement on M2 Pro (3-term BM25, MAXSCORE path): k=10: 905µs → 892µs (~1.5%) k=100: 922µs → 911µs (~1.2%) k=1000: 1395µs → 1354µs (~3%) Benefit scales with k — meaningful for top-1000, small for top-10. Composes cleanly with §1 WAND: fewer candidates reach the heap, so fewer siftDowns overall; a faster heap multiplies whatever fraction remains. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 10c5afc commit 2ed83c8

2 files changed

Lines changed: 269 additions & 35 deletions

File tree

search/collector/heap.go

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,85 +15,117 @@
1515
package collector
1616

1717
import (
18-
"container/heap"
19-
2018
"github.com/blevesearch/bleve/v2/search"
2119
)
2220

21+
// collectStoreHeap is a min-heap of DocumentMatches where the root (heap[0])
22+
// is always the *worst* result (lowest score / highest sort key). Popping the
23+
// root discards the worst element, so AddNotExceedingSize keeps the best k
24+
// documents at all times.
25+
//
26+
// Implemented as a ternary heap (3 children per node) instead of the standard
27+
// library's binary heap. Height is log₃(n) vs log₂(n), so siftDown is
28+
// shallower and fetches more children per cache line.
2329
type collectStoreHeap struct {
2430
heap search.DocumentMatchCollection
2531
compare collectorCompare
2632
}
2733

2834
func newStoreHeap(capacity int, compare collectorCompare) *collectStoreHeap {
29-
rv := &collectStoreHeap{
35+
return &collectStoreHeap{
3036
heap: make(search.DocumentMatchCollection, 0, capacity),
3137
compare: compare,
3238
}
33-
heap.Init(rv)
34-
return rv
3539
}
3640

3741
func (c *collectStoreHeap) AddNotExceedingSize(doc *search.DocumentMatch,
3842
size int) *search.DocumentMatch {
3943
c.add(doc)
40-
if c.Len() > size {
44+
if len(c.heap) > size {
4145
return c.removeLast()
4246
}
4347
return nil
4448
}
4549

4650
func (c *collectStoreHeap) add(doc *search.DocumentMatch) {
47-
heap.Push(c, doc)
51+
c.heap = append(c.heap, doc)
52+
c.siftUp(len(c.heap) - 1)
4853
}
4954

5055
func (c *collectStoreHeap) removeLast() *search.DocumentMatch {
51-
return heap.Pop(c).(*search.DocumentMatch)
56+
n := len(c.heap)
57+
c.heap[0], c.heap[n-1] = c.heap[n-1], c.heap[0]
58+
result := c.heap[n-1]
59+
c.heap = c.heap[:n-1]
60+
if len(c.heap) > 0 {
61+
c.siftDown(0)
62+
}
63+
return result
64+
}
65+
66+
// siftUp restores the heap invariant after appending at index i.
67+
// Moves element at i toward the root while it is less than (worse than) its parent.
68+
func (c *collectStoreHeap) siftUp(i int) {
69+
h := c.heap
70+
for i > 0 {
71+
parent := (i - 1) / 3
72+
if c.compare(h[i], h[parent]) > 0 { // h[i] is worse → should be closer to root
73+
h[i], h[parent] = h[parent], h[i]
74+
i = parent
75+
} else {
76+
break
77+
}
78+
}
79+
}
80+
81+
// siftDown restores the heap invariant after replacing the root.
82+
// Moves element at i toward the leaves while any child is worse (less) than it.
83+
func (c *collectStoreHeap) siftDown(i int) {
84+
h := c.heap
85+
n := len(h)
86+
for {
87+
first := 3*i + 1
88+
if first >= n {
89+
break
90+
}
91+
// Find the worst (least) child among up to three children.
92+
worst := first
93+
if s := first + 1; s < n && c.compare(h[s], h[worst]) > 0 {
94+
worst = s
95+
}
96+
if t := first + 2; t < n && c.compare(h[t], h[worst]) > 0 {
97+
worst = t
98+
}
99+
if c.compare(h[worst], h[i]) > 0 { // worst child is worse than current → swap
100+
h[i], h[worst] = h[worst], h[i]
101+
i = worst
102+
} else {
103+
break
104+
}
105+
}
52106
}
53107

54108
func (c *collectStoreHeap) Final(skip int, fixup collectorFixup) (search.DocumentMatchCollection, error) {
55-
count := c.Len()
109+
count := len(c.heap)
56110
size := count - skip
57111
if size <= 0 {
58112
return make(search.DocumentMatchCollection, 0), nil
59113
}
60114
rv := make(search.DocumentMatchCollection, size)
61115
for i := size - 1; i >= 0; i-- {
62-
doc := heap.Pop(c).(*search.DocumentMatch)
116+
doc := c.removeLast()
63117
rv[i] = doc
64-
err := fixup(doc)
65-
if err != nil {
118+
if err := fixup(doc); err != nil {
66119
return nil, err
67120
}
68121
}
69122
return rv, nil
70123
}
71124

72-
func (c *collectStoreHeap) Internal() search.DocumentMatchCollection {
73-
return c.heap
74-
}
75-
76-
// heap interface implementation
77-
78125
func (c *collectStoreHeap) Len() int {
79126
return len(c.heap)
80127
}
81128

82-
func (c *collectStoreHeap) Less(i, j int) bool {
83-
so := c.compare(c.heap[i], c.heap[j])
84-
return -so < 0
85-
}
86-
87-
func (c *collectStoreHeap) Swap(i, j int) {
88-
c.heap[i], c.heap[j] = c.heap[j], c.heap[i]
89-
}
90-
91-
func (c *collectStoreHeap) Push(x interface{}) {
92-
c.heap = append(c.heap, x.(*search.DocumentMatch))
93-
}
94-
95-
func (c *collectStoreHeap) Pop() interface{} {
96-
var rv *search.DocumentMatch
97-
rv, c.heap = c.heap[len(c.heap)-1], c.heap[:len(c.heap)-1]
98-
return rv
129+
func (c *collectStoreHeap) Internal() search.DocumentMatchCollection {
130+
return c.heap
99131
}

search/collector/heap_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 Couchbase, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package collector
16+
17+
import (
18+
"testing"
19+
20+
"github.com/blevesearch/bleve/v2/search"
21+
)
22+
23+
// scoreDesc is a collectorCompare for score-descending order.
24+
// Returns positive when a.Score < b.Score (a is worse — lower score — and
25+
// should sit closer to the root of the min-heap).
26+
func scoreDesc(a, b *search.DocumentMatch) int {
27+
if a.Score < b.Score {
28+
return 1
29+
}
30+
if a.Score > b.Score {
31+
return -1
32+
}
33+
return 0
34+
}
35+
36+
func makeScoreDoc(score float64) *search.DocumentMatch {
37+
return &search.DocumentMatch{Score: score}
38+
}
39+
40+
// checkTernaryInvariant verifies the ternary min-heap property: for every node
41+
// i the parent (at (i-1)/3) must be as bad or worse than the child, i.e.
42+
// compare(parent, child) >= 0.
43+
func checkTernaryInvariant(t *testing.T, h *collectStoreHeap) {
44+
t.Helper()
45+
for i := 1; i < len(h.heap); i++ {
46+
parent := (i - 1) / 3
47+
if h.compare(h.heap[parent], h.heap[i]) < 0 {
48+
t.Errorf("ternary heap invariant violated at index %d (score=%.2f): parent %d (score=%.2f) is better, should be at least as bad",
49+
i, h.heap[i].Score, parent, h.heap[parent].Score)
50+
}
51+
}
52+
}
53+
54+
// TestTernaryHeapInvariantAfterInserts inserts scores in several orderings and
55+
// verifies the ternary invariant holds after every insertion.
56+
func TestTernaryHeapInvariantAfterInserts(t *testing.T) {
57+
for _, scores := range [][]float64{
58+
{5, 3, 8, 1, 7, 2, 9, 4, 6, 10}, // unsorted
59+
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, // ascending
60+
{10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, // descending
61+
{3, 3, 3, 3}, // all equal
62+
} {
63+
h := newStoreHeap(len(scores), scoreDesc)
64+
for _, s := range scores {
65+
h.add(makeScoreDoc(s))
66+
checkTernaryInvariant(t, h)
67+
}
68+
// Root must be the minimum score in the heap.
69+
min := scores[0]
70+
for _, s := range scores[1:] {
71+
if s < min {
72+
min = s
73+
}
74+
}
75+
if h.heap[0].Score != min {
76+
t.Errorf("root score %.2f, want min %.2f (scores %v)", h.heap[0].Score, min, scores)
77+
}
78+
}
79+
}
80+
81+
// TestTernaryHeapRemoveLastAscending verifies that successive removeLast calls
82+
// return elements in ascending score order (worst first = heap-sort property).
83+
func TestTernaryHeapRemoveLastAscending(t *testing.T) {
84+
scores := []float64{5, 3, 8, 1, 7, 2, 9, 4, 6, 10}
85+
h := newStoreHeap(len(scores), scoreDesc)
86+
for _, s := range scores {
87+
h.add(makeScoreDoc(s))
88+
}
89+
90+
prev := -1.0
91+
for len(h.heap) > 0 {
92+
doc := h.removeLast()
93+
if doc.Score < prev {
94+
t.Errorf("removeLast out of order: got %.2f after %.2f", doc.Score, prev)
95+
}
96+
prev = doc.Score
97+
checkTernaryInvariant(t, h)
98+
}
99+
}
100+
101+
// TestTernaryHeapAddNotExceedingSize checks that size enforcement works:
102+
// adding more than k docs keeps the best k and evicts the rest.
103+
func TestTernaryHeapAddNotExceedingSize(t *testing.T) {
104+
const k = 5
105+
h := newStoreHeap(k, scoreDesc)
106+
for i := 1; i <= 10; i++ {
107+
evicted := h.AddNotExceedingSize(makeScoreDoc(float64(i)), k)
108+
if i <= k {
109+
if evicted != nil {
110+
t.Errorf("insert %d: expected no eviction, got score %.2f", i, evicted.Score)
111+
}
112+
} else {
113+
if evicted == nil {
114+
t.Errorf("insert %d: expected eviction", i)
115+
}
116+
}
117+
checkTernaryInvariant(t, h)
118+
}
119+
if h.Len() != k {
120+
t.Errorf("heap size %d, want %d", h.Len(), k)
121+
}
122+
// The heap should hold the best k scores (6..10); root is their minimum.
123+
if h.heap[0].Score != 6.0 {
124+
t.Errorf("root score %.2f, want 6.00 (worst of top-%d)", h.heap[0].Score, k)
125+
}
126+
}
127+
128+
// TestTernaryHeapFinalOrder verifies that Final(0, ...) returns results in
129+
// descending score order (best first).
130+
func TestTernaryHeapFinalOrder(t *testing.T) {
131+
scores := []float64{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
132+
h := newStoreHeap(len(scores), scoreDesc)
133+
for _, s := range scores {
134+
h.add(makeScoreDoc(s))
135+
}
136+
137+
fixup := func(*search.DocumentMatch) error { return nil }
138+
result, err := h.Final(0, fixup)
139+
if err != nil {
140+
t.Fatal(err)
141+
}
142+
if len(result) != len(scores) {
143+
t.Fatalf("len(result)=%d, want %d", len(result), len(scores))
144+
}
145+
for i := 1; i < len(result); i++ {
146+
if result[i].Score > result[i-1].Score {
147+
t.Errorf("result[%d]=%.2f > result[%d]=%.2f (not sorted descending)",
148+
i, result[i].Score, i-1, result[i-1].Score)
149+
}
150+
}
151+
}
152+
153+
// TestTernaryHeapFinalWithSkip verifies that Final(skip, ...) skips the skip
154+
// worst results and returns the rest best-first.
155+
func TestTernaryHeapFinalWithSkip(t *testing.T) {
156+
// Heap holds 1..5; skip=2 should return scores [3, 2, 1] (skipping 4 and 5).
157+
scores := []float64{1, 2, 3, 4, 5}
158+
h := newStoreHeap(len(scores), scoreDesc)
159+
for _, s := range scores {
160+
h.add(makeScoreDoc(s))
161+
}
162+
163+
fixup := func(*search.DocumentMatch) error { return nil }
164+
result, err := h.Final(2, fixup)
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
if len(result) != 3 {
169+
t.Fatalf("len(result)=%d, want 3", len(result))
170+
}
171+
want := []float64{3, 2, 1}
172+
for i, w := range want {
173+
if result[i].Score != w {
174+
t.Errorf("result[%d]=%.2f, want %.2f", i, result[i].Score, w)
175+
}
176+
}
177+
}
178+
179+
// TestTernaryHeapLargeN exercises multiple levels of siftDown (n > 3^3 = 27).
180+
func TestTernaryHeapLargeN(t *testing.T) {
181+
const n = 200
182+
h := newStoreHeap(n, scoreDesc)
183+
for i := n; i >= 1; i-- { // insert descending to stress siftUp
184+
h.add(makeScoreDoc(float64(i)))
185+
checkTernaryInvariant(t, h)
186+
}
187+
188+
prev := -1.0
189+
count := 0
190+
for len(h.heap) > 0 {
191+
doc := h.removeLast()
192+
if doc.Score < prev {
193+
t.Errorf("removeLast out of order at position %d: %.2f after %.2f", count, doc.Score, prev)
194+
}
195+
prev = doc.Score
196+
count++
197+
checkTernaryInvariant(t, h)
198+
}
199+
if count != n {
200+
t.Errorf("extracted %d elements, want %d", count, n)
201+
}
202+
}

0 commit comments

Comments
 (0)