Skip to content

Commit 7fe6c1a

Browse files
perf: root BatchEqual in the existence bitmap and share plane work across values
Follow-up addressing review findings on the vectorized implementation. Correctness: the per-value path built results from bitplane clones and never intersected eBM, so columns present in a plane but absent from the existence bitmap leaked into results. The checked-in testdata/age fixture contains exactly this state (plane 4 holds 33.3M bits against 28.0M in eBM): BatchEqual(0, {16}) returned 454,521 columns, all of them outside eBM, where the pre-optimization implementation returned none. GetValue, CompareValue, Sum, and the original BatchEqual all treat eBM as authoritative; the new implementation is rooted in eBM by construction, and a regression test pins the invariant. Scaling: the maxVectorizedBatchSize=128 crossover was inverted on large datasets. Measured on the age fixture, the vectorized path at M=128 cost 3.6s / 2.1GB while the scalar fallback it guarded cost 0.59s: the guard fired on list size when the real variable is data size. Both thresholds and the scalar fallback path are deleted. BatchEqual now descends the bitplanes from the most significant bit as a binary trie over the sorted, deduplicated query values: each shared value prefix is intersected once, subtrees whose every bit pattern is queried collapse to a single operation, and empty intermediates prune the recursion. Work is bounded by the size of the values' bit trie and the data actually present rather than len(values) x BitCount(). testdata/age fixture (28M columns, 8 planes), 16 threads, -benchmem, previous commit -> this commit: M=2 {55,57} ~56ms / 33.5MB / 10.3k allocs -> ~50ms / 26.7MB / 8.2k M=128 contiguous 3.6s / 2.1GB -> 1.2ms / 9.3MB M=128 scattered 3.3s / 2.2GB -> 440ms / 283MB M=200 (fallback) 590ms / 155MB -> 3.3ms / 9.5MB The M=2 delta is within run-to-run noise; allocation counts are exact. New benchmarks cover both sides of the deleted thresholds and both contiguous and scattered value shapes.
1 parent da86289 commit 7fe6c1a

2 files changed

Lines changed: 135 additions & 108 deletions

File tree

BitSliceIndexing/bsi.go

Lines changed: 74 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"math/bits"
66
"runtime"
7+
"sort"
78
"sync"
89
"sync/atomic"
910

@@ -271,7 +272,6 @@ type task struct {
271272
op Operation
272273
valueOrStart int64
273274
end int64
274-
values map[int64]struct{}
275275
bits *roaring.Bitmap
276276
}
277277

@@ -743,134 +743,100 @@ func (b *BSI) MarshalBinary() ([][]byte, error) {
743743
return data, nil
744744
}
745745

746-
// maxVectorizedBatchSize is the threshold above which BatchEqual falls back
747-
// to the scalar parallel executor. This prevents a memory allocation/CPU scaling
748-
// cliff when querying a very large list of unique values (large M) on a small/sparse
749-
// database, which would otherwise allocate and process M intermediate bitmaps.
750-
const maxVectorizedBatchSize = 128
751-
752-
// maxInPlaceCombineSize is the threshold below which BatchEqual combines the
753-
// computed match bitmaps sequentially in-place using Bitmap.Or rather than roaring.ParOr.
754-
// This avoids the goroutine spawning, chunking spec, and channel allocation overhead
755-
// of ParOr when combining a small number of bitmaps, keeping allocs/op low and
756-
// avoiding a performance regression on typical small queries.
757-
const maxInPlaceCombineSize = 16
758-
759-
// BatchEqual returns a bitmap containing the column IDs where the values are contained within the list of values provided.
746+
// BatchEqual returns a bitmap containing the column IDs where the values are contained
747+
// within the list of values provided. The parallelism parameter is accepted for API
748+
// compatibility; work is shared across values, so the query runs on the calling goroutine.
760749
func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap {
761750
if b.eBM.IsEmpty() || len(values) == 0 {
762751
return roaring.NewBitmap()
763752
}
764753

765-
if parallelism < 0 {
766-
parallelism = 0
767-
}
754+
bitCount := b.BitCount()
768755

769-
// Deduplicate values
770-
valMap := make(map[int64]struct{}, len(values))
756+
// Deduplicate, and drop values that cannot be represented in bitCount
757+
// planes: GetValue can never observe such a value, so it matches no column.
758+
seen := make(map[uint64]struct{}, len(values))
759+
vals := make([]uint64, 0, len(values))
771760
for _, v := range values {
772-
valMap[v] = struct{}{}
773-
}
774-
775-
// Filter and collect valid unique values
776-
bitCount := b.BitCount()
777-
validValues := make([]int64, 0, len(valMap))
778-
for v := range valMap {
779-
if bitCount >= 64 {
780-
validValues = append(validValues, v)
781-
} else {
782-
if v >= 0 && uint64(v) < (uint64(1)<<uint64(bitCount)) {
783-
validValues = append(validValues, v)
784-
}
761+
u := uint64(v)
762+
if bitCount < 64 && (v < 0 || u >= uint64(1)<<uint(bitCount)) {
763+
continue
764+
}
765+
if _, ok := seen[u]; ok {
766+
continue
785767
}
768+
seen[u] = struct{}{}
769+
vals = append(vals, u)
786770
}
787-
788-
if len(validValues) == 0 {
771+
if len(vals) == 0 {
789772
return roaring.NewBitmap()
790773
}
774+
sort.Slice(vals, func(i, j int) bool { return vals[i] < vals[j] })
791775

792-
// Fallback to scalar parallel executor if query list is very large
793-
if len(validValues) > maxVectorizedBatchSize {
794-
comp := &task{bsi: b, values: valMap}
795-
return parallelExecutor(parallelism, comp, batchEqualFallback, b.eBM)
796-
}
797-
798-
bitmaps := make([]*roaring.Bitmap, len(validValues))
799-
for i, v := range validValues {
800-
bitmaps[i] = b.getEqualBitmap(v)
801-
}
802-
803-
var finalResult *roaring.Bitmap
804-
if len(bitmaps) == 1 {
805-
finalResult = bitmaps[0]
806-
} else if len(bitmaps) <= maxInPlaceCombineSize {
807-
finalResult = bitmaps[0]
808-
for i := 1; i < len(bitmaps); i++ {
809-
finalResult.Or(bitmaps[i])
810-
}
811-
} else {
812-
finalResult = roaring.ParOr(parallelism, bitmaps...)
813-
}
814-
776+
result := b.matchTrie(vals, bitCount-1, b.eBM, false)
815777
if b.runOptimized {
816-
finalResult.RunOptimize()
778+
result.RunOptimize()
817779
}
818-
return finalResult
780+
return result
819781
}
820782

821-
// getEqualBitmap returns a bitmap of column IDs whose value is exactly v
822-
func (b *BSI) getEqualBitmap(v int64) *roaring.Bitmap {
823-
bitCount := b.BitCount()
824-
// Find first set bit to start with a sparse bitmap instead of eBM
825-
firstSetBit := -1
826-
for i := 0; i < bitCount; i++ {
827-
if (uint64(v) & (1 << uint64(i))) != 0 {
828-
firstSetBit = i
829-
break
783+
// matchTrie returns the columns in prefix whose bits on planes [0, p] equal the low
784+
// p+1 bits of any value in vals. vals must be unique, sorted, and share identical bits
785+
// above plane p; prefix holds the columns matching those shared upper bits, so results
786+
// stay rooted in the existence bitmap the recursion starts from. owned reports whether
787+
// prefix is a private intermediate the callee may consume in place. Each shared value
788+
// prefix is intersected exactly once and empty intermediates prune the recursion, so
789+
// total work is bounded by the size of the values' bit trie and the data actually
790+
// present, not len(vals) × BitCount().
791+
func (b *BSI) matchTrie(vals []uint64, p int, prefix *roaring.Bitmap, owned bool) *roaring.Bitmap {
792+
if prefix.IsEmpty() {
793+
if owned {
794+
return prefix
830795
}
796+
return roaring.NewBitmap()
831797
}
832-
833-
var vBM *roaring.Bitmap
834-
if firstSetBit != -1 {
835-
vBM = b.bA[firstSetBit].Clone()
836-
for i := 0; i < bitCount; i++ {
837-
if i == firstSetBit {
838-
continue
839-
}
840-
if (uint64(v) & (1 << uint64(i))) != 0 {
841-
vBM.And(b.bA[i])
842-
} else {
843-
vBM.AndNot(b.bA[i])
844-
}
845-
}
846-
} else {
847-
vBM = b.eBM.Clone()
848-
for i := 0; i < bitCount; i++ {
849-
vBM.AndNot(b.bA[i])
798+
// Either all planes are consumed (exactly one value remains and prefix matched
799+
// every one of its bits), or vals covers every bit pattern of the remaining
800+
// planes, which collapses dense value ranges to a single operation. In both
801+
// cases every column in prefix matches.
802+
if p < 0 || (p < 63 && uint64(len(vals)) == uint64(1)<<uint(p+1)) {
803+
if owned {
804+
return prefix
805+
}
806+
return prefix.Clone()
807+
}
808+
// vals is sorted and shares all bits above p, so bit p partitions it in place.
809+
cut := sort.Search(len(vals), func(i int) bool { return vals[i]&(uint64(1)<<uint(p)) != 0 })
810+
lo, hi := vals[:cut], vals[cut:]
811+
switch {
812+
case len(hi) == 0:
813+
return b.matchTrie(lo, p-1, planeChild(prefix, b.bA[p], false, owned), true)
814+
case len(lo) == 0:
815+
return b.matchTrie(hi, p-1, planeChild(prefix, b.bA[p], true, owned), true)
816+
default:
817+
// Materialize the set branch before the clear branch may consume prefix.
818+
hiBM := roaring.And(prefix, b.bA[p])
819+
result := b.matchTrie(lo, p-1, planeChild(prefix, b.bA[p], false, owned), true)
820+
result.Or(b.matchTrie(hi, p-1, hiBM, true))
821+
return result
822+
}
823+
}
824+
825+
// planeChild narrows prefix by one bitplane: prefix ∧ plane when set, prefix ∧ ¬plane
826+
// otherwise. When the caller owns prefix it is consumed in place to avoid a copy.
827+
func planeChild(prefix, plane *roaring.Bitmap, set, owned bool) *roaring.Bitmap {
828+
if owned {
829+
if set {
830+
prefix.And(plane)
831+
} else {
832+
prefix.AndNot(plane)
850833
}
834+
return prefix
851835
}
852-
return vBM
853-
}
854-
855-
func batchEqualFallback(e *task, batch []uint32, resultsChan chan *roaring.Bitmap,
856-
wg *sync.WaitGroup) {
857-
858-
defer wg.Done()
859-
860-
results := roaring.NewBitmap()
861-
if e.bsi.runOptimized {
862-
results.RunOptimize()
836+
if set {
837+
return roaring.And(prefix, plane)
863838
}
864-
865-
for i := 0; i < len(batch); i++ {
866-
cID := batch[i]
867-
if value, ok := e.bsi.GetValue(uint64(cID)); ok {
868-
if _, yes := e.values[value]; yes {
869-
results.Add(cID)
870-
}
871-
}
872-
}
873-
resultsChan <- results
839+
return roaring.AndNot(prefix, plane)
874840
}
875841

876842
// ClearBits cleared the bits that exist in the target if they are also in the found set.

BitSliceIndexing/bsi_benchmark_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,64 @@ func TestBatchEqualConsistentWithGetValue(t *testing.T) {
171171
}
172172
}
173173
}
174+
175+
// TestBatchEqualExistenceAuthority pins BatchEqual results to the existence
176+
// bitmap. UnmarshalBinary accepts plane data that is not a subset of eBM (the
177+
// checked-in testdata/age fixture is such data), and every read path treats
178+
// eBM as authoritative, so columns present in a plane but absent from eBM must
179+
// never appear in results.
180+
func TestBatchEqualExistenceAuthority(t *testing.T) {
181+
// Synthetic state: column 2 has bits in plane 0 but is absent from eBM.
182+
ebm := roaring.BitmapOf(1)
183+
plane := roaring.BitmapOf(1, 2)
184+
ebmData, err := ebm.MarshalBinary()
185+
if err != nil {
186+
t.Fatal(err)
187+
}
188+
planeData, err := plane.MarshalBinary()
189+
if err != nil {
190+
t.Fatal(err)
191+
}
192+
bsi := NewDefaultBSI()
193+
if err := bsi.UnmarshalBinary([][]byte{ebmData, planeData}); err != nil {
194+
t.Fatal(err)
195+
}
196+
res := bsi.BatchEqual(0, []int64{1})
197+
assert.True(t, res.Contains(1))
198+
assert.False(t, res.Contains(2), "column 2 is not in eBM and must not match")
199+
200+
// The age fixture ships with plane cardinalities above the eBM cardinality;
201+
// results must still be a subset of eBM.
202+
large := setupLargeBSI(t)
203+
if large == nil {
204+
t.Skip("skipping, large BSI setup failed")
205+
}
206+
for _, vals := range [][]int64{{16}, {55, 57}, {0, 1, 2, 3}} {
207+
res := large.BatchEqual(0, vals)
208+
outside := roaring.AndNot(res, large.GetExistenceBitmap())
209+
assert.True(t, outside.IsEmpty(), "BatchEqual(%v) returned %d columns outside eBM", vals, outside.GetCardinality())
210+
}
211+
}
212+
213+
// Benchmarks across query-list shapes: work sharing behaves differently for
214+
// small lists, dense contiguous ranges (which collapse to a few plane
215+
// operations), and scattered values (no range collapse).
216+
func BenchmarkBatchEqualM128(b *testing.B) { benchmarkBatchEqualM(b, 128, 1) }
217+
func BenchmarkBatchEqualM128Scattered(b *testing.B) { benchmarkBatchEqualM(b, 128, 2) }
218+
func BenchmarkBatchEqualM200(b *testing.B) { benchmarkBatchEqualM(b, 200, 1) }
219+
220+
func benchmarkBatchEqualM(b *testing.B, m int, stride int64) {
221+
bsi := setupLargeBSI(b)
222+
if bsi == nil {
223+
b.Skip("skipping, large BSI setup failed")
224+
}
225+
vals := make([]int64, m)
226+
for i := range vals {
227+
vals[i] = int64(i)*stride + stride - 1
228+
}
229+
b.ResetTimer()
230+
for i := 0; i < b.N; i++ {
231+
res := bsi.BatchEqual(0, vals)
232+
_ = res
233+
}
234+
}

0 commit comments

Comments
 (0)