Skip to content

Commit 7900bdc

Browse files
authored
Merge pull request #533 from perfloop/perfloop-pr-open-knxx61ntcy
perf(BSI): bound BatchEqual memory on scattered IN-lists with parallel scan fallback
2 parents 873a1e3 + 3e12681 commit 7900bdc

2 files changed

Lines changed: 158 additions & 2 deletions

File tree

BitSliceIndexing/bsi.go

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,8 +744,9 @@ func (b *BSI) MarshalBinary() ([][]byte, error) {
744744
}
745745

746746
// 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.
747+
// within the list of values provided. The trie path shares work across values and runs
748+
// on the calling goroutine; on scattered queries that would fan the trie out it falls
749+
// back to a linear existence-bitmap scan, which parallelism splits across goroutines.
749750
func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap {
750751
if b.eBM.IsEmpty() || len(values) == 0 {
751752
return roaring.NewBitmap()
@@ -773,13 +774,121 @@ func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap {
773774
}
774775
sort.Slice(vals, func(i, j int) bool { return vals[i] < vals[j] })
775776

777+
if len(vals) >= 128 && b.shouldUseParallelScan(vals, bitCount) {
778+
result := b.parallelBatchEqualScan(parallelism, vals)
779+
if b.runOptimized {
780+
result.RunOptimize()
781+
}
782+
return result
783+
}
784+
776785
result := b.matchTrie(vals, bitCount-1, b.eBM, false)
777786
if b.runOptimized {
778787
result.RunOptimize()
779788
}
780789
return result
781790
}
782791

792+
// shouldUseParallelScan reports whether BatchEqual should skip the match trie in
793+
// favor of a linear existence-bitmap scan. estimateBranchCount caps the trie's
794+
// branch fan-out; past the crossover the trie degenerates into an intermediate-
795+
// bitmap blowup, and only then is the scan's goroutine cost worth paying. The
796+
// scan also needs a large existence bitmap for the parallelism to earn its keep.
797+
func (b *BSI) shouldUseParallelScan(vals []uint64, bitCount int) bool {
798+
return estimateBranchCount(vals, bitCount-1, 64) >= 64 && b.eBM.GetCardinality() >= 100000
799+
}
800+
801+
// estimateBranchCount bounds the branches the match trie would take on vals over
802+
// planes [0, p], stopping once the count reaches limit. Perfectly contiguous
803+
// ranges collapse to zero. It reads sorted query values only, so the estimate
804+
// costs nothing against the data.
805+
func estimateBranchCount(vals []uint64, p int, limit int) int {
806+
if len(vals) <= 1 || limit <= 0 {
807+
return 0
808+
}
809+
if vals[len(vals)-1]-vals[0] == uint64(len(vals)-1) {
810+
return 0
811+
}
812+
if p >= 64 {
813+
p = 63
814+
}
815+
if p < 0 || (p < 63 && uint64(len(vals)) == uint64(1)<<uint(p+1)) {
816+
return 0
817+
}
818+
mask := uint64(1) << uint(p)
819+
cut := sort.Search(len(vals), func(i int) bool {
820+
return vals[i]&mask != 0
821+
})
822+
if cut == 0 || cut == len(vals) {
823+
return estimateBranchCount(vals, p-1, limit)
824+
}
825+
leftLimit := limit - 1
826+
leftBranch := estimateBranchCount(vals[:cut], p-1, leftLimit)
827+
rightLimit := leftLimit - leftBranch
828+
rightBranch := estimateBranchCount(vals[cut:], p-1, rightLimit)
829+
return 1 + leftBranch + rightBranch
830+
}
831+
832+
// parallelBatchEqualScan computes BatchEqual by a flat membership scan: it reads
833+
// each existing column's value with GetValue and keeps the ones present in vals.
834+
// The existence bitmap is authoritative, so the result is always a subset of it.
835+
// This trades the trie's intermediate-bitmap allocations for one pass over the
836+
// data, which wins when the query fans the trie out (see shouldUseParallelScan).
837+
func (b *BSI) parallelBatchEqualScan(parallelism int, vals []uint64) *roaring.Bitmap {
838+
want := make(map[uint64]struct{}, len(vals))
839+
for _, v := range vals {
840+
want[v] = struct{}{}
841+
}
842+
843+
n := parallelism
844+
if n <= 0 {
845+
n = runtime.NumCPU()
846+
}
847+
card := b.eBM.GetCardinality()
848+
if card == 0 {
849+
return roaring.NewBitmap()
850+
}
851+
if uint64(n) > card {
852+
n = int(card)
853+
}
854+
855+
x := card / uint64(n)
856+
remainder := card - (x * uint64(n))
857+
iter := b.eBM.ManyIterator()
858+
resultsChan := make(chan *roaring.Bitmap, n)
859+
860+
var wg sync.WaitGroup
861+
for i := 0; i < n; i++ {
862+
size := x
863+
if i == n-1 {
864+
size += remainder
865+
}
866+
batch := make([]uint32, size)
867+
iter.NextMany(batch)
868+
wg.Add(1)
869+
go func(cols []uint32) {
870+
defer wg.Done()
871+
out := roaring.NewBitmap()
872+
for _, col := range cols {
873+
if v, ok := b.GetValue(uint64(col)); ok {
874+
if _, hit := want[uint64(v)]; hit {
875+
out.Add(col)
876+
}
877+
}
878+
}
879+
resultsChan <- out
880+
}(batch)
881+
}
882+
wg.Wait()
883+
close(resultsChan)
884+
885+
ba := make([]*roaring.Bitmap, 0, n)
886+
for bm := range resultsChan {
887+
ba = append(ba, bm)
888+
}
889+
return roaring.ParOr(0, ba...)
890+
}
891+
783892
// matchTrie returns the columns in prefix whose bits on planes [0, p] equal the low
784893
// p+1 bits of any value in vals. vals must be unique, sorted, and share identical bits
785894
// above plane p; prefix holds the columns matching those shared upper bits, so results

BitSliceIndexing/bsi_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,50 @@ func TestTransposeWithCountsNil(t *testing.T) {
502502
assert.True(t, ok)
503503
assert.Equal(t, int64(2), a)
504504
}
505+
506+
// TestBatchEqualLargeQueryValues drives the scattered-query scan path: a large
507+
// existence bitmap and a 128+ value query push BatchEqual past the crossover, and
508+
// the result is pinned to the GetValue ground truth across parallelism levels, so
509+
// the linear scan and its partitioning stay authoritative (subset of eBM) and exact.
510+
func TestBatchEqualLargeQueryValues(t *testing.T) {
511+
rg := rand.New(rand.NewSource(12345))
512+
for run := 0; run < 10; run++ {
513+
// Large values (>= 2^20) and a big column set push past the scan crossover.
514+
bsi := NewDefaultBSI()
515+
numCols := rg.Intn(50000) + 120000
516+
for col := 0; col < numCols; col++ {
517+
if rg.Float64() < 0.8 {
518+
val := rg.Int63n(100000) + 1048500
519+
bsi.SetValue(uint64(col), val)
520+
}
521+
}
522+
523+
querySize := rg.Intn(100) + 128
524+
query := make([]int64, querySize)
525+
for i := range query {
526+
query[i] = rg.Int63n(100100) + 1048500
527+
}
528+
529+
// Ground truth: GetValue per existing column.
530+
expected := roaring.NewBitmap()
531+
valMap := make(map[int64]bool)
532+
for _, q := range query {
533+
valMap[q] = true
534+
}
535+
iter := bsi.GetExistenceBitmap().Iterator()
536+
for iter.HasNext() {
537+
col := iter.Next()
538+
val, ok := bsi.GetValue(uint64(col))
539+
if ok && valMap[val] {
540+
expected.Add(col)
541+
}
542+
}
543+
544+
for _, parallelism := range []int{0, 1, 2, 4} {
545+
actual := bsi.BatchEqual(parallelism, query)
546+
if !actual.Equals(expected) {
547+
t.Fatalf("mismatch in run %d parallelism %d: expected %v, got %v", run, parallelism, expected.ToArray(), actual.ToArray())
548+
}
549+
}
550+
}
551+
}

0 commit comments

Comments
 (0)