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 7e4c9c87 perf(arrow/array): reuse dictionary mappings and handle empty 
chunks in Concatenate (#1403)
7e4c9c87 is described below

commit 7e4c9c871cd2c4fd83f4c89a37da302dbf6e5a09
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 18:34:57 2026 +0200

    perf(arrow/array): reuse dictionary mappings and handle empty chunks in 
Concatenate (#1403)
    
    ## What
    Reuse dictionary checks and index mappings in `array.Concatenate`.
    
    ## Why
    Many chunks share dictionary data. Rechecking values and rebuilding
    mappings for each chunk adds repeated work.
    
    ## Implementation
    - Skip value comparison when chunks share the same dictionary data.
    - Reuse transposition buffers for repeated dictionaries.
    - Add correctness coverage and a public API benchmark.
    
    ## Benchmark
    Public `array.Concatenate` call with 12 chunks and 8,192 dictionary
    entries. Go 1.26.3, Apple M1 Pro (`darwin/arm64`), `GOMAXPROCS=1`; 10
    runs of 200 ms per case. Base `de0c94e` vs head `651fe14`.
    
    | Metric | Before | After | Change |
    | --- | ---: | ---: | ---: |
    | Shared dictionary, time | 82.374 µs/op | 3.723 µs/op | -95.48% |
    | Repeated mixed dictionaries, time | 5.593 ms/op | 2.039 ms/op |
    -63.54% |
    | Shared dictionary, bytes | 15.42 KiB/op | 14.48 KiB/op | -6.08% |
    | Repeated mixed dictionaries, bytes | 4.262 MiB/op | 2.784 MiB/op |
    -34.69% |
    | Shared dictionary, allocations | 23 allocs/op | 11 allocs/op | -52.17%
    |
    | Repeated mixed dictionaries, allocations | 99.04k allocs/op | 25.26k
    allocs/op | -74.49% |
    
    Both time cases improved. The benchmark excludes setup and releases each
    output. Command: `GOMAXPROCS=1 go test ./arrow/array -run '^$' -bench
    '^BenchmarkConcatenateDictionaryReuse$' -benchmem -benchtime=200ms
    -count=10 -cpu=1`.
---
 arrow/array/concat.go                       |  35 +++++-
 arrow/array/concat_dictionary_reuse_test.go | 167 ++++++++++++++++++++++++++++
 2 files changed, 197 insertions(+), 5 deletions(-)

diff --git a/arrow/array/concat.go b/arrow/array/concat.go
index 5fb9cb4b..2c04ec09 100644
--- a/arrow/array/concat.go
+++ b/arrow/array/concat.go
@@ -326,13 +326,29 @@ func unifyDictionaries(mem memory.Allocator, data 
[]arrow.ArrayData, dt *arrow.D
        defer unifier.Release()
 
        newLookup := make([]*memory.Buffer, len(data))
+       // Reusing the same dictionary data must also reuse its transposition.
+       // A dictionary's indices in the unified result never change as new
+       // dictionaries are added to the unifier.
+       transpositions := make(map[arrow.ArrayData]*memory.Buffer)
        for i, d := range data {
-               dictArr := MakeFromData(d.Dictionary())
-               defer dictArr.Release()
+               dictData := d.Dictionary()
+               if existing, ok := transpositions[dictData]; ok {
+                       // concatDictIndices releases one buffer per input 
chunk.
+                       existing.Retain()
+                       newLookup[i] = existing
+                       continue
+               }
+
+               dictArr := MakeFromData(dictData)
                newLookup[i], err = unifier.UnifyAndTranspose(dictArr)
+               dictArr.Release()
                if err != nil {
+                       for _, b := range newLookup[:i] {
+                               b.Release()
+                       }
                        return nil, nil, err
                }
+               transpositions[dictData] = newLookup[i]
        }
 
        unified, err := unifier.GetResultWithIndexType(dt.IndexType)
@@ -365,6 +381,9 @@ func concatDictIndices(mem memory.Allocator, data 
[]arrow.ArrayData, idxType arr
 
        outData := out.Bytes()
        for i, d := range data {
+               if d.Len() == 0 {
+                       continue
+               }
                transposeMap := 
arrow.Int32Traits.CastFromBytes(transpositions[i].Bytes())
                src := d.Buffers()[1].Bytes()
                if d.Buffers()[0] == nil {
@@ -607,13 +626,19 @@ func concat(data []arrow.ArrayData, mem memory.Allocator) 
(arr arrow.ArrayData,
                dict0 := MakeFromData(data[0].Dictionary())
                defer dict0.Release()
                for _, d := range data {
+                       // Chunked arrays often share one immutable dictionary. 
Its
+                       // backing Data pointer is enough to prove equality, 
avoiding
+                       // a full scan of the dictionary for every chunk.
+                       if d.Dictionary() == data[0].Dictionary() {
+                               continue
+                       }
                        dict := MakeFromData(d.Dictionary())
-                       if !Equal(dict0, dict) {
-                               dict.Release()
+                       equal := Equal(dict0, dict)
+                       dict.Release()
+                       if !equal {
                                dictsSame = false
                                break
                        }
-                       dict.Release()
                }
 
                if dictsSame {
diff --git a/arrow/array/concat_dictionary_reuse_test.go 
b/arrow/array/concat_dictionary_reuse_test.go
new file mode 100644
index 00000000..8a5ad7d3
--- /dev/null
+++ b/arrow/array/concat_dictionary_reuse_test.go
@@ -0,0 +1,167 @@
+// 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"
+       "github.com/stretchr/testify/require"
+)
+
+// makeConcatDictionaryChunks reuses the same dictionary Data for all chunks
+// selecting the same values entry, including when the indices differ.
+func makeConcatDictionaryChunks(mem memory.Allocator, valueSets [][]string, 
sequence []int, rows int, nullable, sliced bool) []arrow.Array {
+       dictionaries := make([]arrow.Array, len(valueSets))
+       for i, values := range valueSets {
+               builder := array.NewStringBuilder(mem)
+               builder.AppendValues(values, nil)
+               dictionaries[i] = builder.NewArray()
+               builder.Release()
+       }
+       defer func() {
+               for _, dict := range dictionaries {
+                       dict.Release()
+               }
+       }()
+
+       dtype := &arrow.DictionaryType{
+               IndexType: arrow.PrimitiveTypes.Int32,
+               ValueType: arrow.BinaryTypes.String,
+       }
+       out := make([]arrow.Array, 0, len(sequence))
+       for chunkIndex, dictIndex := range sequence {
+               builder := array.NewInt32Builder(mem)
+               for row := 0; row < rows; row++ {
+                       if nullable && (row+chunkIndex)%11 == 0 {
+                               builder.AppendNull()
+                       } else {
+                               builder.Append(int32((row + chunkIndex) % 
len(valueSets[dictIndex])))
+                       }
+               }
+               indices := builder.NewArray()
+               builder.Release()
+               dictArray := array.NewDictionaryArray(dtype, indices, 
dictionaries[dictIndex])
+               indices.Release()
+
+               if sliced && rows > 1 {
+                       start := int64(chunkIndex % 2)
+                       slice := array.NewSlice(dictArray, start, int64(rows))
+                       dictArray.Release()
+                       dictArray = slice.(*array.Dictionary)
+               }
+               out = append(out, dictArray)
+       }
+       return out
+}
+
+func releaseConcatDictionaryChunks(chunks []arrow.Array) {
+       for _, chunk := range chunks {
+               chunk.Release()
+       }
+}
+
+func TestConcatenateDictionaryReuse(t *testing.T) {
+       base := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
+       reversed := []string{"epsilon", "gamma", "alpha", "zeta", "beta"}
+       tests := []struct {
+               name      string
+               valueSets [][]string
+               sequence  []int
+               rows      int
+               nullable  bool
+               sliced    bool
+       }{
+               {"shared", [][]string{base}, []int{0, 0, 0, 0}, 29, false, 
false},
+               {"shared_sliced_nulls", [][]string{base}, []int{0, 0, 0, 0, 0}, 
29, true, true},
+               {"equal_distinct_data", [][]string{base, append([]string(nil), 
base...)}, []int{0, 1, 0, 1}, 29, true, false},
+               {"mixed_repeated", [][]string{base, reversed}, []int{0, 1, 0, 
1, 1, 0, 1, 0}, 37, true, true},
+               {"mixed_empty_chunks", [][]string{base, reversed}, []int{0, 1, 
0, 1}, 0, false, false},
+               {"single_chunk", [][]string{base}, []int{0}, 31, true, false},
+       }
+
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.NewGoAllocator())
+                       defer mem.AssertSize(t, 0)
+
+                       inputs := makeConcatDictionaryChunks(mem, tc.valueSets, 
tc.sequence, tc.rows, tc.nullable, tc.sliced)
+                       defer releaseConcatDictionaryChunks(inputs)
+                       expected := make([]string, 0)
+                       for _, in := range inputs {
+                               for i := 0; i < in.Len(); i++ {
+                                       expected = append(expected, 
in.ValueStr(i))
+                               }
+                       }
+
+                       combined, err := array.Concatenate(inputs, mem)
+                       require.NoError(t, err)
+                       defer combined.Release()
+                       require.NoError(t, array.ValidateFull(combined))
+                       require.Equal(t, len(expected), combined.Len())
+                       for i, want := range expected {
+                               require.Equal(t, want, combined.ValueStr(i), 
"row %d", i)
+                       }
+               })
+       }
+}
+
+// BenchmarkConcatenateDictionaryReuse measures the complete public concatenate
+// call with shared dictionaries and repeated dictionary versions. Run with:
+// go test ./arrow/array -run '^$' -bench 
'^BenchmarkConcatenateDictionaryReuse$' -benchmem
+func BenchmarkConcatenateDictionaryReuse(b *testing.B) {
+       base := make([]string, 8192)
+       for i := range base {
+               base[i] = fmt.Sprintf("dictionary-value-%06d", i)
+       }
+       changed := append([]string(nil), base...)
+       for i := 0; i < len(changed); i += 16 {
+               changed[i] = fmt.Sprintf("new-value-%06d", i)
+       }
+       second := append([]string(nil), base...)
+       for i := 1; i < len(second); i += 16 {
+               second[i] = fmt.Sprintf("another-new-value-%06d", i)
+       }
+
+       cases := []struct {
+               name      string
+               valueSets [][]string
+               sequence  []int
+       }{
+               {"shared_dictionary", [][]string{base}, []int{0, 0, 0, 0, 0, 0, 
0, 0, 0, 0, 0, 0}},
+               {"repeated_mixed_dictionaries", [][]string{base, changed, 
second}, []int{0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2}},
+       }
+       for _, tc := range cases {
+               b.Run(tc.name, func(b *testing.B) {
+                       mem := memory.NewGoAllocator()
+                       inputs := makeConcatDictionaryChunks(mem, tc.valueSets, 
tc.sequence, 256, true, false)
+                       defer releaseConcatDictionaryChunks(inputs)
+                       b.ReportAllocs()
+                       b.ResetTimer()
+                       for b.Loop() {
+                               output, err := array.Concatenate(inputs, mem)
+                               if err != nil {
+                                       b.Fatal(err)
+                               }
+                               output.Release()
+                       }
+               })
+       }
+}

Reply via email to