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 816ad55a perf(arrow/array): compare fixed-width values in bulk (#1171)
816ad55a is described below
commit 816ad55aaba8788490704729bd487f8811114a20
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 28 21:54:44 2026 +0200
perf(arrow/array): compare fixed-width values in bulk (#1171)
### Rationale for this change
Fixed-width array equality currently checks validity and values one
element at a time. The values are already contiguous, so larger arrays
can be compared much more efficiently in bulk.
### What changes are included in this PR?
- Compare validity bitmaps in bulk.
- Compare null-free integer and temporal arrays with `bytes.Equal`.
- Walk contiguous valid runs for nullable arrays, ignoring bytes under
null slots.
- Keep floating-point arrays on the scalar path to preserve NaN and
signed-zero behavior.
- Keep arrays shorter than 8 values on the scalar path.
- Add benchmarks for int32/int64 arrays, several null patterns, and
mismatch positions.
- Add tests for null-slot and floating-point semantics.
On an M1 Pro with GOMAXPROCS=1, 65,536-value equality benchmarks
improved as follows:
| Case | Before | After |
| --- | ---: | ---: |
| int32, all valid | 505.9 µs | 6.5 µs |
| int32, 1% null | 516.2 µs | 14.2 µs |
| int32, alternating null | 432.2 µs | 247.9 µs |
| int64, all valid | 520.0 µs | 13.2 µs |
| int64, 1% null | 523.0 µs | 20.3 µs |
| int64, alternating null | 439.0 µs | 245.9 µs |
Allocations remain at zero.
### Are these changes tested?
- `go test ./arrow/...`
- `go test -race ./arrow/array`
- `go vet -composites=false ./arrow/array`
The full Arrow test run used the checked-out Parquet test data.
### Are there any user-facing changes?
No.
---
arrow/array/compare.go | 33 +++++--
arrow/array/compare_benchmark_test.go | 155 +++++++++++++++++++++++++++++++
arrow/array/compare_test.go | 170 ++++++++++++++++++++++++++++++++++
arrow/array/numeric_generic.go | 46 ++++++++-
arrow/array/timestamp.go | 10 +-
5 files changed, 394 insertions(+), 20 deletions(-)
diff --git a/arrow/array/compare.go b/arrow/array/compare.go
index 7624b996..37ef0594 100644
--- a/arrow/array/compare.go
+++ b/arrow/array/compare.go
@@ -21,6 +21,7 @@ import (
"math"
"github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
"github.com/apache/arrow-go/v18/arrow/float16"
"github.com/apache/arrow-go/v18/internal/bitutils"
)
@@ -264,13 +265,13 @@ func Equal(left, right arrow.Array) bool {
return arrayEqualFixedWidth(l, r)
case *Float16:
r := right.(*Float16)
- return arrayEqualFixedWidth(l, r)
+ return arrayEqualFixedWidthScalar(l, r)
case *Float32:
r := right.(*Float32)
- return arrayEqualFixedWidth(l, r)
+ return arrayEqualFixedWidthScalar(l, r)
case *Float64:
r := right.(*Float64)
- return arrayEqualFixedWidth(l, r)
+ return arrayEqualFixedWidthScalar(l, r)
case *Decimal32:
r := right.(*Decimal32)
return arrayEqualDecimal(l, r)
@@ -663,17 +664,29 @@ func baseArrayEqual(left, right arrow.Array) bool {
}
func validityBitmapEqual(left, right arrow.Array) bool {
- // TODO(alexandreyc): make it faster by comparing byte slices of the
validity bitmap?
- n := left.Len()
- if n != right.Len() {
+ if left.Len() != right.Len() {
return false
}
- for i := 0; i < n; i++ {
- if left.IsNull(i) != right.IsNull(i) {
- return false
+
+ leftBitmap := left.NullBitmapBytes()
+ rightBitmap := right.NullBitmapBytes()
+ if left.NullN() == 0 && len(leftBitmap) == 0 && len(rightBitmap) == 0 {
+ return true
+ }
+
+ if len(leftBitmap) == 0 || len(rightBitmap) == 0 {
+ for i := range left.Len() {
+ if left.IsNull(i) != right.IsNull(i) {
+ return false
+ }
}
+ return true
}
- return true
+
+ return bitutil.BitmapEquals(
+ leftBitmap, rightBitmap,
+ int64(left.Data().Offset()), int64(right.Data().Offset()),
int64(left.Len()),
+ )
}
func arrayApproxEqualString(left, right *String) bool {
diff --git a/arrow/array/compare_benchmark_test.go
b/arrow/array/compare_benchmark_test.go
new file mode 100644
index 00000000..f80fe263
--- /dev/null
+++ b/arrow/array/compare_benchmark_test.go
@@ -0,0 +1,155 @@
+// 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 array_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+)
+
+type fixedWidthArrayFactory func([]int64, []bool) arrow.Array
+
+func BenchmarkEqualFixedWidth(b *testing.B) {
+ types := []struct {
+ name string
+ factory fixedWidthArrayFactory
+ }{
+ {"int32", makeInt32Array},
+ {"int64", makeInt64Array},
+ }
+ nullPatterns := []struct {
+ name string
+ valid func(int) []bool
+ }{
+ {"all-valid", func(int) []bool { return nil }},
+ {"one-percent-null", makeValidityEvery(100)},
+ {"alternating-null", makeValidityEvery(2)},
+ {"clustered-null", makeClusteredValidity},
+ }
+
+ for _, typ := range types {
+ b.Run(typ.name, func(b *testing.B) {
+ for _, length := range []int{1024, 65536} {
+ b.Run(fmt.Sprintf("len-%d", length), func(b
*testing.B) {
+ for _, pattern := range nullPatterns {
+ b.Run(pattern.name, func(b
*testing.B) {
+ values :=
makeBenchmarkValues(length)
+ valid :=
pattern.valid(length)
+ rightValues :=
append([]int64(nil), values...)
+ for idx, isValid :=
range valid {
+ if !isValid {
+
rightValues[idx]++
+ }
+ }
+
+ left :=
typ.factory(values, valid)
+ defer left.Release()
+ right :=
typ.factory(rightValues, valid)
+ defer right.Release()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ if
!array.Equal(left, right) {
+
b.Fatal("expected arrays to be equal")
+ }
+ }
+ })
+ }
+ })
+ }
+
+ for _, mismatch := range []struct {
+ name string
+ pos int
+ }{
+ {"mismatch-first", 0},
+ {"mismatch-middle", 65536 / 2},
+ {"mismatch-last", 65536 - 1},
+ } {
+ b.Run(mismatch.name, func(b *testing.B) {
+ values := makeBenchmarkValues(65536)
+ rightValues := append([]int64(nil),
values...)
+ rightValues[mismatch.pos]++
+
+ left := typ.factory(values, nil)
+ defer left.Release()
+ right := typ.factory(rightValues, nil)
+ defer right.Release()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ if array.Equal(left, right) {
+ b.Fatal("expected
arrays to differ")
+ }
+ }
+ })
+ }
+ })
+ }
+}
+
+func makeBenchmarkValues(length int) []int64 {
+ values := make([]int64, length)
+ for idx := range values {
+ values[idx] = int64(idx*17 + idx%11)
+ }
+ return values
+}
+
+func makeValidityEvery(n int) func(int) []bool {
+ return func(length int) []bool {
+ valid := make([]bool, length)
+ for idx := range valid {
+ valid[idx] = idx%n != 0
+ }
+ return valid
+ }
+}
+
+func makeClusteredValidity(length int) []bool {
+ valid := make([]bool, length)
+ for idx := length / 2; idx < length; idx++ {
+ valid[idx] = true
+ }
+ return valid
+}
+
+func makeInt32Array(values []int64, valid []bool) arrow.Array {
+ builder := array.NewInt32Builder(memory.DefaultAllocator)
+ defer builder.Release()
+
+ converted := make([]int32, len(values))
+ for idx, value := range values {
+ converted[idx] = int32(value)
+ }
+ builder.AppendValues(converted, valid)
+ return builder.NewInt32Array()
+}
+
+func makeInt64Array(values []int64, valid []bool) arrow.Array {
+ builder := array.NewInt64Builder(memory.DefaultAllocator)
+ defer builder.Release()
+
+ builder.AppendValues(values, valid)
+ return builder.NewInt64Array()
+}
diff --git a/arrow/array/compare_test.go b/arrow/array/compare_test.go
index d89ce42b..4c5d0373 100644
--- a/arrow/array/compare_test.go
+++ b/arrow/array/compare_test.go
@@ -781,6 +781,176 @@ func TestArrayEqualBaseArray(t *testing.T) {
}
}
+func TestArrayEqualFloatingPointSemantics(t *testing.T) {
+ negativeZero := math.Copysign(0, -1)
+ tests := []struct {
+ name string
+ left interface{}
+ right interface{}
+ want bool
+ }{
+ {"float16 signed zero", []float16.Num{float16.New(0)},
[]float16.Num{float16.New(float32(negativeZero))}, false},
+ {"float16 NaN",
[]float16.Num{float16.New(float32(math.NaN()))},
[]float16.Num{float16.New(float32(math.NaN()))}, true},
+ {"float32 signed zero", []float32{0},
[]float32{float32(negativeZero)}, true},
+ {"float32 NaN", []float32{float32(math.NaN())},
[]float32{float32(math.NaN())}, false},
+ {"float64 signed zero", []float64{0}, []float64{negativeZero},
true},
+ {"float64 NaN", []float64{math.NaN()}, []float64{math.NaN()},
false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ left := arrayOf(nil, test.left, nil)
+ defer left.Release()
+ right := arrayOf(nil, test.right, nil)
+ defer right.Release()
+
+ assert.Equal(t, test.want, array.Equal(left, right))
+ })
+ }
+}
+
+const fixedWidthEqualityTestLength = 72
+
+func fixedWidthEqualityValidity() []bool {
+ valid := make([]bool, fixedWidthEqualityTestLength)
+ for i := range valid {
+ valid[i] = true
+ }
+ for _, i := range []int{0, 11, 30, 60, 68} {
+ valid[i] = false
+ }
+ return valid
+}
+
+func makeSlicedInt64EqualityArray(values []int64, valid []bool, offset int)
*array.Int64 {
+ baseValues := make([]int64, offset+len(values)+1)
+ baseValid := make([]bool, len(baseValues))
+ for i := range baseValid {
+ baseValid[i] = true
+ }
+ copy(baseValues[offset:], values)
+ copy(baseValid[offset:], valid)
+
+ builder := array.NewInt64Builder(memory.DefaultAllocator)
+ builder.AppendValues(baseValues, baseValid)
+ base := builder.NewInt64Array()
+ builder.Release()
+
+ sliced := array.NewSlice(base, int64(offset),
int64(offset+len(values))).(*array.Int64)
+ base.Release()
+ return sliced
+}
+
+func makeSlicedTimestampEqualityArray(values []arrow.Timestamp, valid []bool,
offset int) *array.Timestamp {
+ baseValues := make([]arrow.Timestamp, offset+len(values)+1)
+ baseValid := make([]bool, len(baseValues))
+ for i := range baseValid {
+ baseValid[i] = true
+ }
+ copy(baseValues[offset:], values)
+ copy(baseValid[offset:], valid)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
arrow.FixedWidthTypes.Timestamp_ms.(*arrow.TimestampType))
+ builder.AppendValues(baseValues, baseValid)
+ base := builder.NewTimestampArray()
+ builder.Release()
+
+ sliced := array.NewSlice(base, int64(offset),
int64(offset+len(values))).(*array.Timestamp)
+ base.Release()
+ return sliced
+}
+
+func makeInt64EqualityArrayWithValidity(validity []byte, nulls int)
*array.Int64 {
+ validityBuffer := memory.NewBufferBytes(validity)
+ valuesBuffer := memory.NewBufferBytes(make([]byte, 8*8))
+ data := array.NewData(
+ arrow.PrimitiveTypes.Int64,
+ 8,
+ []*memory.Buffer{validityBuffer, valuesBuffer},
+ nil,
+ nulls,
+ 0,
+ )
+ validityBuffer.Release()
+ valuesBuffer.Release()
+
+ result := array.NewInt64Data(data)
+ data.Release()
+ return result
+}
+
+func TestArrayEqualFixedWidthIgnoresNullValues(t *testing.T) {
+ const sliceLength = fixedWidthEqualityTestLength
+ valid := fixedWidthEqualityValidity()
+
+ leftValues := make([]int64, sliceLength)
+ rightValues := make([]int64, sliceLength)
+ for i := range leftValues {
+ leftValues[i] = int64(i + 1)
+ rightValues[i] = leftValues[i]
+ if !valid[i] {
+ rightValues[i] = -rightValues[i]
+ }
+ }
+
+ left := makeSlicedInt64EqualityArray(leftValues, valid, 3)
+ defer left.Release()
+ right := makeSlicedInt64EqualityArray(rightValues, valid, 5)
+ defer right.Release()
+
+ assert.Equal(t, 3, left.Data().Offset())
+ assert.Equal(t, 5, right.Data().Offset())
+ assert.True(t, array.Equal(left, right))
+
+ rightValues[5]++
+ different := makeSlicedInt64EqualityArray(rightValues, valid, 5)
+ defer different.Release()
+ assert.False(t, array.Equal(left, different))
+}
+
+func TestArrayEqualFixedWidthTimestamp(t *testing.T) {
+ const length = fixedWidthEqualityTestLength
+ valid := fixedWidthEqualityValidity()
+
+ leftValues := make([]arrow.Timestamp, length)
+ rightValues := make([]arrow.Timestamp, length)
+ for i := range leftValues {
+ leftValues[i] = arrow.Timestamp(i + 1)
+ rightValues[i] = leftValues[i]
+ if !valid[i] {
+ rightValues[i] = -rightValues[i]
+ }
+ }
+
+ left := makeSlicedTimestampEqualityArray(leftValues, valid, 3)
+ defer left.Release()
+ right := makeSlicedTimestampEqualityArray(rightValues, valid, 5)
+ defer right.Release()
+
+ assert.True(t, array.Equal(left, right))
+}
+
+func TestArrayEqualFixedWidthEmptyNullBitmap(t *testing.T) {
+ left := makeInt64EqualityArrayWithValidity(nil, 1)
+ defer left.Release()
+ right := makeInt64EqualityArrayWithValidity(nil, 1)
+ defer right.Release()
+
+ assert.NotPanics(t, func() {
+ assert.True(t, array.Equal(left, right))
+ assert.True(t, array.Equal(right, left))
+ })
+}
+
+func TestArrayEqualValidityBitmapWithZeroNullCount(t *testing.T) {
+ left := makeInt64EqualityArrayWithValidity([]byte{0xff}, 0)
+ defer left.Release()
+ right := makeInt64EqualityArrayWithValidity([]byte{0xfe}, 0)
+ defer right.Release()
+
+ assert.False(t, array.Equal(left, right))
+}
+
func TestArrayEqualNull(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
defer mem.AssertSize(t, 0)
diff --git a/arrow/array/numeric_generic.go b/arrow/array/numeric_generic.go
index 2ff88427..7d7de68d 100644
--- a/arrow/array/numeric_generic.go
+++ b/arrow/array/numeric_generic.go
@@ -17,6 +17,7 @@
package array
import (
+ "bytes"
"fmt"
"strconv"
"strings"
@@ -24,6 +25,7 @@ import (
"unsafe"
"github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/internal/bitutils"
"github.com/apache/arrow-go/v18/internal/json"
)
@@ -497,7 +499,49 @@ func NewDate64Data(data arrow.ArrayData) *Date64 {
func (a *Date64) Date64Values() []arrow.Date64 { return a.Values() }
-func arrayEqualFixedWidth[T arrow.FixedWidthType](left, right
arrow.TypedArray[T]) bool {
+type fixedWidthArray[T arrow.FixedWidthType] interface {
+ arrow.TypedArray[T]
+ Values() []T
+}
+
+func arrayEqualFixedWidth[T arrow.FixedWidthType](left, right
fixedWidthArray[T]) bool {
+ // Avoid the fixed cost of bytes.Equal for very small arrays.
+ if left.Len() < 8 {
+ return arrayEqualFixedWidthScalar(left, right)
+ }
+
+ leftValues := left.Values()
+ rightValues := right.Values()
+ if left.NullN() == 0 {
+ return bytes.Equal(arrow.GetBytes(leftValues),
arrow.GetBytes(rightValues))
+ }
+
+ leftBitmap := left.NullBitmapBytes()
+ if len(leftBitmap) == 0 {
+ return arrayEqualFixedWidthScalar(left, right)
+ }
+
+ runs := bitutils.NewSetBitRunReader(
+ leftBitmap, int64(left.Data().Offset()), int64(left.Len()),
+ )
+ for {
+ run := runs.NextRun()
+ if run.Length == 0 {
+ return true
+ }
+
+ start := int(run.Pos)
+ end := start + int(run.Length)
+ if !bytes.Equal(
+ arrow.GetBytes(leftValues[start:end]),
+ arrow.GetBytes(rightValues[start:end]),
+ ) {
+ return false
+ }
+ }
+}
+
+func arrayEqualFixedWidthScalar[T arrow.FixedWidthType](left, right
arrow.TypedArray[T]) bool {
for i := range left.Len() {
if left.IsNull(i) {
continue
diff --git a/arrow/array/timestamp.go b/arrow/array/timestamp.go
index c3d9f190..1b17c3e2 100644
--- a/arrow/array/timestamp.go
+++ b/arrow/array/timestamp.go
@@ -133,15 +133,7 @@ func (a *Timestamp) MarshalJSON() ([]byte, error) {
}
func arrayEqualTimestamp(left, right *Timestamp) bool {
- for i := 0; i < left.Len(); i++ {
- if left.IsNull(i) {
- continue
- }
- if left.Value(i) != right.Value(i) {
- return false
- }
- }
- return true
+ return arrayEqualFixedWidth(left, right)
}
type TimestampBuilder struct {