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 31ef15de perf(arrow/array): compare list values by valid runs (#1184)
31ef15de is described below

commit 31ef15de46ebd3782152e269323b6649a3b3fdff
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 14 23:22:36 2026 +0200

    perf(arrow/array): compare list values by valid runs (#1184)
    
    ## What does this PR do?
    
    - compares List and LargeList child values once per contiguous valid
    parent run
    - checks every parent list length before combining its child range
    - compares FixedSizeList child values once per valid parent run
    - avoids creating two child array slices for every valid parent value
    
    ListView and LargeListView are not included because their child ranges
    can be non-contiguous.
    
    ## Why?
    
    List equality currently creates two temporary child arrays and
    recursively calls `Equal` for every valid parent value.
    
    For 65,536 all-valid lists, that creates about 131,000 temporary child
    arrays. A valid-run comparison checks the parent lengths and compares
    one contiguous child range instead.
    
    This follows the same approach as Arrow C++ list equality.
    
    ## Benchmarks
    
    Apple M1 Pro, 65,536 equal parent values:
    
    | case | before | after | change | allocations before | allocations
    after |
    | --- | ---: | ---: | ---: | ---: | ---: |
    | List<Int32>, size 1, all valid | 15.6 ms | 0.82 ms | -95% | 262,145 |
    5 |
    | List<Int32>, size 4, all valid | 16.5 ms | 2.39 ms | -86% | 262,145 |
    5 |
    | List<Int32>, size 16, all valid | 22.3 ms | 8.50 ms | -62% | 262,145 |
    5 |
    | List<String>, size 16, all valid | 23.0 ms | 8.65 ms | -62% | 262,145
    | 5 |
    | LargeList<Int32>, size 16, all valid | 22.3 ms | 8.47 ms | -62% |
    262,145 | 5 |
    | FixedSizeList<Int32>, size 16, all valid | 22.5 ms | 8.50 ms | -62% |
    262,145 | 5 |
    | List<Int32>, size 16, clustered 10% null | 20.1 ms | 7.90 ms | -61% |
    235,933 | 9 |
    
    Alternating valid and null parents stays around 11.2 ms in both
    versions. This is the most fragmented case, where each valid run
    contains one parent value.
    
    ```text
    go test ./arrow/array -run '^$' -bench '^BenchmarkListEqual$' -benchmem 
-benchtime=200ms -count=3
    ```
    
    ## Tests
    
    - checks equal and different child values
    - checks different parent list lengths with the same flattened child
    values
    - checks that null parent payloads are ignored
    - checks sliced parents with different left and right offsets
    - covers List, LargeList, and FixedSizeList
    - ran `go test ./arrow/...`
    - ran `go test -race ./arrow/array`
    - ran `go vet ./arrow/array`
---
 arrow/array/compare.go                   |  32 ++++++
 arrow/array/compare_test.go              | 168 +++++++++++++++++++++++++++++++
 arrow/array/fixed_size_list.go           |  30 +++---
 arrow/array/list.go                      |  36 +------
 arrow/array/list_equal_benchmark_test.go | 129 ++++++++++++++++++++++++
 5 files changed, 348 insertions(+), 47 deletions(-)

diff --git a/arrow/array/compare.go b/arrow/array/compare.go
index d8a9552e..7624b996 100644
--- a/arrow/array/compare.go
+++ b/arrow/array/compare.go
@@ -361,6 +361,38 @@ func SliceEqual(left arrow.Array, lbeg, lend int64, right 
arrow.Array, rbeg, ren
        return Equal(l, r)
 }
 
+type listOffset interface {
+       int32 | int64
+}
+
+func arrayEqualListOffsets[T listOffset](leftValues, rightValues arrow.Array,
+       leftOffsets, rightOffsets []T, leftOffset, rightOffset, length int, 
validBits []byte) 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 !SliceEqual(leftValues, leftStart, leftEnd, 
rightValues, rightStart, rightEnd) {
+                               return arrow.ErrInvalid
+                       }
+                       return nil
+               }) == nil
+}
+
 // 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...)
diff --git a/arrow/array/compare_test.go b/arrow/array/compare_test.go
index 0671def6..d89ce42b 100644
--- a/arrow/array/compare_test.go
+++ b/arrow/array/compare_test.go
@@ -85,6 +85,174 @@ func TestArraySliceEqual(t *testing.T) {
        }
 }
 
+func TestListEqualByValidRuns(t *testing.T) {
+       for _, dt := range []arrow.DataType{
+               arrow.ListOf(arrow.PrimitiveTypes.Int32),
+               arrow.LargeListOf(arrow.PrimitiveTypes.Int32),
+       } {
+               t.Run(dt.ID().String(), func(t *testing.T) {
+                       tests := []struct {
+                               name        string
+                               left, right [][]int32
+                               valid       []bool
+                               want        bool
+                       }{
+                               {
+                                       name:  "equal",
+                                       left:  [][]int32{{1, 2}, {3}, {4, 5, 
6}},
+                                       right: [][]int32{{1, 2}, {3}, {4, 5, 
6}},
+                                       valid: []bool{true, true, true},
+                                       want:  true,
+                               },
+                               {
+                                       name:  "different list lengths with 
equal child values",
+                                       left:  [][]int32{{1}, {2, 3}},
+                                       right: [][]int32{{1, 2}, {3}},
+                                       valid: []bool{true, true},
+                                       want:  false,
+                               },
+                               {
+                                       name:  "ignore null list payloads",
+                                       left:  [][]int32{{1}, {99}, {3, 4}},
+                                       right: [][]int32{{1}, {100, 101}, {3, 
4}},
+                                       valid: []bool{true, false, true},
+                                       want:  true,
+                               },
+                               {
+                                       name:  "different valid child values",
+                                       left:  [][]int32{{1}, {99}, {3, 4}},
+                                       right: [][]int32{{1}, {100}, {3, 5}},
+                                       valid: []bool{true, false, true},
+                                       want:  false,
+                               },
+                       }
+
+                       for _, tc := range tests {
+                               t.Run(tc.name, func(t *testing.T) {
+                                       mem := 
memory.NewCheckedAllocator(memory.NewGoAllocator())
+                                       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))
+                                       left.Release()
+                                       right.Release()
+                                       mem.AssertSize(t, 0)
+                               })
+                       }
+
+                       t.Run("sliced parents", func(t *testing.T) {
+                               mem := 
memory.NewCheckedAllocator(memory.NewGoAllocator())
+                               left := makeListEqualTestArray(mem, dt,
+                                       [][]int32{{9}, {1, 2}, {3}, {4, 5}}, 
[]bool{true, true, false, true})
+                               right := makeListEqualTestArray(mem, dt,
+                                       [][]int32{{7}, {8, 8}, {1, 2}, {99}, 
{4, 5}}, []bool{true, true, true, false, true})
+                               leftSlice := array.NewSlice(left, 1, 4)
+                               rightSlice := array.NewSlice(right, 2, 5)
+                               assert.True(t, array.Equal(leftSlice, 
rightSlice))
+                               leftSlice.Release()
+                               rightSlice.Release()
+                               left.Release()
+                               right.Release()
+                               mem.AssertSize(t, 0)
+                       })
+               })
+       }
+}
+
+func TestFixedSizeListEqualByValidRuns(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+       dt := arrow.FixedSizeListOf(2, arrow.PrimitiveTypes.Int32)
+
+       left := makeListEqualTestArray(mem, dt,
+               [][]int32{{1, 2}, {99, 98}, {3, 4}}, []bool{true, false, true})
+       right := makeListEqualTestArray(mem, dt,
+               [][]int32{{1, 2}, {100, 101}, {3, 4}}, []bool{true, false, 
true})
+       assert.True(t, array.Equal(left, right))
+
+       different := makeListEqualTestArray(mem, dt,
+               [][]int32{{1, 2}, {100, 101}, {3, 5}}, []bool{true, false, 
true})
+       assert.False(t, array.Equal(left, different))
+
+       leftWithPrefix := makeListEqualTestArray(mem, dt,
+               [][]int32{{9, 9}, {1, 2}, {99, 98}, {3, 4}}, []bool{true, true, 
false, true})
+       rightWithPrefix := makeListEqualTestArray(mem, dt,
+               [][]int32{{7, 7}, {8, 8}, {1, 2}, {100, 101}, {3, 4}}, 
[]bool{true, true, true, false, true})
+       leftSlice := array.NewSlice(leftWithPrefix, 1, 4)
+       rightSlice := array.NewSlice(rightWithPrefix, 2, 5)
+       assert.True(t, array.Equal(leftSlice, rightSlice))
+
+       left.Release()
+       right.Release()
+       different.Release()
+       leftSlice.Release()
+       rightSlice.Release()
+       leftWithPrefix.Release()
+       rightWithPrefix.Release()
+       mem.AssertSize(t, 0)
+}
+
+func TestListEqualWithEmptyValidityBuffer(t *testing.T) {
+       for _, dt := range []arrow.DataType{
+               arrow.ListOf(arrow.PrimitiveTypes.Int32),
+               arrow.LargeListOf(arrow.PrimitiveTypes.Int32),
+               arrow.FixedSizeListOf(2, arrow.PrimitiveTypes.Int32),
+       } {
+               t.Run(dt.ID().String(), func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.NewGoAllocator())
+                       values := [][]int32{{1, 2}, {3, 4}}
+                       valid := makeListEqualTestArray(mem, dt, values, 
[]bool{true, true})
+                       emptyValidity := 
makeListEqualTestArrayWithEmptyValidity(mem, dt, values)
+
+                       assert.NotNil(t, emptyValidity.NullBitmapBytes())
+                       assert.True(t, array.Equal(emptyValidity, valid))
+                       assert.True(t, array.Equal(valid, emptyValidity))
+
+                       emptyValidity.Release()
+                       valid.Release()
+                       mem.AssertSize(t, 0)
+               })
+       }
+}
+
+func makeListEqualTestArray(mem memory.Allocator, dt arrow.DataType, values 
[][]int32, valid []bool) arrow.Array {
+       switch dt.ID() {
+       case arrow.LIST, arrow.LARGE_LIST:
+               bldr := array.NewBuilder(mem, dt).(array.VarLenListLikeBuilder)
+               child := bldr.ValueBuilder().(*array.Int32Builder)
+               for i, value := range values {
+                       bldr.AppendWithSize(valid[i], len(value))
+                       child.AppendValues(value, nil)
+               }
+               out := bldr.NewArray()
+               bldr.Release()
+               return out
+       case arrow.FIXED_SIZE_LIST:
+               listSize := dt.(*arrow.FixedSizeListType).Len()
+               bldr := array.NewFixedSizeListBuilder(mem, listSize, 
arrow.PrimitiveTypes.Int32)
+               child := bldr.ValueBuilder().(*array.Int32Builder)
+               for i, value := range values {
+                       bldr.Append(valid[i])
+                       child.AppendValues(value, nil)
+               }
+               out := bldr.NewArray()
+               bldr.Release()
+               return out
+       default:
+               panic("unsupported list type")
+       }
+}
+
+func makeListEqualTestArrayWithEmptyValidity(mem memory.Allocator, dt 
arrow.DataType, values [][]int32) arrow.Array {
+       arr := makeListEqualTestArray(mem, dt, values, []bool{true, true})
+       data := arr.Data()
+       buffers := append([]*memory.Buffer(nil), data.Buffers()...)
+       buffers[0] = memory.NewBufferBytes([]byte{})
+       emptyValidityData := array.NewData(data.DataType(), data.Len(), 
buffers, data.Children(), data.NullN(), data.Offset())
+       out := array.MakeFromData(emptyValidityData)
+       emptyValidityData.Release()
+       arr.Release()
+       return out
+}
+
 func TestArrayApproxEqual(t *testing.T) {
        for name, recs := range arrdata.Records {
                t.Run(name, func(t *testing.T) {
diff --git a/arrow/array/fixed_size_list.go b/arrow/array/fixed_size_list.go
index 55477f11..c3aafb31 100644
--- a/arrow/array/fixed_size_list.go
+++ b/arrow/array/fixed_size_list.go
@@ -25,6 +25,7 @@ import (
        "github.com/apache/arrow-go/v18/arrow/bitutil"
        "github.com/apache/arrow-go/v18/arrow/internal/debug"
        "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/internal/bitutils"
        "github.com/apache/arrow-go/v18/internal/json"
 )
 
@@ -88,22 +89,21 @@ func (a *FixedSizeList) setData(data *Data) {
 }
 
 func arrayEqualFixedSizeList(left, right *FixedSizeList) 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 Equal(l, r)
-               }()
-               if !o {
-                       return false
-               }
+       listSize := int64(left.n)
+       validBits := left.NullBitmapBytes()
+       if len(validBits) == 0 {
+               validBits = nil
        }
-       return true
+       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 !SliceEqual(left.values, leftStart, 
leftStart+length*listSize,
+                               right.values, rightStart, 
rightStart+length*listSize) {
+                               return arrow.ErrInvalid
+                       }
+                       return nil
+               }) == nil
 }
 
 // Len returns the number of elements in the array.
diff --git a/arrow/array/list.go b/arrow/array/list.go
index dd9d82ef..4ccea68e 100644
--- a/arrow/array/list.go
+++ b/arrow/array/list.go
@@ -137,22 +137,8 @@ func (a *List) MarshalJSON() ([]byte, error) {
 }
 
 func arrayEqualList(left, right *List) 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 Equal(l, r)
-               }()
-               if !o {
-                       return false
-               }
-       }
-       return true
+       return arrayEqualListOffsets(left.values, right.values, left.offsets, 
right.offsets,
+               left.data.offset, right.data.offset, left.Len(), 
left.NullBitmapBytes())
 }
 
 // Len returns the number of elements in the array.
@@ -276,22 +262,8 @@ func (a *LargeList) MarshalJSON() ([]byte, error) {
 }
 
 func arrayEqualLargeList(left, right *LargeList) 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 Equal(l, r)
-               }()
-               if !o {
-                       return false
-               }
-       }
-       return true
+       return arrayEqualListOffsets(left.values, right.values, left.offsets, 
right.offsets,
+               left.data.offset, right.data.offset, left.Len(), 
left.NullBitmapBytes())
 }
 
 // Len returns the number of elements in the array.
diff --git a/arrow/array/list_equal_benchmark_test.go 
b/arrow/array/list_equal_benchmark_test.go
new file mode 100644
index 00000000..bd4bfb9c
--- /dev/null
+++ b/arrow/array/list_equal_benchmark_test.go
@@ -0,0 +1,129 @@
+// 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"
+)
+
+var benchmarkListEqualResult bool
+
+func BenchmarkListEqual(b *testing.B) {
+       const rows = 65536
+       tests := []struct {
+               listType string
+               child    arrow.DataType
+               listSize int
+               validity string
+       }{
+               {"list", arrow.PrimitiveTypes.Int32, 1, "all-valid"},
+               {"list", arrow.PrimitiveTypes.Int32, 4, "all-valid"},
+               {"list", arrow.PrimitiveTypes.Int32, 16, "all-valid"},
+               {"list", arrow.PrimitiveTypes.Int32, 64, "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()
+                       b.ResetTimer()
+                       for i := 0; i < b.N; i++ {
+                               benchmarkListEqualResult = array.Equal(left, 
right)
+                       }
+               })
+       }
+}
+
+func makeListEqualBenchmarkArray(listType string, childType arrow.DataType, 
rows, listSize int, validity string) arrow.Array {
+       var (
+               child        array.Builder
+               appendParent func(bool)
+               newArray     func() arrow.Array
+               release      func()
+       )
+
+       switch listType {
+       case "list":
+               bldr := array.NewListBuilder(memory.DefaultAllocator, childType)
+               child = bldr.ValueBuilder()
+               appendParent = bldr.Append
+               newArray = bldr.NewArray
+               release = bldr.Release
+       case "large-list":
+               bldr := array.NewLargeListBuilder(memory.DefaultAllocator, 
childType)
+               child = bldr.ValueBuilder()
+               appendParent = bldr.Append
+               newArray = bldr.NewArray
+               release = bldr.Release
+       case "fixed-size-list":
+               bldr := array.NewFixedSizeListBuilder(memory.DefaultAllocator, 
int32(listSize), childType)
+               child = bldr.ValueBuilder()
+               appendParent = bldr.Append
+               newArray = bldr.NewArray
+               release = bldr.Release
+       default:
+               panic("unsupported list type")
+       }
+       defer release()
+
+       for i := 0; i < rows; i++ {
+               valid := listBenchmarkValueIsValid(i, rows, validity)
+               appendParent(valid)
+               for j := 0; j < listSize; j++ {
+                       switch child := child.(type) {
+                       case *array.Int32Builder:
+                               child.Append(int32(j))
+                       case *array.StringBuilder:
+                               child.Append([]string{"alpha", "bravo", 
"charlie", "delta"}[j%4])
+                       default:
+                               panic("unsupported child type")
+                       }
+               }
+       }
+       return newArray()
+}
+
+func listBenchmarkValueIsValid(index, length int, pattern string) bool {
+       switch pattern {
+       case "all-valid":
+               return true
+       case "10pct-null":
+               return index%10 != 0
+       case "clustered-10pct-null":
+               return index < length*45/100 || index >= length*55/100
+       case "alternating-null":
+               return index%2 == 0
+       default:
+               panic("unsupported validity pattern")
+       }
+}

Reply via email to