This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git
The following commit(s) were added to refs/heads/main by this push:
new 0bf2789b perf(bitutil): accelerate misaligned bitmap equality (#1414)
0bf2789b is described below
commit 0bf2789bed952ea322f17fe35a231beb2403b49b
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 26 18:54:32 2026 +0100
perf(bitutil): accelerate misaligned bitmap equality (#1414)
## What
Speed up BitmapEquals for sliced, misaligned ranges.
## Why
Unaligned slices use word readers and allocate temporary buffers.
## Implementation
- Use masked edge bytes and bytes.Equal for matching byte offsets.
- Read packed words directly for other offsets.
- Keep bounds checks and add parity cases for offsets and tails.
## Benchmark
**6.53x geometric mean** across 32 isolated cases. Unaligned cases drop
from 2 allocs/op to 0.
---------
Co-authored-by: Matt Topol <[email protected]>
---
arrow/bitutil/bitmap_equals_offset_test.go | 104 +++++++++++++++++++++
.../bitmap_equals_offsets_benchmark_test.go | 70 ++++++++++++++
arrow/bitutil/bitmaps.go | 92 +++++++++++++++---
3 files changed, 251 insertions(+), 15 deletions(-)
diff --git a/arrow/bitutil/bitmap_equals_offset_test.go
b/arrow/bitutil/bitmap_equals_offset_test.go
new file mode 100644
index 00000000..935f16f2
--- /dev/null
+++ b/arrow/bitutil/bitmap_equals_offset_test.go
@@ -0,0 +1,104 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bitutil_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+)
+
+func TestBitmapEqualsOffsetParity(t *testing.T) {
+ offsets := []int{0, 1, 2, 3, 7, 8, 11, 15, 16, 19, 31, 64, 71}
+ lengths := []int{0, 1, 2, 3, 7, 8, 9, 15, 16, 17, 31, 32, 63, 64, 65,
66, 127, 128, 129, 255, 256, 257, 511, 512, 513}
+ for _, length := range lengths {
+ for _, lo := range offsets {
+ for _, ro := range offsets {
+ left := make([]byte,
bitutil.BytesForBits(int64(lo+length)))
+ right := make([]byte,
bitutil.BytesForBits(int64(ro+length)))
+ for i := range left {
+ left[i] = 0xa5
+ }
+ for i := range right {
+ right[i] = 0x5a
+ }
+ for i := 0; i < length; i++ {
+ val := (i*31+i/7)%11 < 5
+ bitutil.SetBitTo(left, lo+i, val)
+ bitutil.SetBitTo(right, ro+i, val)
+ }
+ if !bitutil.BitmapEquals(left, right,
int64(lo), int64(ro), int64(length)) {
+ t.Fatalf("equal ranges: len=%d left=%d
right=%d", length, lo, ro)
+ }
+ for _, pos := range []int{0, length / 2, length
- 1} {
+ if pos < 0 || pos >= length {
+ continue
+ }
+ index := ro + pos
+ right[index/8] ^= byte(1) <<
uint(index%8)
+ if bitutil.BitmapEquals(left, right,
int64(lo), int64(ro), int64(length)) {
+ t.Fatalf("missed difference:
len=%d left=%d right=%d pos=%d", length, lo, ro, pos)
+ }
+ right[index/8] ^= byte(1) <<
uint(index%8)
+ }
+ }
+ }
+ }
+}
+
+func TestBitmapEqualsLargeOffsetParity(t *testing.T) {
+ const length = 1 << 20
+ for _, pair := range []struct{ left, right int }{{0, 0}, {3, 11}, {7,
15}, {3, 6}, {0, 7}, {15, 31}} {
+ t.Run(fmt.Sprintf("%d_%d", pair.left, pair.right), func(t
*testing.T) {
+ left := make([]byte,
bitutil.BytesForBits(length+int64(pair.left)))
+ right := make([]byte,
bitutil.BytesForBits(length+int64(pair.right)))
+ for i := 0; i < length; i++ {
+ v := (i*7+i/29)%11 < 5
+ bitutil.SetBitTo(left, pair.left+i, v)
+ bitutil.SetBitTo(right, pair.right+i, v)
+ }
+ if !bitutil.BitmapEquals(left, right, int64(pair.left),
int64(pair.right), length) {
+ t.Fatal("large equal bitmap ranges differ")
+ }
+ index := pair.right + length - 1
+ right[index/8] ^= 1 << uint(index%8)
+ if bitutil.BitmapEquals(left, right, int64(pair.left),
int64(pair.right), length) {
+ t.Fatal("missed difference at end of large
range")
+ }
+ })
+ }
+}
+
+func TestBitmapEqualsUnalignedBounds(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ lo, ro, length int64
+ }{
+ {name: "same offset", lo: 3, ro: 11, length: 128},
+ {name: "different offset", lo: 3, ro: 6, length: 128},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("expected out-of-bounds range
to panic")
+ }
+ }()
+ bitutil.BitmapEquals([]byte{0xff}, []byte{0}, tc.lo,
tc.ro, tc.length)
+ })
+ }
+}
diff --git a/arrow/bitutil/bitmap_equals_offsets_benchmark_test.go
b/arrow/bitutil/bitmap_equals_offsets_benchmark_test.go
new file mode 100644
index 00000000..d731780b
--- /dev/null
+++ b/arrow/bitutil/bitmap_equals_offsets_benchmark_test.go
@@ -0,0 +1,70 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bitutil_test
+
+import (
+ "fmt"
+ "math/rand"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+)
+
+var bitmapEqualsOffsetBenchmarkResult bool
+
+func BenchmarkBitmapEqualsOffsets(b *testing.B) {
+ for _, nbits := range []int{64, 1024, 65536, 1048576} {
+ for _, pair := range []struct {
+ name string
+ lo, ro int
+ }{
+ {name: "aligned", lo: 0, ro: 0},
+ {name: "same3", lo: 3, ro: 11},
+ {name: "same7", lo: 7, ro: 15},
+ {name: "different", lo: 3, ro: 6},
+ } {
+ for _, lastMismatch := range []bool{false, true} {
+ name := "equal"
+ if lastMismatch {
+ name = "lastMismatch"
+ }
+ left := make([]byte,
bitutil.BytesForBits(int64(nbits+pair.lo)))
+ right := make([]byte,
bitutil.BytesForBits(int64(nbits+pair.ro)))
+ rng := rand.New(rand.NewSource(55))
+ _, _ = rng.Read(left)
+ for i := 0; i < nbits; i++ {
+ if bitutil.BitIsSet(left, pair.lo+i) {
+ bitutil.SetBit(right, pair.ro+i)
+ }
+ }
+ if lastMismatch {
+ i := pair.ro + nbits - 1
+ right[i/8] ^= 1 << uint(i%8)
+ }
+ b.Run(fmt.Sprintf("bits=%d/%s/%s", nbits,
pair.name, name), func(b *testing.B) {
+ b.SetBytes(int64((nbits + 7) / 8 * 2))
+ b.ReportAllocs()
+ for b.Loop() {
+
bitmapEqualsOffsetBenchmarkResult = bitutil.BitmapEquals(
+ left, right,
int64(pair.lo), int64(pair.ro), int64(nbits),
+ )
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/arrow/bitutil/bitmaps.go b/arrow/bitutil/bitmaps.go
index ac61397a..e0e332ad 100644
--- a/arrow/bitutil/bitmaps.go
+++ b/arrow/bitutil/bitmaps.go
@@ -18,6 +18,7 @@ package bitutil
import (
"bytes"
+ "encoding/binary"
"errors"
"math/bits"
"unsafe"
@@ -658,17 +659,17 @@ func BitmapXnorAlloc(mem memory.Allocator, left, right
[]byte, lOffset, rOffset
return BitmapOpAlloc(mem, bitXnorOp, left, right, lOffset, rOffset,
length, outOffset)
}
+// BitmapEquals reports whether the selected ranges have the same bits.
+// Bytes outside the compared ranges are ignored.
func BitmapEquals(left, right []byte, lOffset, rOffset int64, length int64)
bool {
if lOffset%8 == 0 && rOffset%8 == 0 {
- // byte aligned, fast path, can use bytes.Equal (memcmp)
+ // Byte aligned: use bytes.Equal for the bulk and compare
trailing bits.
byteLen := length / 8
lStart := lOffset / 8
rStart := rOffset / 8
if !bytes.Equal(left[lStart:lStart+byteLen],
right[rStart:rStart+byteLen]) {
return false
}
-
- // check trailing bits
for i := (length / 8) * 8; i < length; i++ {
if BitIsSet(left, int(lOffset+i)) != BitIsSet(right,
int(rOffset+i)) {
return false
@@ -677,25 +678,86 @@ func BitmapEquals(left, right []byte, lOffset, rOffset
int64, length int64) bool
return true
}
- lrdr := NewBitmapWordReader(left, int(lOffset), int(length))
- rrdr := NewBitmapWordReader(right, int(rOffset), int(length))
+ // Slice up front, as the word-reader path did, so out-of-bounds
+ // ranges still panic even when the first compared bits differ.
+ lshift, rshift := uint(lOffset%8), uint(rOffset%8)
+ left = left[lOffset/8 : lOffset/8+(int64(lshift)+length+7)/8]
+ right = right[rOffset/8 : rOffset/8+(int64(rshift)+length+7)/8]
- nwords := lrdr.Words()
- for nwords > 0 {
- nwords--
- if lrdr.NextWord() != rrdr.NextWord() {
+ if lshift == rshift {
+ // Equal intra-byte offsets: compare the leading partial byte,
+ // all complete middle bytes using memcmp, then the trailing
bits.
+ if length == 0 {
+ return true
+ }
+ leading := length
+ if leading > int64(8-lshift) {
+ leading = int64(8 - lshift)
+ }
+ mask := byte((1<<uint(leading))-1) << lshift
+ if (left[0]^right[0])&mask != 0 {
+ return false
+ }
+ if leading == length {
+ return true
+ }
+ left, right = left[1:], right[1:]
+ length -= leading
+ nbytes := length / 8
+ if !bytes.Equal(left[:nbytes], right[:nbytes]) {
return false
}
+ if tail := length % 8; tail != 0 {
+ mask := byte((1 << uint(tail)) - 1)
+ return (left[nbytes]^right[nbytes])&mask == 0
+ }
+ return true
}
- nbytes := lrdr.TrailingBytes()
- for nbytes > 0 {
- nbytes--
- lbt, _ := lrdr.NextTrailingByte()
- rbt, _ := rrdr.NextTrailingByte()
- if lbt != rbt {
+ // Different offsets: unpack directly from the input slices without
+ // constructing two BitmapWordReaders. Compare full 64-bit words first.
+ for length >= 64 {
+ lword := binary.LittleEndian.Uint64(left)
+ if lshift != 0 {
+ lword = (lword >> lshift) | uint64(left[8])<<(64-lshift)
+ }
+ rword := binary.LittleEndian.Uint64(right)
+ if rshift != 0 {
+ rword = (rword >> rshift) |
uint64(right[8])<<(64-rshift)
+ }
+ if lword != rword {
return false
}
+ left, right = left[8:], right[8:]
+ length -= 64
+ }
+
+ for length >= 8 {
+ lbyte := left[0] >> lshift
+ if lshift != 0 {
+ lbyte |= left[1] << (8 - lshift)
+ }
+ rbyte := right[0] >> rshift
+ if rshift != 0 {
+ rbyte |= right[1] << (8 - rshift)
+ }
+ if lbyte != rbyte {
+ return false
+ }
+ left, right = left[1:], right[1:]
+ length -= 8
+ }
+ if length != 0 {
+ lbyte := left[0] >> lshift
+ if length+int64(lshift) > 8 {
+ lbyte |= left[1] << (8 - lshift)
+ }
+ rbyte := right[0] >> rshift
+ if length+int64(rshift) > 8 {
+ rbyte |= right[1] << (8 - rshift)
+ }
+ mask := byte((1 << uint(length)) - 1)
+ return (lbyte^rbyte)&mask == 0
}
return true
}