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 4dfd78d2 perf(compute): fast-path single-span execution (#1233)
4dfd78d2 is described below

commit 4dfd78d220f814adb3a32a5ad9ddf27f0fa034d9
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 28 18:55:53 2026 +0200

    perf(compute): fast-path single-span execution (#1233)
    
    ### Rationale for this change
    
    `iterateExecSpans` allocates three tracking slices before it knows
    whether the
    batch can be processed as one contiguous span. Most non-chunked batches
    do not
    need that state.
    
    ### What changes are included in this PR?
    
    - Detect non-chunked batches that fit in one execution span.
    - Return that span directly without allocating chunk iteration state.
    - Keep the existing path for chunked batches and batches split by
      `maxChunkSize`.
    - Preserve sliced array offsets, scalar promotion, and iterator
    completion
      behavior.
    - Add focused tests and a benchmark.
    
    ### Benchmark
    
    The benchmark covers one array, two arrays, scalar plus array, all
    scalars, and
    a chunked control case at lengths 1, 16, 256, and 4096.
    
    Representative results from one run on an Apple M1 Pro with Go 1.26.3:
    
    | Case | Before | After | Allocations |
    | --- | ---: | ---: | ---: |
    | Array, length 1 | 342.0 ns/op, 528 B/op | 189.0 ns/op, 304 B/op | 8 ->
    5 |
    | Two arrays, length 1 | 451.4 ns/op, 776 B/op | 286.6 ns/op, 528 B/op |
    8 -> 5 |
    | Scalar plus array, length 1 | 433.8 ns/op, 776 B/op | 260.8 ns/op, 528
    B/op | 8 -> 5 |
    | All scalars | 468.1 ns/op, 784 B/op | 281.4 ns/op, 528 B/op | 10 -> 7
    |
    | Chunked control | 356.6 ns/op, 528 B/op | 339.2 ns/op, 528 B/op | 8 ->
    8 |
    
    Command:
    
    ```bash
    go test ./arrow/compute -run '^$' -bench '^BenchmarkIterateExecSpans$' 
-benchmem -benchtime=100ms -count=1 -cpu=1
    ```
    
    ### Are these changes tested?
    
    - `go test ./arrow/compute -count=1`
    - `go test -race ./arrow/compute -count=1`
---
 arrow/compute/exec_spans_benchmark_test.go | 109 +++++++++++++++++++++++++++++
 arrow/compute/exec_spans_test.go           |  85 ++++++++++++++++++++++
 arrow/compute/executor.go                  |  37 +++++++---
 3 files changed, 222 insertions(+), 9 deletions(-)

diff --git a/arrow/compute/exec_spans_benchmark_test.go 
b/arrow/compute/exec_spans_benchmark_test.go
new file mode 100644
index 00000000..070c8fd9
--- /dev/null
+++ b/arrow/compute/exec_spans_benchmark_test.go
@@ -0,0 +1,109 @@
+// 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.
+
+//go:build go1.18
+
+package compute
+
+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"
+)
+
+func BenchmarkIterateExecSpans(b *testing.B) {
+       for _, length := range []int64{1, 16, 256, 4096} {
+               b.Run(fmt.Sprintf("array/%d", length), func(b *testing.B) {
+                       batch := newExecSpanBenchmarkBatch(b, length, 1, false)
+                       benchmarkIterateExecSpans(b, batch, DefaultMaxChunkSize)
+               })
+
+               b.Run(fmt.Sprintf("binary/%d", length), func(b *testing.B) {
+                       batch := newExecSpanBenchmarkBatch(b, length, 2, false)
+                       benchmarkIterateExecSpans(b, batch, DefaultMaxChunkSize)
+               })
+
+               b.Run(fmt.Sprintf("scalar-array/%d", length), func(b 
*testing.B) {
+                       batch := newExecSpanBenchmarkBatch(b, length, 1, true)
+                       benchmarkIterateExecSpans(b, batch, DefaultMaxChunkSize)
+               })
+       }
+
+       b.Run("all-scalars", func(b *testing.B) {
+               batch := &ExecBatch{
+                       Values: []Datum{NewDatum(int32(1)), NewDatum(int32(2))},
+                       Len:    1,
+               }
+               benchmarkIterateExecSpans(b, batch, DefaultMaxChunkSize)
+       })
+
+       b.Run("chunked-control", func(b *testing.B) {
+               batch := newExecSpanBenchmarkBatch(b, 4096, 1, false)
+               arr := batch.Values[0].(*ArrayDatum).Value
+               chunk := array.MakeFromData(arr)
+               chunked := arrow.NewChunked(arrow.PrimitiveTypes.Int32, 
[]arrow.Array{chunk})
+               chunk.Release()
+               b.Cleanup(chunked.Release)
+               batch.Values[0] = &ChunkedDatum{Value: chunked}
+               benchmarkIterateExecSpans(b, batch, DefaultMaxChunkSize)
+       })
+}
+
+func benchmarkIterateExecSpans(b *testing.B, batch *ExecBatch, maxChunkSize 
int64) {
+       b.Helper()
+       b.ReportAllocs()
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               _, iter, err := iterateExecSpans(batch, maxChunkSize, true)
+               if err != nil {
+                       b.Fatal(err)
+               }
+
+               span, pos, ok := iter()
+               if !ok || pos != batch.Len || span.Len != batch.Len {
+                       b.Fatalf("unexpected first span: len=%d pos=%d ok=%t", 
span.Len, pos, ok)
+               }
+
+               _, pos, ok = iter()
+               if ok || pos != batch.Len {
+                       b.Fatalf("unexpected second span: pos=%d ok=%t", pos, 
ok)
+               }
+       }
+}
+
+func newExecSpanBenchmarkBatch(tb testing.TB, length int64, arrayCount int, 
addScalar bool) *ExecBatch {
+       tb.Helper()
+       values := make([]Datum, 0, arrayCount+1)
+       for i := 0; i < arrayCount; i++ {
+               builder := array.NewInt32Builder(memory.DefaultAllocator)
+               builder.Reserve(int(length))
+               for j := int64(0); j < length; j++ {
+                       builder.Append(int32(i) + int32(j))
+               }
+               arr := builder.NewInt32Array()
+               builder.Release()
+               tb.Cleanup(arr.Release)
+               values = append(values, &ArrayDatum{Value: arr.Data()})
+       }
+
+       if addScalar {
+               values = append(values, NewDatum(int32(1)))
+       }
+       return &ExecBatch{Values: values, Len: length}
+}
diff --git a/arrow/compute/exec_spans_test.go b/arrow/compute/exec_spans_test.go
new file mode 100644
index 00000000..3361a7bb
--- /dev/null
+++ b/arrow/compute/exec_spans_test.go
@@ -0,0 +1,85 @@
+// 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.
+
+//go:build go1.18
+
+package compute
+
+import (
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/stretchr/testify/require"
+)
+
+func TestIterateExecSpansSingleSpan(t *testing.T) {
+       const length = 16
+
+       builder := array.NewInt32Builder(memory.DefaultAllocator)
+       builder.AppendValues(make([]int32, length+2), nil)
+       full := builder.NewInt32Array()
+       builder.Release()
+       input := array.NewSlice(full, 1, length+1)
+       full.Release()
+       defer input.Release()
+
+       batch := &ExecBatch{
+               Values: []Datum{
+                       &ArrayDatum{Value: input.Data()},
+                       NewDatum(int32(5)),
+               },
+               Len: int64(input.Len()),
+       }
+
+       allScalars, iter, err := iterateExecSpans(batch, DefaultMaxChunkSize, 
true)
+       require.NoError(t, err)
+       require.False(t, allScalars)
+
+       span, pos, ok := iter()
+       require.True(t, ok)
+       require.EqualValues(t, length, span.Len)
+       require.EqualValues(t, length, pos)
+       require.EqualValues(t, input.Data().Offset(), 
span.Values[0].Array.Offset)
+       require.EqualValues(t, input.Len(), span.Values[0].Array.Len)
+       require.True(t, span.Values[1].IsScalar())
+
+       _, pos, ok = iter()
+       require.False(t, ok)
+       require.EqualValues(t, length, pos)
+}
+
+func TestIterateExecSpansPromotesAllScalars(t *testing.T) {
+       batch := &ExecBatch{
+               Values: []Datum{NewDatum(int32(1)), NewDatum(int32(2))},
+               Len:    1,
+       }
+
+       allScalars, iter, err := iterateExecSpans(batch, DefaultMaxChunkSize, 
true)
+       require.NoError(t, err)
+       require.True(t, allScalars)
+
+       span, pos, ok := iter()
+       require.True(t, ok)
+       require.EqualValues(t, 1, span.Len)
+       require.EqualValues(t, 1, pos)
+       require.True(t, span.Values[0].IsArray())
+       require.True(t, span.Values[1].IsArray())
+
+       _, pos, ok = iter()
+       require.False(t, ok)
+       require.EqualValues(t, 1, pos)
+}
diff --git a/arrow/compute/executor.go b/arrow/compute/executor.go
index febbec08..c32c9768 100644
--- a/arrow/compute/executor.go
+++ b/arrow/compute/executor.go
@@ -781,36 +781,40 @@ func iterateExecSpans(batch *ExecBatch, maxChunkSize 
int64, promoteIfAllScalar b
        }
 
        var (
-               args           = batch.Values
-               haveChunked    bool
-               chunkIdxes           = make([]int, len(args))
-               valuePositions       = make([]int64, len(args))
-               valueOffsets         = make([]int64, len(args))
-               pos, length    int64 = 0, batch.Len
+               args              = batch.Values
+               pos, length int64 = 0, batch.Len
        )
        haveAllScalars = checkIfAllScalar(batch)
        maxChunkSize = exec.Min(length, maxChunkSize)
 
        span := exec.ExecSpan{Values: make([]exec.ExecValue, len(args)), Len: 0}
+       haveChunked := false
        for i, a := range args {
                switch arg := a.(type) {
                case *ScalarDatum:
                        span.Values[i].Scalar = arg.Value
                case *ArrayDatum:
                        span.Values[i].Array.SetMembers(arg.Value)
-                       valueOffsets[i] = int64(arg.Value.Offset())
                case *ChunkedDatum:
+                       haveChunked = true
                        // populate from first chunk
                        carr := arg.Value
                        if len(carr.Chunks()) > 0 {
                                arr := carr.Chunk(0).Data()
                                span.Values[i].Array.SetMembers(arr)
-                               valueOffsets[i] = int64(arr.Offset())
                        } else {
                                // fill as zero len
                                exec.FillZeroLength(carr.DataType(), 
&span.Values[i].Array)
                        }
-                       haveChunked = true
+               }
+       }
+
+       singleSpan := !haveChunked && length > 0 && length <= maxChunkSize
+       var valueOffsets []int64
+       if !singleSpan {
+               valueOffsets = make([]int64, len(args))
+               for i := range args {
+                       valueOffsets[i] = span.Values[i].Array.Offset
                }
        }
 
@@ -818,6 +822,21 @@ func iterateExecSpans(batch *ExecBatch, maxChunkSize 
int64, promoteIfAllScalar b
                exec.PromoteExecSpanScalars(span)
        }
 
+       if singleSpan {
+               span.Len = length
+               returned := false
+               return haveAllScalars, func() (exec.ExecSpan, int64, bool) {
+                       if returned {
+                               return exec.ExecSpan{}, length, false
+                       }
+                       returned = true
+                       return span, length, true
+               }, nil
+       }
+
+       chunkIdxes := make([]int, len(args))
+       valuePositions := make([]int64, len(args))
+
        nextChunkSpan := func(iterSz int64, span exec.ExecSpan) int64 {
                for i := 0; i < len(args) && iterSz > 0; i++ {
                        // if the argument is not chunked, it's either a scalar 
or an array

Reply via email to