From e0a362eaccf8f1645f7fce53d0bb7d58fa8470a3 Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Tue, 7 Jul 2026 00:18:17 +0000 Subject: [PATCH 1/2] perf(BSI): bound BatchEqual memory on scattered IN-lists with parallel scan fallback --- BitSliceIndexing/bsi.go | 113 ++++++++++ BitSliceIndexing/bsi_benchmark_test.go | 38 +--- BitSliceIndexing/bsi_successor_test.go | 202 ++++++++++++++++++ .../bsi_sweep_clustered_n128_test.go | 22 ++ BitSliceIndexing/bsi_sweep_n17_test.go | 19 ++ BitSliceIndexing/bsi_sweep_test.go | 35 +++ BitSliceIndexing/bsi_test.go | 159 ++++++++++++++ bsi_helper.go | 159 ++++++++++++++ roaring.go | 5 + 9 files changed, 715 insertions(+), 37 deletions(-) create mode 100644 BitSliceIndexing/bsi_successor_test.go create mode 100644 BitSliceIndexing/bsi_sweep_clustered_n128_test.go create mode 100644 BitSliceIndexing/bsi_sweep_n17_test.go create mode 100644 BitSliceIndexing/bsi_sweep_test.go create mode 100644 bsi_helper.go diff --git a/BitSliceIndexing/bsi.go b/BitSliceIndexing/bsi.go index 00c1674b..0a2a04ee 100644 --- a/BitSliceIndexing/bsi.go +++ b/BitSliceIndexing/bsi.go @@ -773,6 +773,14 @@ func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap { } sort.Slice(vals, func(i, j int) bool { return vals[i] < vals[j] }) + if len(vals) >= 128 && b.shouldUseParallelScan(vals, bitCount) { + result := b.parallelBatchEqualScan(parallelism, vals) + if b.runOptimized { + result.RunOptimize() + } + return result + } + result := b.matchTrie(vals, bitCount-1, b.eBM, false) if b.runOptimized { result.RunOptimize() @@ -780,6 +788,111 @@ func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap { return result } +func (b *BSI) shouldUseParallelScan(vals []uint64, bitCount int) bool { + // Guard against BSIs with excessive bitplanes to ensure safe limits. + if bitCount > 128 { + return false + } + // Avoid scanning for perfectly contiguous ranges which are highly efficient on the trie. + if vals[len(vals)-1]-vals[0] == uint64(len(vals)-1) { + return false + } + // If existence bitmap has only 1 container, sequential trie walk is already very fast. + if b.eBM.GetContainerCount() < 2 { + return false + } + // Verify if the unique query values split frequently to cause a branch blowup on the trie. + if estimateBranchCount(vals, bitCount-1, 64) < 64 { + return false + } + // The overhead of launching goroutines is only justified on sufficiently large existence bitmaps. + if b.eBM.GetCardinality() < 100000 { + return false + } + return true +} + +func estimateBranchCount(vals []uint64, p int, limit int) int { + if len(vals) <= 1 || limit <= 0 { + return 0 + } + // Quick O(1) check: if the values are perfectly contiguous, branch count is 0 + if vals[len(vals)-1]-vals[0] == uint64(len(vals)-1) { + return 0 + } + if p >= 64 { + p = 63 + } + if p < 0 || (p < 63 && uint64(len(vals)) == uint64(1)< 1024 { + n = 1024 + } + + card := b.eBM.GetCardinality() + if card == 0 { + return roaring.NewBitmap() + } + if uint64(n) > card { + n = int(card) + } + + resultsChan := make(chan *roaring.Bitmap, n) + x := card / uint64(n) + remainder := card - (x * uint64(n)) + + var wg sync.WaitGroup + iter := b.eBM.ManyIterator() + + bitCount := b.BitCount() + + for i := 0; i < n; i++ { + var batch []uint32 + if i == n-1 { + batch = make([]uint32, x+remainder) + } else { + batch = make([]uint32, x) + } + iter.NextMany(batch) + wg.Add(1) + go func(cols []uint32) { + defer wg.Done() + out := roaring.ParallelBSIScanHelper(cols, b.bA, bitCount, vals) + resultsChan <- out + }(batch) + } + + wg.Wait() + close(resultsChan) + + ba := make([]*roaring.Bitmap, 0, n) + for bm := range resultsChan { + ba = append(ba, bm) + } + + return roaring.ParOr(0, ba...) +} + // matchTrie returns the columns in prefix whose bits on planes [0, p] equal the low // p+1 bits of any value in vals. vals must be unique, sorted, and share identical bits // above plane p; prefix holds the columns matching those shared upper bits, so results diff --git a/BitSliceIndexing/bsi_benchmark_test.go b/BitSliceIndexing/bsi_benchmark_test.go index 4d6519f6..b10cfce1 100644 --- a/BitSliceIndexing/bsi_benchmark_test.go +++ b/BitSliceIndexing/bsi_benchmark_test.go @@ -172,43 +172,7 @@ func TestBatchEqualConsistentWithGetValue(t *testing.T) { } } -// TestBatchEqualExistenceAuthority pins BatchEqual results to the existence -// bitmap. UnmarshalBinary accepts plane data that is not a subset of eBM (the -// checked-in testdata/age fixture is such data), and every read path treats -// eBM as authoritative, so columns present in a plane but absent from eBM must -// never appear in results. -func TestBatchEqualExistenceAuthority(t *testing.T) { - // Synthetic state: column 2 has bits in plane 0 but is absent from eBM. - ebm := roaring.BitmapOf(1) - plane := roaring.BitmapOf(1, 2) - ebmData, err := ebm.MarshalBinary() - if err != nil { - t.Fatal(err) - } - planeData, err := plane.MarshalBinary() - if err != nil { - t.Fatal(err) - } - bsi := NewDefaultBSI() - if err := bsi.UnmarshalBinary([][]byte{ebmData, planeData}); err != nil { - t.Fatal(err) - } - res := bsi.BatchEqual(0, []int64{1}) - assert.True(t, res.Contains(1)) - assert.False(t, res.Contains(2), "column 2 is not in eBM and must not match") - - // The age fixture ships with plane cardinalities above the eBM cardinality; - // results must still be a subset of eBM. - large := setupLargeBSI(t) - if large == nil { - t.Skip("skipping, large BSI setup failed") - } - for _, vals := range [][]int64{{16}, {55, 57}, {0, 1, 2, 3}} { - res := large.BatchEqual(0, vals) - outside := roaring.AndNot(res, large.GetExistenceBitmap()) - assert.True(t, outside.IsEmpty(), "BatchEqual(%v) returned %d columns outside eBM", vals, outside.GetCardinality()) - } -} +// TestBatchEqualExistenceAuthority has been relocated to bsi_test.go // Benchmarks across query-list shapes: work sharing behaves differently for // small lists, dense contiguous ranges (which collapse to a few plane diff --git a/BitSliceIndexing/bsi_successor_test.go b/BitSliceIndexing/bsi_successor_test.go new file mode 100644 index 00000000..73cd1eaf --- /dev/null +++ b/BitSliceIndexing/bsi_successor_test.go @@ -0,0 +1,202 @@ +package roaring + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/RoaringBitmap/roaring/v2" + "github.com/stretchr/testify/assert" +) + +func TestBatchEqualLargeQueryValues(t *testing.T) { + rg := rand.New(rand.NewSource(12345)) + for run := 0; run < 10; run++ { + // Create a randomized BSI with large values >= 1,048,576 + bsi := NewDefaultBSI() + numCols := rg.Intn(50000) + 120000 + for col := 0; col < numCols; col++ { + if rg.Float64() < 0.8 { + // Generate some large positive values around 1,048,576 + val := rg.Int63n(100000) + 1048500 + bsi.SetValue(uint64(col), val) + } + } + + // Generate query values containing values >= 1,048,576 + querySize := rg.Intn(100) + 128 + query := make([]int64, querySize) + for i := range query { + query[i] = rg.Int63n(100100) + 1048500 + } + + // Ground truth + expected := roaring.NewBitmap() + valMap := make(map[int64]bool) + for _, q := range query { + valMap[q] = true + } + iter := bsi.GetExistenceBitmap().Iterator() + for iter.HasNext() { + col := iter.Next() + val, ok := bsi.GetValue(uint64(col)) + if ok && valMap[val] { + expected.Add(col) + } + } + + // Test different parallelism settings + for _, parallelism := range []int{0, 1, 2, 4} { + actual := bsi.BatchEqual(parallelism, query) + if !actual.Equals(expected) { + t.Fatalf("Mismatch with large query values in run %d parallelism %d. Expected: %v, Got: %v", run, parallelism, expected.ToArray(), actual.ToArray()) + } + } + } +} + +func TestBatchEqualParallelScanCheckedInFixture(t *testing.T) { + large := setupLargeBSI(t) + if large == nil { + t.Skip("skipping, large BSI setup failed") + } + + // Generate a query that triggers the parallel scan path (e.g. 130 scattered values) + rg := rand.New(rand.NewSource(12345)) + vals := make([]int64, 130) + for i := range vals { + vals[i] = rg.Int63n(100) + } + + // Result from the fallback path (either automatically triggered or explicitly run) + resAuto := large.BatchEqual(0, vals) + + // Since len(vals) >= 128 and estimateBranchCount >= 64, + // BatchEqual(0, vals) will run the parallel scan path. + // Let's verify that the results are a subset of eBM and perfectly match the ground truth. + outside := roaring.AndNot(resAuto, large.GetExistenceBitmap()) + assert.True(t, outside.IsEmpty(), "parallel scan returned columns outside eBM") + + // Let's also verify consistency with GetValue ground truth + expected := roaring.NewBitmap() + valMap := make(map[int64]bool) + for _, q := range vals { + valMap[q] = true + } + iter := large.GetExistenceBitmap().Iterator() + for iter.HasNext() { + col := iter.Next() + val, ok := large.GetValue(uint64(col)) + if ok && valMap[val] { + expected.Add(col) + } + } + + assert.True(t, resAuto.Equals(expected), "Parallel scan results do not match ground truth on checked-in fixture") +} + +func BenchmarkBatchEqualM128ScatteredLargeValues(b *testing.B) { + rg := rand.New(rand.NewSource(12345)) + bsi := NewDefaultBSI() + numCols := 50000 + for col := 0; col < numCols; col++ { + if rg.Float64() < 0.8 { + val := rg.Int63n(100000) + 1048500 + bsi.SetValue(uint64(col), val) + } + } + + vals := make([]int64, 128) + for i := range vals { + vals[i] = rg.Int63n(100000) + 1048500 + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res := bsi.BatchEqual(0, vals) + _ = res + } +} + +func BenchmarkBatchEqualScatteredLowCardinality(b *testing.B) { + rg := rand.New(rand.NewSource(12345)) + bsi := NewDefaultBSI() + numCols := 500 // low cardinality + for col := 0; col < numCols; col++ { + if rg.Float64() < 0.8 { + val := rg.Int63n(100) + bsi.SetValue(uint64(col), val) + } + } + vals := make([]int64, 128) + for i := range vals { + vals[i] = rg.Int63n(100) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} + +func BenchmarkBatchEqualScatteredFewPlanes(b *testing.B) { + rg := rand.New(rand.NewSource(12345)) + bsi := NewBSI(3, 0) // only 2 bitplanes + numCols := 50000 + for col := 0; col < numCols; col++ { + if rg.Float64() < 0.8 { + val := rg.Int63n(4) + bsi.SetValue(uint64(col), val) + } + } + vals := make([]int64, 128) + for i := range vals { + vals[i] = rg.Int63n(4) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} + +func BenchmarkBatchEqualSweepBranchCount(b *testing.B) { + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + + for _, count := range []int{2, 4, 8, 12, 14, 15, 16, 18, 20, 24, 32} { + b.Run(fmt.Sprintf("BranchCount_%d", count), func(b *testing.B) { + vals := make([]int64, count) + for i := range vals { + vals[i] = int64(i) * 5 + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + res := bsi.BatchEqual(0, vals) + _ = res + } + }) + } +} + +func BenchmarkBatchEqualClustered(b *testing.B) { + rg := rand.New(rand.NewSource(12345)) + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + // Generate query values that form clustered ranges: e.g. 8 clusters of 4 contiguous values each + vals := make([]int64, 0, 32) + for cluster := 0; cluster < 8; cluster++ { + start := rg.Int63n(100) * 10 + for i := int64(0); i < 4; i++ { + vals = append(vals, start+i) + } + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} diff --git a/BitSliceIndexing/bsi_sweep_clustered_n128_test.go b/BitSliceIndexing/bsi_sweep_clustered_n128_test.go new file mode 100644 index 00000000..206b1350 --- /dev/null +++ b/BitSliceIndexing/bsi_sweep_clustered_n128_test.go @@ -0,0 +1,22 @@ +package roaring + +import "testing" + +func BenchmarkBatchEqualClusteredLargeN128(b *testing.B) { + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + vals := make([]int64, 0, 128) + for cluster := 0; cluster < 16; cluster++ { + start := int64(cluster) * 100 + for i := int64(0); i < 8; i++ { + vals = append(vals, start+i) + } + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} diff --git a/BitSliceIndexing/bsi_sweep_n17_test.go b/BitSliceIndexing/bsi_sweep_n17_test.go new file mode 100644 index 00000000..11273d3c --- /dev/null +++ b/BitSliceIndexing/bsi_sweep_n17_test.go @@ -0,0 +1,19 @@ +package roaring + +import "testing" + +func BenchmarkBatchEqualScatteredN17Sweep(b *testing.B) { + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + vals := make([]int64, 17) + for i := range vals { + vals[i] = int64(i) * 5 + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} diff --git a/BitSliceIndexing/bsi_sweep_test.go b/BitSliceIndexing/bsi_sweep_test.go new file mode 100644 index 00000000..73b928e4 --- /dev/null +++ b/BitSliceIndexing/bsi_sweep_test.go @@ -0,0 +1,35 @@ +package roaring + +import "testing" + +func BenchmarkBatchEqualScatteredN15(b *testing.B) { + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + vals := make([]int64, 15) + for i := range vals { + vals[i] = int64(i) * 5 + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} + +func BenchmarkBatchEqualScatteredN17(b *testing.B) { + bsi := setupLargeBSI(b) + if bsi == nil { + b.Skip("skipping, large BSI setup failed") + return + } + vals := make([]int64, 17) + for i := range vals { + vals[i] = int64(i) * 5 + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = bsi.BatchEqual(0, vals) + } +} diff --git a/BitSliceIndexing/bsi_test.go b/BitSliceIndexing/bsi_test.go index b28d0044..46b3a29e 100644 --- a/BitSliceIndexing/bsi_test.go +++ b/BitSliceIndexing/bsi_test.go @@ -502,3 +502,162 @@ func TestTransposeWithCountsNil(t *testing.T) { assert.True(t, ok) assert.Equal(t, int64(2), a) } + +func TestBatchEqualParallelBSIScanHelperAssertion(t *testing.T) { + unsortedCols := []uint32{10, 5, 20} + sortedCols := []uint32{5, 10, 20} + emptyCols := []uint32{} + + t.Run("ParallelBSIScanHelper_Unsorted", func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Errorf("Expected ParallelBSIScanHelper to panic on unsorted cols") + } + msg, ok := r.(string) + if !ok || msg != "ParallelBSIScanHelper: input cols must be sorted in ascending order" { + t.Errorf("Expected specific panic message, got: %v", r) + } + }() + _ = roaring.ParallelBSIScanHelper(unsortedCols, nil, 0, nil) + }) + + t.Run("ParallelBSIScanHelper_SortedAndEmpty", func(t *testing.T) { + dummyBA := []*roaring.Bitmap{roaring.NewBitmap()} + vals := []uint64{0, 1} + _ = roaring.ParallelBSIScanHelper(sortedCols, dummyBA, 1, vals) + _ = roaring.ParallelBSIScanHelper(emptyCols, dummyBA, 1, vals) + }) +} + +func TestBatchEqualParallelBSIScanHelperValsAssertion(t *testing.T) { + unsortedVals := []uint64{10, 5, 20} + sortedCols := []uint32{5, 10, 20} + dummyBA := []*roaring.Bitmap{roaring.NewBitmap()} + + t.Run("ParallelBSIScanHelper_UnsortedVals", func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Errorf("Expected ParallelBSIScanHelper to panic on unsorted vals") + } + msg, ok := r.(string) + if !ok || msg != "ParallelBSIScanHelper: input vals must be sorted in ascending order" { + t.Errorf("Expected specific panic message, got: %v", r) + } + }() + _ = roaring.ParallelBSIScanHelper(sortedCols, dummyBA, 1, unsortedVals) + }) +} + +func TestBatchEqualManyBitplanes(t *testing.T) { + // Create a BSI with 70 bitplanes (more than 64!) + bsi := NewDefaultBSI() + + bsi.eBM.Add(1) + bsi.eBM.Add(2) + bsi.eBM.Add(3) + bsi.eBM.Add(4) + + bsi.bA = make([]*roaring.Bitmap, 70) + for i := range bsi.bA { + bsi.bA[i] = roaring.NewBitmap() + } + + // Column 1: value is 1<<65 (so only plane 65 has it) + bsi.bA[65].Add(1) + // Column 2: value is 1<<3 (so only plane 3 has it) + bsi.bA[3].Add(2) + // Column 3: value is (1<<65) | (1<<3) + bsi.bA[65].Add(3) + bsi.bA[3].Add(3) + // Column 4: value is 1<<3 + bsi.bA[3].Add(4) + + query := []int64{8} + + // Test Trie Path + resTrie := bsi.BatchEqual(0, query) + assert.True(t, resTrie.Contains(2)) + assert.True(t, resTrie.Contains(4)) + assert.False(t, resTrie.Contains(1)) + assert.False(t, resTrie.Contains(3)) + + // Test Parallel Scan Path + vals := []uint64{8} + resScan := bsi.parallelBatchEqualScan(1, vals) + assert.True(t, resScan.Contains(2)) + assert.True(t, resScan.Contains(4)) + assert.False(t, resScan.Contains(1)) + assert.False(t, resScan.Contains(3)) +} + +// TestBatchEqualExistenceAuthority pins BatchEqual results to the existence +// bitmap. UnmarshalBinary accepts plane data that is not a subset of eBM (the +// checked-in testdata/age fixture is such data), and every read path treats +// eBM as authoritative, so columns present in a plane but absent from eBM must +// never appear in results. +func TestBatchEqualExistenceAuthority(t *testing.T) { + // Synthetic state: column 2 has bits in plane 0 but is absent from eBM. + ebm := roaring.BitmapOf(1) + plane := roaring.BitmapOf(1, 2) + ebmData, err := ebm.MarshalBinary() + if err != nil { + t.Fatal(err) + } + planeData, err := plane.MarshalBinary() + if err != nil { + t.Fatal(err) + } + bsi := NewDefaultBSI() + if err := bsi.UnmarshalBinary([][]byte{ebmData, planeData}); err != nil { + t.Fatal(err) + } + res := bsi.BatchEqual(0, []int64{1}) + assert.True(t, res.Contains(1)) + assert.False(t, res.Contains(2), "column 2 is not in eBM and must not match") + + // The age fixture ships with plane cardinalities above the eBM cardinality; + // results must still be a subset of eBM. + large := setupLargeBSI(t) + if large == nil { + t.Skip("skipping, large BSI setup failed") + } + for _, vals := range [][]int64{{16}, {55, 57}, {0, 1, 2, 3}} { + res := large.BatchEqual(0, vals) + outside := roaring.AndNot(res, large.GetExistenceBitmap()) + assert.True(t, outside.IsEmpty(), "BatchEqual(%v) returned %d columns outside eBM", vals, outside.GetCardinality()) + } +} + +func TestBatchEqualManyBitplanesPanicSafety(t *testing.T) { + // Create a BSI with 130 bitplanes (exceeding 128) + bsi := NewDefaultBSI() + + bsi.eBM.Add(1) + bsi.eBM.Add(2) + + bsi.bA = make([]*roaring.Bitmap, 130) + for i := range bsi.bA { + bsi.bA[i] = roaring.NewBitmap() + } + + // Set value on plane 129 + bsi.bA[129].Add(1) + + // query with 130 scattered values (size >= 128) + query := make([]int64, 130) + for i := range query { + query[i] = int64(i) * 3 + } + + // This must not panic (it should safely stay on the trie walk and complete) + defer func() { + if r := recover(); r != nil { + t.Fatalf("BatchEqual panicked with > 128 bitplanes: %v", r) + } + }() + + res := bsi.BatchEqual(0, query) + _ = res +} diff --git a/bsi_helper.go b/bsi_helper.go new file mode 100644 index 00000000..c1179bbd --- /dev/null +++ b/bsi_helper.go @@ -0,0 +1,159 @@ +package roaring + +type activeContState struct { + cType int // 0: array, 1: bitmap, 2: run + shift uint + + // arrayContainer fields + arrayContent []uint16 + arrayIdx int + + // bitmapContainer fields + bitmapContent []uint64 + + // runContainer16 fields + runIv []interval16 + runIdx int +} + +// ParallelBSIScanHelper is an internal optimization helper. It is exported solely because +// the BSI functionality resides in the subpackage "github.com/RoaringBitmap/roaring/v2/BitSliceIndexing" +// which must call into the core package to perform the fast parallel linear scan. +// This function accesses unexported container implementations and their fields (such as keys, +// arrayContainer.content, bitmapContainer.bitmap, and runContainer16.iv) to perform +// highly specialized, zero-allocation container-level membership scans, which would be +// impossible to implement with the same efficiency on the public API. +// +// Normal library users should not call this function directly. +func ParallelBSIScanHelper(cols []uint32, bA []*Bitmap, bitCount int, vals []uint64) *Bitmap { + // Guard the sorted column ID assumption + for i := 1; i < len(cols); i++ { + if cols[i] < cols[i-1] { + panic("ParallelBSIScanHelper: input cols must be sorted in ascending order") + } + } + + // Guard the sorted vals assumption + for i := 1; i < len(vals); i++ { + if vals[i] < vals[i-1] { + panic("ParallelBSIScanHelper: input vals must be sorted in ascending order") + } + } + + out := NewBitmap() + nCols := len(cols) + if nCols == 0 { + return out + } + + if bitCount > 128 { + panic("ParallelBSIScanHelper: bitCount exceeds 128") + } + var curIndexBuf [128]int + curIndex := curIndexBuf[:bitCount] + + var iCol int + for iCol < nCols { + col := cols[iCol] + hb := uint16(col >> 16) + + var activeBuf [128]activeContState + active := activeBuf[:0] + + for p := 0; p < bitCount; p++ { + ra := bA[p].highlowcontainer + idx := ra.binarySearch(int64(curIndex[p]), int64(len(ra.keys)), hb) + if idx >= 0 { + curIndex[p] = idx + c := ra.containers[idx] + + state := activeContState{ + shift: uint(p), + } + switch tc := c.(type) { + case *arrayContainer: + state.cType = 0 + state.arrayContent = tc.content + case *bitmapContainer: + state.cType = 1 + state.bitmapContent = tc.bitmap + case *runContainer16: + state.cType = 2 + state.runIv = tc.iv + } + active = append(active, state) + } else { + curIndex[p] = -idx - 1 + } + } + + // Process all columns in the batch that share this hb + for iCol < nCols { + currCol := cols[iCol] + currHb := uint16(currCol >> 16) + if currHb != hb { + break + } + + val := uint64(0) + overflow := false + lb := uint16(currCol & 0xffff) + for p := 0; p < len(active); p++ { + ac := &active[p] + found := false + switch ac.cType { + case 0: // arrayContainer + for ac.arrayIdx < len(ac.arrayContent) && ac.arrayContent[ac.arrayIdx] < lb { + ac.arrayIdx++ + } + if ac.arrayIdx < len(ac.arrayContent) && ac.arrayContent[ac.arrayIdx] == lb { + found = true + } + case 1: // bitmapContainer + if (ac.bitmapContent[lb>>6] & (uint64(1) << (lb & 63))) != 0 { + found = true + } + case 2: // runContainer16 + for ac.runIdx < len(ac.runIv) && uint32(ac.runIv[ac.runIdx].start)+uint32(ac.runIv[ac.runIdx].length) < uint32(lb) { + ac.runIdx++ + } + if ac.runIdx < len(ac.runIv) && lb >= ac.runIv[ac.runIdx].start { + found = true + } + } + + if found { + if ac.shift >= 64 { + overflow = true + break + } + val |= uint64(1) << ac.shift + } + } + + if !overflow { + // Binary search inline on vals + l, r := 0, len(vals)-1 + foundVal := false + for l <= r { + m := (l + r) >> 1 + v := vals[m] + if v == val { + foundVal = true + break + } + if v < val { + l = m + 1 + } else { + r = m - 1 + } + } + if foundVal { + out.Add(currCol) + } + } + iCol++ + } + } + return out +} diff --git a/roaring.go b/roaring.go index 50ae58e9..faa330b3 100644 --- a/roaring.go +++ b/roaring.go @@ -1243,6 +1243,11 @@ func (rb *Bitmap) IsEmpty() bool { return rb.highlowcontainer.size() == 0 } +// GetContainerCount returns the number of containers in the bitmap. +func (rb *Bitmap) GetContainerCount() int { + return rb.highlowcontainer.size() +} + // GetCardinality returns the number of integers contained in the bitmap func (rb *Bitmap) GetCardinality() uint64 { size := uint64(0) From 3e12681deefc53401b7ce1c72899fdb24cfc00a9 Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Thu, 9 Jul 2026 14:31:39 +0200 Subject: [PATCH 2/2] perf(BSI): keep BatchEqual fallback in-package, drop exported helper --- BitSliceIndexing/bsi.go | 78 ++++--- BitSliceIndexing/bsi_benchmark_test.go | 38 +++- BitSliceIndexing/bsi_successor_test.go | 202 ------------------ .../bsi_sweep_clustered_n128_test.go | 22 -- BitSliceIndexing/bsi_sweep_n17_test.go | 19 -- BitSliceIndexing/bsi_sweep_test.go | 35 --- BitSliceIndexing/bsi_test.go | 192 ++++------------- bsi_helper.go | 159 -------------- roaring.go | 5 - 9 files changed, 114 insertions(+), 636 deletions(-) delete mode 100644 BitSliceIndexing/bsi_successor_test.go delete mode 100644 BitSliceIndexing/bsi_sweep_clustered_n128_test.go delete mode 100644 BitSliceIndexing/bsi_sweep_n17_test.go delete mode 100644 BitSliceIndexing/bsi_sweep_test.go delete mode 100644 bsi_helper.go diff --git a/BitSliceIndexing/bsi.go b/BitSliceIndexing/bsi.go index 0a2a04ee..6b2ddc56 100644 --- a/BitSliceIndexing/bsi.go +++ b/BitSliceIndexing/bsi.go @@ -744,8 +744,9 @@ func (b *BSI) MarshalBinary() ([][]byte, error) { } // BatchEqual returns a bitmap containing the column IDs where the values are contained -// within the list of values provided. The parallelism parameter is accepted for API -// compatibility; work is shared across values, so the query runs on the calling goroutine. +// within the list of values provided. The trie path shares work across values and runs +// on the calling goroutine; on scattered queries that would fan the trie out it falls +// back to a linear existence-bitmap scan, which parallelism splits across goroutines. func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap { if b.eBM.IsEmpty() || len(values) == 0 { return roaring.NewBitmap() @@ -788,35 +789,23 @@ func (b *BSI) BatchEqual(parallelism int, values []int64) *roaring.Bitmap { return result } +// shouldUseParallelScan reports whether BatchEqual should skip the match trie in +// favor of a linear existence-bitmap scan. estimateBranchCount caps the trie's +// branch fan-out; past the crossover the trie degenerates into an intermediate- +// bitmap blowup, and only then is the scan's goroutine cost worth paying. The +// scan also needs a large existence bitmap for the parallelism to earn its keep. func (b *BSI) shouldUseParallelScan(vals []uint64, bitCount int) bool { - // Guard against BSIs with excessive bitplanes to ensure safe limits. - if bitCount > 128 { - return false - } - // Avoid scanning for perfectly contiguous ranges which are highly efficient on the trie. - if vals[len(vals)-1]-vals[0] == uint64(len(vals)-1) { - return false - } - // If existence bitmap has only 1 container, sequential trie walk is already very fast. - if b.eBM.GetContainerCount() < 2 { - return false - } - // Verify if the unique query values split frequently to cause a branch blowup on the trie. - if estimateBranchCount(vals, bitCount-1, 64) < 64 { - return false - } - // The overhead of launching goroutines is only justified on sufficiently large existence bitmaps. - if b.eBM.GetCardinality() < 100000 { - return false - } - return true + return estimateBranchCount(vals, bitCount-1, 64) >= 64 && b.eBM.GetCardinality() >= 100000 } +// estimateBranchCount bounds the branches the match trie would take on vals over +// planes [0, p], stopping once the count reaches limit. Perfectly contiguous +// ranges collapse to zero. It reads sorted query values only, so the estimate +// costs nothing against the data. func estimateBranchCount(vals []uint64, p int, limit int) int { if len(vals) <= 1 || limit <= 0 { return 0 } - // Quick O(1) check: if the values are perfectly contiguous, branch count is 0 if vals[len(vals)-1]-vals[0] == uint64(len(vals)-1) { return 0 } @@ -840,15 +829,21 @@ func estimateBranchCount(vals []uint64, p int, limit int) int { return 1 + leftBranch + rightBranch } +// parallelBatchEqualScan computes BatchEqual by a flat membership scan: it reads +// each existing column's value with GetValue and keeps the ones present in vals. +// The existence bitmap is authoritative, so the result is always a subset of it. +// This trades the trie's intermediate-bitmap allocations for one pass over the +// data, which wins when the query fans the trie out (see shouldUseParallelScan). func (b *BSI) parallelBatchEqualScan(parallelism int, vals []uint64) *roaring.Bitmap { - var n int = parallelism + want := make(map[uint64]struct{}, len(vals)) + for _, v := range vals { + want[v] = struct{}{} + } + + n := parallelism if n <= 0 { n = runtime.NumCPU() } - if n > 1024 { - n = 1024 - } - card := b.eBM.GetCardinality() if card == 0 { return roaring.NewBitmap() @@ -857,31 +852,33 @@ func (b *BSI) parallelBatchEqualScan(parallelism int, vals []uint64) *roaring.Bi n = int(card) } - resultsChan := make(chan *roaring.Bitmap, n) x := card / uint64(n) remainder := card - (x * uint64(n)) - - var wg sync.WaitGroup iter := b.eBM.ManyIterator() + resultsChan := make(chan *roaring.Bitmap, n) - bitCount := b.BitCount() - + var wg sync.WaitGroup for i := 0; i < n; i++ { - var batch []uint32 + size := x if i == n-1 { - batch = make([]uint32, x+remainder) - } else { - batch = make([]uint32, x) + size += remainder } + batch := make([]uint32, size) iter.NextMany(batch) wg.Add(1) go func(cols []uint32) { defer wg.Done() - out := roaring.ParallelBSIScanHelper(cols, b.bA, bitCount, vals) + out := roaring.NewBitmap() + for _, col := range cols { + if v, ok := b.GetValue(uint64(col)); ok { + if _, hit := want[uint64(v)]; hit { + out.Add(col) + } + } + } resultsChan <- out }(batch) } - wg.Wait() close(resultsChan) @@ -889,7 +886,6 @@ func (b *BSI) parallelBatchEqualScan(parallelism int, vals []uint64) *roaring.Bi for bm := range resultsChan { ba = append(ba, bm) } - return roaring.ParOr(0, ba...) } diff --git a/BitSliceIndexing/bsi_benchmark_test.go b/BitSliceIndexing/bsi_benchmark_test.go index b10cfce1..4d6519f6 100644 --- a/BitSliceIndexing/bsi_benchmark_test.go +++ b/BitSliceIndexing/bsi_benchmark_test.go @@ -172,7 +172,43 @@ func TestBatchEqualConsistentWithGetValue(t *testing.T) { } } -// TestBatchEqualExistenceAuthority has been relocated to bsi_test.go +// TestBatchEqualExistenceAuthority pins BatchEqual results to the existence +// bitmap. UnmarshalBinary accepts plane data that is not a subset of eBM (the +// checked-in testdata/age fixture is such data), and every read path treats +// eBM as authoritative, so columns present in a plane but absent from eBM must +// never appear in results. +func TestBatchEqualExistenceAuthority(t *testing.T) { + // Synthetic state: column 2 has bits in plane 0 but is absent from eBM. + ebm := roaring.BitmapOf(1) + plane := roaring.BitmapOf(1, 2) + ebmData, err := ebm.MarshalBinary() + if err != nil { + t.Fatal(err) + } + planeData, err := plane.MarshalBinary() + if err != nil { + t.Fatal(err) + } + bsi := NewDefaultBSI() + if err := bsi.UnmarshalBinary([][]byte{ebmData, planeData}); err != nil { + t.Fatal(err) + } + res := bsi.BatchEqual(0, []int64{1}) + assert.True(t, res.Contains(1)) + assert.False(t, res.Contains(2), "column 2 is not in eBM and must not match") + + // The age fixture ships with plane cardinalities above the eBM cardinality; + // results must still be a subset of eBM. + large := setupLargeBSI(t) + if large == nil { + t.Skip("skipping, large BSI setup failed") + } + for _, vals := range [][]int64{{16}, {55, 57}, {0, 1, 2, 3}} { + res := large.BatchEqual(0, vals) + outside := roaring.AndNot(res, large.GetExistenceBitmap()) + assert.True(t, outside.IsEmpty(), "BatchEqual(%v) returned %d columns outside eBM", vals, outside.GetCardinality()) + } +} // Benchmarks across query-list shapes: work sharing behaves differently for // small lists, dense contiguous ranges (which collapse to a few plane diff --git a/BitSliceIndexing/bsi_successor_test.go b/BitSliceIndexing/bsi_successor_test.go deleted file mode 100644 index 73cd1eaf..00000000 --- a/BitSliceIndexing/bsi_successor_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package roaring - -import ( - "fmt" - "math/rand" - "testing" - - "github.com/RoaringBitmap/roaring/v2" - "github.com/stretchr/testify/assert" -) - -func TestBatchEqualLargeQueryValues(t *testing.T) { - rg := rand.New(rand.NewSource(12345)) - for run := 0; run < 10; run++ { - // Create a randomized BSI with large values >= 1,048,576 - bsi := NewDefaultBSI() - numCols := rg.Intn(50000) + 120000 - for col := 0; col < numCols; col++ { - if rg.Float64() < 0.8 { - // Generate some large positive values around 1,048,576 - val := rg.Int63n(100000) + 1048500 - bsi.SetValue(uint64(col), val) - } - } - - // Generate query values containing values >= 1,048,576 - querySize := rg.Intn(100) + 128 - query := make([]int64, querySize) - for i := range query { - query[i] = rg.Int63n(100100) + 1048500 - } - - // Ground truth - expected := roaring.NewBitmap() - valMap := make(map[int64]bool) - for _, q := range query { - valMap[q] = true - } - iter := bsi.GetExistenceBitmap().Iterator() - for iter.HasNext() { - col := iter.Next() - val, ok := bsi.GetValue(uint64(col)) - if ok && valMap[val] { - expected.Add(col) - } - } - - // Test different parallelism settings - for _, parallelism := range []int{0, 1, 2, 4} { - actual := bsi.BatchEqual(parallelism, query) - if !actual.Equals(expected) { - t.Fatalf("Mismatch with large query values in run %d parallelism %d. Expected: %v, Got: %v", run, parallelism, expected.ToArray(), actual.ToArray()) - } - } - } -} - -func TestBatchEqualParallelScanCheckedInFixture(t *testing.T) { - large := setupLargeBSI(t) - if large == nil { - t.Skip("skipping, large BSI setup failed") - } - - // Generate a query that triggers the parallel scan path (e.g. 130 scattered values) - rg := rand.New(rand.NewSource(12345)) - vals := make([]int64, 130) - for i := range vals { - vals[i] = rg.Int63n(100) - } - - // Result from the fallback path (either automatically triggered or explicitly run) - resAuto := large.BatchEqual(0, vals) - - // Since len(vals) >= 128 and estimateBranchCount >= 64, - // BatchEqual(0, vals) will run the parallel scan path. - // Let's verify that the results are a subset of eBM and perfectly match the ground truth. - outside := roaring.AndNot(resAuto, large.GetExistenceBitmap()) - assert.True(t, outside.IsEmpty(), "parallel scan returned columns outside eBM") - - // Let's also verify consistency with GetValue ground truth - expected := roaring.NewBitmap() - valMap := make(map[int64]bool) - for _, q := range vals { - valMap[q] = true - } - iter := large.GetExistenceBitmap().Iterator() - for iter.HasNext() { - col := iter.Next() - val, ok := large.GetValue(uint64(col)) - if ok && valMap[val] { - expected.Add(col) - } - } - - assert.True(t, resAuto.Equals(expected), "Parallel scan results do not match ground truth on checked-in fixture") -} - -func BenchmarkBatchEqualM128ScatteredLargeValues(b *testing.B) { - rg := rand.New(rand.NewSource(12345)) - bsi := NewDefaultBSI() - numCols := 50000 - for col := 0; col < numCols; col++ { - if rg.Float64() < 0.8 { - val := rg.Int63n(100000) + 1048500 - bsi.SetValue(uint64(col), val) - } - } - - vals := make([]int64, 128) - for i := range vals { - vals[i] = rg.Int63n(100000) + 1048500 - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - res := bsi.BatchEqual(0, vals) - _ = res - } -} - -func BenchmarkBatchEqualScatteredLowCardinality(b *testing.B) { - rg := rand.New(rand.NewSource(12345)) - bsi := NewDefaultBSI() - numCols := 500 // low cardinality - for col := 0; col < numCols; col++ { - if rg.Float64() < 0.8 { - val := rg.Int63n(100) - bsi.SetValue(uint64(col), val) - } - } - vals := make([]int64, 128) - for i := range vals { - vals[i] = rg.Int63n(100) - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} - -func BenchmarkBatchEqualScatteredFewPlanes(b *testing.B) { - rg := rand.New(rand.NewSource(12345)) - bsi := NewBSI(3, 0) // only 2 bitplanes - numCols := 50000 - for col := 0; col < numCols; col++ { - if rg.Float64() < 0.8 { - val := rg.Int63n(4) - bsi.SetValue(uint64(col), val) - } - } - vals := make([]int64, 128) - for i := range vals { - vals[i] = rg.Int63n(4) - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} - -func BenchmarkBatchEqualSweepBranchCount(b *testing.B) { - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - - for _, count := range []int{2, 4, 8, 12, 14, 15, 16, 18, 20, 24, 32} { - b.Run(fmt.Sprintf("BranchCount_%d", count), func(b *testing.B) { - vals := make([]int64, count) - for i := range vals { - vals[i] = int64(i) * 5 - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - res := bsi.BatchEqual(0, vals) - _ = res - } - }) - } -} - -func BenchmarkBatchEqualClustered(b *testing.B) { - rg := rand.New(rand.NewSource(12345)) - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - // Generate query values that form clustered ranges: e.g. 8 clusters of 4 contiguous values each - vals := make([]int64, 0, 32) - for cluster := 0; cluster < 8; cluster++ { - start := rg.Int63n(100) * 10 - for i := int64(0); i < 4; i++ { - vals = append(vals, start+i) - } - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} diff --git a/BitSliceIndexing/bsi_sweep_clustered_n128_test.go b/BitSliceIndexing/bsi_sweep_clustered_n128_test.go deleted file mode 100644 index 206b1350..00000000 --- a/BitSliceIndexing/bsi_sweep_clustered_n128_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package roaring - -import "testing" - -func BenchmarkBatchEqualClusteredLargeN128(b *testing.B) { - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - vals := make([]int64, 0, 128) - for cluster := 0; cluster < 16; cluster++ { - start := int64(cluster) * 100 - for i := int64(0); i < 8; i++ { - vals = append(vals, start+i) - } - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} diff --git a/BitSliceIndexing/bsi_sweep_n17_test.go b/BitSliceIndexing/bsi_sweep_n17_test.go deleted file mode 100644 index 11273d3c..00000000 --- a/BitSliceIndexing/bsi_sweep_n17_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package roaring - -import "testing" - -func BenchmarkBatchEqualScatteredN17Sweep(b *testing.B) { - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - vals := make([]int64, 17) - for i := range vals { - vals[i] = int64(i) * 5 - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} diff --git a/BitSliceIndexing/bsi_sweep_test.go b/BitSliceIndexing/bsi_sweep_test.go deleted file mode 100644 index 73b928e4..00000000 --- a/BitSliceIndexing/bsi_sweep_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package roaring - -import "testing" - -func BenchmarkBatchEqualScatteredN15(b *testing.B) { - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - vals := make([]int64, 15) - for i := range vals { - vals[i] = int64(i) * 5 - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} - -func BenchmarkBatchEqualScatteredN17(b *testing.B) { - bsi := setupLargeBSI(b) - if bsi == nil { - b.Skip("skipping, large BSI setup failed") - return - } - vals := make([]int64, 17) - for i := range vals { - vals[i] = int64(i) * 5 - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = bsi.BatchEqual(0, vals) - } -} diff --git a/BitSliceIndexing/bsi_test.go b/BitSliceIndexing/bsi_test.go index 46b3a29e..4219c6e9 100644 --- a/BitSliceIndexing/bsi_test.go +++ b/BitSliceIndexing/bsi_test.go @@ -503,161 +503,49 @@ func TestTransposeWithCountsNil(t *testing.T) { assert.Equal(t, int64(2), a) } -func TestBatchEqualParallelBSIScanHelperAssertion(t *testing.T) { - unsortedCols := []uint32{10, 5, 20} - sortedCols := []uint32{5, 10, 20} - emptyCols := []uint32{} - - t.Run("ParallelBSIScanHelper_Unsorted", func(t *testing.T) { - defer func() { - r := recover() - if r == nil { - t.Errorf("Expected ParallelBSIScanHelper to panic on unsorted cols") +// TestBatchEqualLargeQueryValues drives the scattered-query scan path: a large +// existence bitmap and a 128+ value query push BatchEqual past the crossover, and +// the result is pinned to the GetValue ground truth across parallelism levels, so +// the linear scan and its partitioning stay authoritative (subset of eBM) and exact. +func TestBatchEqualLargeQueryValues(t *testing.T) { + rg := rand.New(rand.NewSource(12345)) + for run := 0; run < 10; run++ { + // Large values (>= 2^20) and a big column set push past the scan crossover. + bsi := NewDefaultBSI() + numCols := rg.Intn(50000) + 120000 + for col := 0; col < numCols; col++ { + if rg.Float64() < 0.8 { + val := rg.Int63n(100000) + 1048500 + bsi.SetValue(uint64(col), val) } - msg, ok := r.(string) - if !ok || msg != "ParallelBSIScanHelper: input cols must be sorted in ascending order" { - t.Errorf("Expected specific panic message, got: %v", r) - } - }() - _ = roaring.ParallelBSIScanHelper(unsortedCols, nil, 0, nil) - }) - - t.Run("ParallelBSIScanHelper_SortedAndEmpty", func(t *testing.T) { - dummyBA := []*roaring.Bitmap{roaring.NewBitmap()} - vals := []uint64{0, 1} - _ = roaring.ParallelBSIScanHelper(sortedCols, dummyBA, 1, vals) - _ = roaring.ParallelBSIScanHelper(emptyCols, dummyBA, 1, vals) - }) -} - -func TestBatchEqualParallelBSIScanHelperValsAssertion(t *testing.T) { - unsortedVals := []uint64{10, 5, 20} - sortedCols := []uint32{5, 10, 20} - dummyBA := []*roaring.Bitmap{roaring.NewBitmap()} - - t.Run("ParallelBSIScanHelper_UnsortedVals", func(t *testing.T) { - defer func() { - r := recover() - if r == nil { - t.Errorf("Expected ParallelBSIScanHelper to panic on unsorted vals") - } - msg, ok := r.(string) - if !ok || msg != "ParallelBSIScanHelper: input vals must be sorted in ascending order" { - t.Errorf("Expected specific panic message, got: %v", r) - } - }() - _ = roaring.ParallelBSIScanHelper(sortedCols, dummyBA, 1, unsortedVals) - }) -} - -func TestBatchEqualManyBitplanes(t *testing.T) { - // Create a BSI with 70 bitplanes (more than 64!) - bsi := NewDefaultBSI() - - bsi.eBM.Add(1) - bsi.eBM.Add(2) - bsi.eBM.Add(3) - bsi.eBM.Add(4) - - bsi.bA = make([]*roaring.Bitmap, 70) - for i := range bsi.bA { - bsi.bA[i] = roaring.NewBitmap() - } - - // Column 1: value is 1<<65 (so only plane 65 has it) - bsi.bA[65].Add(1) - // Column 2: value is 1<<3 (so only plane 3 has it) - bsi.bA[3].Add(2) - // Column 3: value is (1<<65) | (1<<3) - bsi.bA[65].Add(3) - bsi.bA[3].Add(3) - // Column 4: value is 1<<3 - bsi.bA[3].Add(4) - - query := []int64{8} - - // Test Trie Path - resTrie := bsi.BatchEqual(0, query) - assert.True(t, resTrie.Contains(2)) - assert.True(t, resTrie.Contains(4)) - assert.False(t, resTrie.Contains(1)) - assert.False(t, resTrie.Contains(3)) - - // Test Parallel Scan Path - vals := []uint64{8} - resScan := bsi.parallelBatchEqualScan(1, vals) - assert.True(t, resScan.Contains(2)) - assert.True(t, resScan.Contains(4)) - assert.False(t, resScan.Contains(1)) - assert.False(t, resScan.Contains(3)) -} - -// TestBatchEqualExistenceAuthority pins BatchEqual results to the existence -// bitmap. UnmarshalBinary accepts plane data that is not a subset of eBM (the -// checked-in testdata/age fixture is such data), and every read path treats -// eBM as authoritative, so columns present in a plane but absent from eBM must -// never appear in results. -func TestBatchEqualExistenceAuthority(t *testing.T) { - // Synthetic state: column 2 has bits in plane 0 but is absent from eBM. - ebm := roaring.BitmapOf(1) - plane := roaring.BitmapOf(1, 2) - ebmData, err := ebm.MarshalBinary() - if err != nil { - t.Fatal(err) - } - planeData, err := plane.MarshalBinary() - if err != nil { - t.Fatal(err) - } - bsi := NewDefaultBSI() - if err := bsi.UnmarshalBinary([][]byte{ebmData, planeData}); err != nil { - t.Fatal(err) - } - res := bsi.BatchEqual(0, []int64{1}) - assert.True(t, res.Contains(1)) - assert.False(t, res.Contains(2), "column 2 is not in eBM and must not match") - - // The age fixture ships with plane cardinalities above the eBM cardinality; - // results must still be a subset of eBM. - large := setupLargeBSI(t) - if large == nil { - t.Skip("skipping, large BSI setup failed") - } - for _, vals := range [][]int64{{16}, {55, 57}, {0, 1, 2, 3}} { - res := large.BatchEqual(0, vals) - outside := roaring.AndNot(res, large.GetExistenceBitmap()) - assert.True(t, outside.IsEmpty(), "BatchEqual(%v) returned %d columns outside eBM", vals, outside.GetCardinality()) - } -} - -func TestBatchEqualManyBitplanesPanicSafety(t *testing.T) { - // Create a BSI with 130 bitplanes (exceeding 128) - bsi := NewDefaultBSI() - - bsi.eBM.Add(1) - bsi.eBM.Add(2) - - bsi.bA = make([]*roaring.Bitmap, 130) - for i := range bsi.bA { - bsi.bA[i] = roaring.NewBitmap() - } - - // Set value on plane 129 - bsi.bA[129].Add(1) + } - // query with 130 scattered values (size >= 128) - query := make([]int64, 130) - for i := range query { - query[i] = int64(i) * 3 - } + querySize := rg.Intn(100) + 128 + query := make([]int64, querySize) + for i := range query { + query[i] = rg.Int63n(100100) + 1048500 + } - // This must not panic (it should safely stay on the trie walk and complete) - defer func() { - if r := recover(); r != nil { - t.Fatalf("BatchEqual panicked with > 128 bitplanes: %v", r) + // Ground truth: GetValue per existing column. + expected := roaring.NewBitmap() + valMap := make(map[int64]bool) + for _, q := range query { + valMap[q] = true + } + iter := bsi.GetExistenceBitmap().Iterator() + for iter.HasNext() { + col := iter.Next() + val, ok := bsi.GetValue(uint64(col)) + if ok && valMap[val] { + expected.Add(col) + } } - }() - res := bsi.BatchEqual(0, query) - _ = res + for _, parallelism := range []int{0, 1, 2, 4} { + actual := bsi.BatchEqual(parallelism, query) + if !actual.Equals(expected) { + t.Fatalf("mismatch in run %d parallelism %d: expected %v, got %v", run, parallelism, expected.ToArray(), actual.ToArray()) + } + } + } } diff --git a/bsi_helper.go b/bsi_helper.go deleted file mode 100644 index c1179bbd..00000000 --- a/bsi_helper.go +++ /dev/null @@ -1,159 +0,0 @@ -package roaring - -type activeContState struct { - cType int // 0: array, 1: bitmap, 2: run - shift uint - - // arrayContainer fields - arrayContent []uint16 - arrayIdx int - - // bitmapContainer fields - bitmapContent []uint64 - - // runContainer16 fields - runIv []interval16 - runIdx int -} - -// ParallelBSIScanHelper is an internal optimization helper. It is exported solely because -// the BSI functionality resides in the subpackage "github.com/RoaringBitmap/roaring/v2/BitSliceIndexing" -// which must call into the core package to perform the fast parallel linear scan. -// This function accesses unexported container implementations and their fields (such as keys, -// arrayContainer.content, bitmapContainer.bitmap, and runContainer16.iv) to perform -// highly specialized, zero-allocation container-level membership scans, which would be -// impossible to implement with the same efficiency on the public API. -// -// Normal library users should not call this function directly. -func ParallelBSIScanHelper(cols []uint32, bA []*Bitmap, bitCount int, vals []uint64) *Bitmap { - // Guard the sorted column ID assumption - for i := 1; i < len(cols); i++ { - if cols[i] < cols[i-1] { - panic("ParallelBSIScanHelper: input cols must be sorted in ascending order") - } - } - - // Guard the sorted vals assumption - for i := 1; i < len(vals); i++ { - if vals[i] < vals[i-1] { - panic("ParallelBSIScanHelper: input vals must be sorted in ascending order") - } - } - - out := NewBitmap() - nCols := len(cols) - if nCols == 0 { - return out - } - - if bitCount > 128 { - panic("ParallelBSIScanHelper: bitCount exceeds 128") - } - var curIndexBuf [128]int - curIndex := curIndexBuf[:bitCount] - - var iCol int - for iCol < nCols { - col := cols[iCol] - hb := uint16(col >> 16) - - var activeBuf [128]activeContState - active := activeBuf[:0] - - for p := 0; p < bitCount; p++ { - ra := bA[p].highlowcontainer - idx := ra.binarySearch(int64(curIndex[p]), int64(len(ra.keys)), hb) - if idx >= 0 { - curIndex[p] = idx - c := ra.containers[idx] - - state := activeContState{ - shift: uint(p), - } - switch tc := c.(type) { - case *arrayContainer: - state.cType = 0 - state.arrayContent = tc.content - case *bitmapContainer: - state.cType = 1 - state.bitmapContent = tc.bitmap - case *runContainer16: - state.cType = 2 - state.runIv = tc.iv - } - active = append(active, state) - } else { - curIndex[p] = -idx - 1 - } - } - - // Process all columns in the batch that share this hb - for iCol < nCols { - currCol := cols[iCol] - currHb := uint16(currCol >> 16) - if currHb != hb { - break - } - - val := uint64(0) - overflow := false - lb := uint16(currCol & 0xffff) - for p := 0; p < len(active); p++ { - ac := &active[p] - found := false - switch ac.cType { - case 0: // arrayContainer - for ac.arrayIdx < len(ac.arrayContent) && ac.arrayContent[ac.arrayIdx] < lb { - ac.arrayIdx++ - } - if ac.arrayIdx < len(ac.arrayContent) && ac.arrayContent[ac.arrayIdx] == lb { - found = true - } - case 1: // bitmapContainer - if (ac.bitmapContent[lb>>6] & (uint64(1) << (lb & 63))) != 0 { - found = true - } - case 2: // runContainer16 - for ac.runIdx < len(ac.runIv) && uint32(ac.runIv[ac.runIdx].start)+uint32(ac.runIv[ac.runIdx].length) < uint32(lb) { - ac.runIdx++ - } - if ac.runIdx < len(ac.runIv) && lb >= ac.runIv[ac.runIdx].start { - found = true - } - } - - if found { - if ac.shift >= 64 { - overflow = true - break - } - val |= uint64(1) << ac.shift - } - } - - if !overflow { - // Binary search inline on vals - l, r := 0, len(vals)-1 - foundVal := false - for l <= r { - m := (l + r) >> 1 - v := vals[m] - if v == val { - foundVal = true - break - } - if v < val { - l = m + 1 - } else { - r = m - 1 - } - } - if foundVal { - out.Add(currCol) - } - } - iCol++ - } - } - return out -} diff --git a/roaring.go b/roaring.go index faa330b3..50ae58e9 100644 --- a/roaring.go +++ b/roaring.go @@ -1243,11 +1243,6 @@ func (rb *Bitmap) IsEmpty() bool { return rb.highlowcontainer.size() == 0 } -// GetContainerCount returns the number of containers in the bitmap. -func (rb *Bitmap) GetContainerCount() int { - return rb.highlowcontainer.size() -} - // GetCardinality returns the number of integers contained in the bitmap func (rb *Bitmap) GetCardinality() uint64 { size := uint64(0)