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 f82b95de perf(arrow/array): compare approximate list values by valid
runs (#1330)
f82b95de is described below
commit f82b95de8195cc3e74040d516eb34b234fbbaae7
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 6 16:24:31 2026 +0200
perf(arrow/array): compare approximate list values by valid runs (#1330)
## What
Speed up `ApproxEqual` for list arrays.
## Why
The old path created and released one child slice for every valid list
item. This is expensive for large arrays.
## Implementation
- Compare contiguous valid runs for `List`, `LargeList`, and
`FixedSizeList`.
- Reuse direct child comparison when a run covers the full child array.
- Keep concrete scalar fallbacks for fragmented validity bitmaps.
- Add coverage for full-range approximate slices and generic arrays.
- Add benchmark coverage for common null patterns.
## Benchmark
Apple M1 Pro, Go 1.26.3, `GOMAXPROCS=1`, 65,536 rows, Int32 child
values, list size 16. Median of 3 runs. Lower `ns/op` is better.
| Case | upstream/main | This PR | Speed | PR allocs/op |
| --- | ---: | ---: | ---: | ---: |
| list / all-valid | 17.8 ms | 0.197 ms | 90.5x | 2 |
| large-list / all-valid | 18.9 ms | 0.216 ms | 87.8x | 2 |
| fixed-size-list / all-valid | 18.0 ms | 0.145 ms | 124x | 2 |
| list / 10% null | 16.2 ms | 2.15 ms | 7.5x | 26,218 |
| list / clustered 10% null | 16.0 ms | 0.195 ms | 81.9x | 10 |
| list / alternating null | 8.72 ms | 8.71 ms | 1.00x, near parity |
131,074 |
---
arrow/array/compare.go | 162 ++++++++++++++++++++---------
arrow/array/compare_binary_builder_test.go | 60 +++++++++++
arrow/array/compare_test.go | 95 +++++++++++++++++
arrow/array/list_equal_benchmark_test.go | 33 ++++++
4 files changed, 303 insertions(+), 47 deletions(-)
diff --git a/arrow/array/compare.go b/arrow/array/compare.go
index 76e420e3..419add8c 100644
--- a/arrow/array/compare.go
+++ b/arrow/array/compare.go
@@ -379,8 +379,13 @@ func canEqualDirectly(left, right arrow.Array) bool {
}
switch left.(type) {
- case *Null, *Boolean, *FixedSizeBinary, *Binary, *String,
- *LargeBinary, *LargeString, *BinaryView, *StringView,
+ case *Binary:
+ // Binary builders can retain string datatypes, which need
normalization.
+ return left.DataType().ID() == arrow.BINARY &&
right.DataType().ID() == arrow.BINARY
+ case *LargeBinary:
+ return left.DataType().ID() == arrow.LARGE_BINARY &&
right.DataType().ID() == arrow.LARGE_BINARY
+ case *Null, *Boolean, *FixedSizeBinary, *String,
+ *LargeString, *BinaryView, *StringView,
*Int8, *Int16, *Int32, *Int64, *Uint8, *Uint16, *Uint32,
*Uint64,
*Float16, *Float32, *Float64,
*Decimal32, *Decimal64, *Decimal128, *Decimal256,
@@ -427,6 +432,79 @@ func arrayEqualListOffsets[T listOffset](leftValues,
rightValues arrow.Array,
}) == nil
}
+func arrayApproxEqualListOffsets[T listOffset](leftValues, rightValues
arrow.Array,
+ leftOffsets, rightOffsets []T, leftOffset, rightOffset, length int,
validBits []byte, opt equalOption) bool {
+ if len(validBits) == 0 {
+ validBits = nil
+ }
+ return bitutils.VisitSetBitRuns(validBits, int64(leftOffset),
int64(length),
+ func(pos, runLength int64) error {
+ leftIndex := leftOffset + int(pos)
+ rightIndex := rightOffset + int(pos)
+ for i := range int(runLength) {
+ leftLength := int64(leftOffsets[leftIndex+i+1])
- int64(leftOffsets[leftIndex+i])
+ rightLength :=
int64(rightOffsets[rightIndex+i+1]) - int64(rightOffsets[rightIndex+i])
+ if leftLength != rightLength {
+ return arrow.ErrInvalid
+ }
+ }
+
+ leftStart := int64(leftOffsets[leftIndex])
+ rightStart := int64(rightOffsets[rightIndex])
+ leftEnd := int64(leftOffsets[leftIndex+int(runLength)])
+ rightEnd :=
int64(rightOffsets[rightIndex+int(runLength)])
+ if !sliceApproxEqual(leftValues, leftStart, leftEnd,
rightValues, rightStart, rightEnd, opt) {
+ return arrow.ErrInvalid
+ }
+ return nil
+ }) == nil
+}
+
+type listArrayWithValue interface {
+ arrow.Array
+ newListValue(int) arrow.Array
+}
+
+func arrayApproxEqualListScalar(left, right listArrayWithValue, opt
equalOption) bool {
+ for i := 0; i < left.Len(); i++ {
+ if left.IsNull(i) {
+ continue
+ }
+ equal := func() bool {
+ l := left.newListValue(i)
+ defer l.Release()
+ r := right.newListValue(i)
+ defer r.Release()
+ return arrayApproxEqual(l, r, opt)
+ }()
+ if !equal {
+ return false
+ }
+ }
+ return true
+}
+
+func useScalarListEquality(validBits []byte, offset, length int64) bool {
+ if len(validBits) == 0 || length <= 64 {
+ return len(validBits) != 0
+ }
+
+ const (
+ sampleRuns = 8
+ minAverageRunLength = 4
+ )
+ runs := bitutils.NewSetBitRunReader(validBits, offset, length)
+ validValues := int64(0)
+ for range sampleRuns {
+ run := runs.NextRun()
+ if run.Length == 0 {
+ return false
+ }
+ validValues += run.Length
+ }
+ return validValues < sampleRuns*minAverageRunLength
+}
+
// SliceApproxEqual reports whether slices left[lbeg:lend] and
right[rbeg:rend] are approximately equal.
func SliceApproxEqual(left arrow.Array, lbeg, lend int64, right arrow.Array,
rbeg, rend int64, opts ...EqualOption) bool {
opt := newEqualOption(opts...)
@@ -434,6 +512,16 @@ func SliceApproxEqual(left arrow.Array, lbeg, lend int64,
right arrow.Array, rbe
}
func sliceApproxEqual(left arrow.Array, lbeg, lend int64, right arrow.Array,
rbeg, rend int64, opt equalOption) bool {
+ if lbeg == 0 && lend == int64(left.Len()) &&
+ rbeg == 0 && rend == int64(right.Len()) &&
+ canEqualDirectly(left, right) {
+ return arrayApproxEqual(left, right, opt)
+ }
+
+ return sliceApproxEqualDirect(left, lbeg, lend, right, rbeg, rend, opt)
+}
+
+func sliceApproxEqualDirect(left arrow.Array, lbeg, lend int64, right
arrow.Array, rbeg, rend int64, opt equalOption) bool {
l := NewSlice(left, lbeg, lend)
defer l.Release()
r := NewSlice(right, rbeg, rend)
@@ -795,41 +883,19 @@ func arrayApproxEqualFloat64(left, right *Float64, opt
equalOption) bool {
}
func arrayApproxEqualList(left, right *List, opt equalOption) bool {
- for i := 0; i < left.Len(); i++ {
- if left.IsNull(i) {
- continue
- }
- o := func() bool {
- l := left.newListValue(i)
- defer l.Release()
- r := right.newListValue(i)
- defer r.Release()
- return arrayApproxEqual(l, r, opt)
- }()
- if !o {
- return false
- }
+ if useScalarListEquality(left.NullBitmapBytes(), int64(left.Offset()),
int64(left.Len())) {
+ return arrayApproxEqualListScalar(left, right, opt)
}
- return true
+ return arrayApproxEqualListOffsets(left.values, right.values,
left.offsets, right.offsets,
+ left.data.offset, right.data.offset, left.Len(),
left.NullBitmapBytes(), opt)
}
func arrayApproxEqualLargeList(left, right *LargeList, opt equalOption) bool {
- for i := 0; i < left.Len(); i++ {
- if left.IsNull(i) {
- continue
- }
- o := func() bool {
- l := left.newListValue(i)
- defer l.Release()
- r := right.newListValue(i)
- defer r.Release()
- return arrayApproxEqual(l, r, opt)
- }()
- if !o {
- return false
- }
+ if useScalarListEquality(left.NullBitmapBytes(), int64(left.Offset()),
int64(left.Len())) {
+ return arrayApproxEqualListScalar(left, right, opt)
}
- return true
+ return arrayApproxEqualListOffsets(left.values, right.values,
left.offsets, right.offsets,
+ left.data.offset, right.data.offset, left.Len(),
left.NullBitmapBytes(), opt)
}
func arrayApproxEqualListView(left, right *ListView, opt equalOption) bool {
@@ -871,22 +937,24 @@ func arrayApproxEqualLargeListView(left, right
*LargeListView, opt equalOption)
}
func arrayApproxEqualFixedSizeList(left, right *FixedSizeList, opt
equalOption) bool {
- for i := 0; i < left.Len(); i++ {
- if left.IsNull(i) {
- continue
- }
- o := func() bool {
- l := left.newListValue(i)
- defer l.Release()
- r := right.newListValue(i)
- defer r.Release()
- return arrayApproxEqual(l, r, opt)
- }()
- if !o {
- return false
- }
+ if useScalarListEquality(left.NullBitmapBytes(), int64(left.Offset()),
int64(left.Len())) {
+ return arrayApproxEqualListScalar(left, right, opt)
}
- return true
+ listSize := int64(left.n)
+ validBits := left.NullBitmapBytes()
+ if len(validBits) == 0 {
+ validBits = nil
+ }
+ return bitutils.VisitSetBitRuns(validBits, int64(left.Offset()),
int64(left.Len()),
+ func(pos, length int64) error {
+ leftStart := (int64(left.Offset()) + pos) * listSize
+ rightStart := (int64(right.Offset()) + pos) * listSize
+ if !sliceApproxEqual(left.values, leftStart,
leftStart+length*listSize,
+ right.values, rightStart,
rightStart+length*listSize, opt) {
+ return arrow.ErrInvalid
+ }
+ return nil
+ }) == nil
}
func arrayApproxEqualStruct(left, right *Struct, opt equalOption) bool {
diff --git a/arrow/array/compare_binary_builder_test.go
b/arrow/array/compare_binary_builder_test.go
new file mode 100644
index 00000000..07977abe
--- /dev/null
+++ b/arrow/array/compare_binary_builder_test.go
@@ -0,0 +1,60 @@
+// 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 (
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSliceApproxEqualBinaryBuilderLogicalType(t *testing.T) {
+ for _, dtype := range []arrow.BinaryDataType{
+ arrow.BinaryTypes.Binary,
+ arrow.BinaryTypes.String,
+ arrow.BinaryTypes.LargeBinary,
+ arrow.BinaryTypes.LargeString,
+ } {
+ t.Run(dtype.Name(), func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ builder := array.NewBinaryBuilder(mem, dtype)
+ defer builder.Release()
+ builder.AppendValues([][]byte{[]byte("a\x00"),
[]byte("same"), []byte("x\x00")}, nil)
+ left := builder.NewArray()
+ defer left.Release()
+ builder.AppendValues([][]byte{[]byte("a"),
[]byte("same"), []byte("x")}, nil)
+ right := builder.NewArray()
+ defer right.Release()
+
+ want := dtype.ID() == arrow.STRING || dtype.ID() ==
arrow.LARGE_STRING
+ assert.Equal(t, want, array.SliceApproxEqual(left, 0,
3, right, 0, 3))
+ assert.Equal(t, want, array.SliceApproxEqual(right, 0,
3, left, 0, 3))
+ assert.Equal(t, want, array.SliceApproxEqual(left, 0,
1, right, 0, 1))
+ assert.True(t, array.SliceApproxEqual(left, 1, 2,
right, 1, 2))
+ assert.False(t, array.SliceEqual(left, 0, 3, right, 0,
3))
+
+ normalized := array.MakeFromData(right.Data())
+ defer normalized.Release()
+ assert.Equal(t, want, array.SliceApproxEqual(left, 0,
3, normalized, 0, 3))
+ assert.Equal(t, want,
array.SliceApproxEqual(normalized, 0, 3, left, 0, 3))
+ })
+ }
+}
diff --git a/arrow/array/compare_test.go b/arrow/array/compare_test.go
index f7af93c9..60e49fb2 100644
--- a/arrow/array/compare_test.go
+++ b/arrow/array/compare_test.go
@@ -149,6 +149,49 @@ func TestArraySliceEqualFullRangeMismatchedConcreteTypes(t
*testing.T) {
))
}
+func TestArraySliceApproxEqualFullRange(t *testing.T) {
+ leftBuilder := array.NewFloat64Builder(memory.DefaultAllocator)
+ leftBuilder.AppendValues([]float64{1, 2, 3}, nil)
+ left := leftBuilder.NewFloat64Array()
+ leftBuilder.Release()
+ defer left.Release()
+
+ rightBuilder := array.NewFloat64Builder(memory.DefaultAllocator)
+ rightBuilder.AppendValues([]float64{1.000001, 2, 3}, nil)
+ right := rightBuilder.NewFloat64Array()
+ rightBuilder.Release()
+ defer right.Release()
+
+ assert.True(t, array.SliceApproxEqual(
+ left, 0, int64(left.Len()),
+ right, 0, int64(right.Len()),
+ array.WithAbsTolerance(1e-5),
+ ))
+ assert.False(t, array.SliceApproxEqual(
+ left, 0, int64(left.Len()),
+ right, 0, int64(right.Len()),
+ array.WithAbsTolerance(1e-7),
+ ))
+}
+
+func TestArraySliceApproxEqualFullRangeGenericArray(t *testing.T) {
+ builder := array.NewInt64Builder(memory.DefaultAllocator)
+ builder.AppendValues([]int64{1, 2, 3}, nil)
+ arr := builder.NewInt64Array()
+ builder.Release()
+ defer arr.Release()
+
+ wrapped := arrayWrapper{Array: arr}
+ assert.True(t, array.SliceApproxEqual(
+ wrapped, 0, int64(wrapped.Len()),
+ wrapped, 0, int64(wrapped.Len()),
+ ))
+ assert.True(t, array.SliceApproxEqual(
+ arr, 0, int64(arr.Len()),
+ wrapped, 0, int64(wrapped.Len()),
+ ))
+}
+
func TestListEqualByValidRuns(t *testing.T) {
for _, dt := range []arrow.DataType{
arrow.ListOf(arrow.PrimitiveTypes.Int32),
@@ -197,6 +240,7 @@ func TestListEqualByValidRuns(t *testing.T) {
left := makeListEqualTestArray(mem, dt,
tc.left, tc.valid)
right := makeListEqualTestArray(mem,
dt, tc.right, tc.valid)
assert.Equal(t, tc.want,
array.Equal(left, right))
+ assert.Equal(t, tc.want,
array.ApproxEqual(left, right))
left.Release()
right.Release()
mem.AssertSize(t, 0)
@@ -212,6 +256,7 @@ func TestListEqualByValidRuns(t *testing.T) {
leftSlice := array.NewSlice(left, 1, 4)
rightSlice := array.NewSlice(right, 2, 5)
assert.True(t, array.Equal(leftSlice,
rightSlice))
+ assert.True(t, array.ApproxEqual(leftSlice,
rightSlice))
leftSlice.Release()
rightSlice.Release()
left.Release()
@@ -231,10 +276,12 @@ func TestFixedSizeListEqualByValidRuns(t *testing.T) {
right := makeListEqualTestArray(mem, dt,
[][]int32{{1, 2}, {100, 101}, {3, 4}}, []bool{true, false,
true})
assert.True(t, array.Equal(left, right))
+ assert.True(t, array.ApproxEqual(left, right))
different := makeListEqualTestArray(mem, dt,
[][]int32{{1, 2}, {100, 101}, {3, 5}}, []bool{true, false,
true})
assert.False(t, array.Equal(left, different))
+ assert.False(t, array.ApproxEqual(left, different))
leftWithPrefix := makeListEqualTestArray(mem, dt,
[][]int32{{9, 9}, {1, 2}, {99, 98}, {3, 4}}, []bool{true, true,
false, true})
@@ -243,6 +290,7 @@ func TestFixedSizeListEqualByValidRuns(t *testing.T) {
leftSlice := array.NewSlice(leftWithPrefix, 1, 4)
rightSlice := array.NewSlice(rightWithPrefix, 2, 5)
assert.True(t, array.Equal(leftSlice, rightSlice))
+ assert.True(t, array.ApproxEqual(leftSlice, rightSlice))
left.Release()
right.Release()
@@ -268,7 +316,9 @@ func TestListEqualWithEmptyValidityBuffer(t *testing.T) {
assert.NotNil(t, emptyValidity.NullBitmapBytes())
assert.True(t, array.Equal(emptyValidity, valid))
+ assert.True(t, array.ApproxEqual(emptyValidity, valid))
assert.True(t, array.Equal(valid, emptyValidity))
+ assert.True(t, array.ApproxEqual(valid, emptyValidity))
emptyValidity.Release()
valid.Release()
@@ -1249,3 +1299,48 @@ func TestTableEqual(t *testing.T) {
})
}
}
+
+func TestListApproxEqualValidRunsOptions(t *testing.T) {
+ for _, dt := range
[]arrow.DataType{arrow.ListOf(arrow.PrimitiveTypes.Float64),
arrow.LargeListOf(arrow.PrimitiveTypes.Float64), arrow.FixedSizeListOf(2,
arrow.PrimitiveTypes.Float64)} {
+ t.Run(dt.String(), func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ makeInput := func(prefix int, delta float64, mismatch
bool) arrow.Array {
+ builder := array.NewBuilder(mem, dt)
+ defer builder.Release()
+ child := builder.(interface{ ValueBuilder()
array.Builder }).ValueBuilder().(*array.Float64Builder)
+ for i := -prefix; i < 128; i++ {
+ valid := i < 32 || i >= 64
+ builder.(interface{ Append(bool)
}).Append(valid)
+ if i >= 32 && i < 64 {
+
child.AppendValues([]float64{float64(prefix) * 999, -float64(prefix) * 999},
nil)
+ } else if i == 80 {
+ child.Append(math.NaN())
+ child.AppendNull()
+ } else {
+ value := float64(i) + delta
+ if mismatch && i == 100 {
+ value += 10
+ }
+
child.AppendValues([]float64{value, value}, nil)
+ }
+ }
+ base := builder.NewArray()
+ defer base.Release()
+ return array.NewSlice(base, int64(prefix),
int64(prefix+128))
+ }
+ left := makeInput(1, 0, false)
+ defer left.Release()
+ right := makeInput(3, 0.01, false)
+ defer right.Release()
+ mismatch := makeInput(2, 0.01, true)
+ defer mismatch.Release()
+ opts :=
[]array.EqualOption{array.WithAbsTolerance(0.1), array.WithNaNsEqual(true)}
+ assert.True(t, array.ApproxEqual(left, right, opts...))
+ assert.True(t, array.ApproxEqual(right, left, opts...))
+ assert.False(t, array.ApproxEqual(left, right,
array.WithNaNsEqual(true)))
+ assert.False(t, array.ApproxEqual(left, right,
array.WithAbsTolerance(0.1)))
+ assert.False(t, array.ApproxEqual(left, mismatch,
opts...))
+ })
+ }
+}
diff --git a/arrow/array/list_equal_benchmark_test.go
b/arrow/array/list_equal_benchmark_test.go
index bd4bfb9c..14883a6a 100644
--- a/arrow/array/list_equal_benchmark_test.go
+++ b/arrow/array/list_equal_benchmark_test.go
@@ -64,6 +64,39 @@ func BenchmarkListEqual(b *testing.B) {
}
}
+func BenchmarkListApproxEqual(b *testing.B) {
+ const rows = 65536
+ tests := []struct {
+ listType string
+ child arrow.DataType
+ listSize int
+ validity string
+ }{
+ {"list", arrow.PrimitiveTypes.Int32, 16, "all-valid"},
+ {"list", arrow.BinaryTypes.String, 16, "all-valid"},
+ {"large-list", arrow.PrimitiveTypes.Int32, 16, "all-valid"},
+ {"fixed-size-list", arrow.PrimitiveTypes.Int32, 16,
"all-valid"},
+ {"list", arrow.PrimitiveTypes.Int32, 16, "10pct-null"},
+ {"list", arrow.PrimitiveTypes.Int32, 16,
"clustered-10pct-null"},
+ {"list", arrow.PrimitiveTypes.Int32, 16, "alternating-null"},
+ }
+
+ for _, tc := range tests {
+ name := fmt.Sprintf("%s/%s/size=%d/%s", tc.listType,
tc.child.Name(), tc.listSize, tc.validity)
+ b.Run(name, func(b *testing.B) {
+ left := makeListEqualBenchmarkArray(tc.listType,
tc.child, rows, tc.listSize, tc.validity)
+ right := makeListEqualBenchmarkArray(tc.listType,
tc.child, rows, tc.listSize, tc.validity)
+ defer left.Release()
+ defer right.Release()
+
+ b.ReportAllocs()
+ for b.Loop() {
+ benchmarkListEqualResult =
array.ApproxEqual(left, right)
+ }
+ })
+ }
+}
+
func makeListEqualBenchmarkArray(listType string, childType arrow.DataType,
rows, listSize int, validity string) arrow.Array {
var (
child array.Builder