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 b4b0c04a perf(compute): use typed memo insertion for binary hashes
(#1315)
b4b0c04a is described below
commit b4b0c04a0b1ef7f6001b5a380e68a918bfe93f4f
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 6 16:23:13 2026 +0200
perf(compute): use typed memo insertion for binary hashes (#1315)
## What does this change?
- `doAppendBinary` and `doAppendFixedSize` still called
`MemoTable.GetOrInsert` for every value.
- The memo is already a `TypedMemoTable[[]byte]`.
- Use `InsertOrGet` directly, like the numeric hash paths.
- Null handling is unchanged.
## Why
This removes the `[]byte` to `interface{}` call-site conversion from the
compute dictionary and unique kernels. It also makes the typed binary
memo path reachable from these hot loops.
## Benchmark
Command:
```text
go test -p 1 -vet=off ./arrow/compute -run '^$' -bench
'^BenchmarkDictionaryEncodeBinary$' -benchmem -benchtime=500ms -count=7
```
Apple M1 Pro, macOS arm64, Go 1.26.3. The numbers below are medians of 7
runs. Baseline uses `GetOrInsert` at both call sites. After uses
`TypedMemoTable[[]byte].InsertOrGet`.
| Case | Before | After | Change |
| --- | ---: | ---: | ---: |
| string, 65,536 values | 4,837,326 ns/op | 3,151,276 ns/op | 34.9%
faster |
| fixed-size binary 16, 65,536 values | 4,445,131 ns/op | 3,134,294
ns/op | 29.5% faster |
| string B/op | 3,444,557 | 1,871,270 | 45.7% fewer |
| fixed-size binary B/op | 3,444,128 | 1,870,749 | 45.7% fewer |
| string allocs/op | 131,150 | 65,613 | 50.0% fewer |
| fixed-size binary allocs/op | 131,146 | 65,609 | 50.0% fewer |
## Coverage
- Existing string and binary dictionary tests cover the variable-width
path.
- Added a fixed-size-binary dictionary test.
- Added string and 16-byte fixed-size-binary benchmarks.
- `go test -p 1 ./arrow/compute -count=1`
No public API changes.
---
arrow/compute/internal/kernels/vector_hash.go | 6 +-
arrow/compute/vector_hash_binary_bench_test.go | 103 ++++++++++++++++++++
arrow/compute/vector_hash_binary_test.go | 127 +++++++++++++++++++++++++
3 files changed, 234 insertions(+), 2 deletions(-)
diff --git a/arrow/compute/internal/kernels/vector_hash.go
b/arrow/compute/internal/kernels/vector_hash.go
index ecad7e98..6eacba05 100644
--- a/arrow/compute/internal/kernels/vector_hash.go
+++ b/arrow/compute/internal/kernels/vector_hash.go
@@ -295,12 +295,13 @@ func doAppendBinary[OffsetT int32 | int64](action Action,
memo hashing.MemoTable
offsets = exec.GetSpanOffsets[OffsetT](arr, 1)
data = arr.Buffers[2].Buf
shouldEncodeNulls = action.ShouldEncodeNulls()
+ typedMemo = memo.(hashing.TypedMemoTable[[]byte])
)
return bitutils.VisitBitBlocksShort(bitmap, arr.Offset, arr.Len,
func(pos int64) error {
v := data[offsets[pos]:offsets[pos+1]]
- idx, found, err := memo.GetOrInsert(v)
+ idx, found, err := typedMemo.InsertOrGet(v)
if err != nil {
return err
}
@@ -328,12 +329,13 @@ func doAppendFixedSize(action Action, memo
hashing.MemoTable, arr *exec.ArraySpa
sz := int64(arr.Type.(arrow.FixedWidthDataType).Bytes())
arrData := arr.Buffers[1].Buf[arr.Offset*sz:]
shouldEncodeNulls := action.ShouldEncodeNulls()
+ typedMemo := memo.(hashing.TypedMemoTable[[]byte])
return bitutils.VisitBitBlocksShort(arr.Buffers[0].Buf, arr.Offset,
arr.Len,
func(pos int64) error {
// fixed size type memo table we use a binary memo table
// so get the raw bytes
- idx, found, err := memo.GetOrInsert(arrData[pos*sz :
(pos+1)*sz])
+ idx, found, err := typedMemo.InsertOrGet(arrData[pos*sz
: (pos+1)*sz])
if err != nil {
return err
}
diff --git a/arrow/compute/vector_hash_binary_bench_test.go
b/arrow/compute/vector_hash_binary_bench_test.go
new file mode 100644
index 00000000..fbf68654
--- /dev/null
+++ b/arrow/compute/vector_hash_binary_bench_test.go
@@ -0,0 +1,103 @@
+// 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.24
+
+package compute_test
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/compute"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+)
+
+func BenchmarkDictionaryEncodeBinary(b *testing.B) {
+ const (
+ nvalues = 1 << 16
+ nunique = 100
+ )
+
+ mem := memory.DefaultAllocator
+ ctx := compute.WithAllocator(context.Background(), mem)
+
+ b.Run("string", func(b *testing.B) {
+ values := make([]string, nvalues)
+ bytes := 0
+ for i := range values {
+ values[i] = fmt.Sprintf("value-%08d", i%nunique)
+ bytes += len(values[i])
+ }
+
+ builder := array.NewStringBuilder(mem)
+ builder.AppendValues(values, nil)
+ input := builder.NewStringArray()
+ builder.Release()
+ defer input.Release()
+
+ benchmarkDictionaryEncodeBinary(b, ctx, input, bytes)
+ })
+
+ b.Run("large-string", func(b *testing.B) {
+ values := make([]string, nvalues)
+ bytes := 0
+ for i := range values {
+ values[i] = fmt.Sprintf("value-%08d", i%nunique)
+ bytes += len(values[i])
+ }
+
+ builder := array.NewLargeStringBuilder(mem)
+ builder.AppendValues(values, nil)
+ input := builder.NewLargeStringArray()
+ builder.Release()
+ defer input.Release()
+
+ benchmarkDictionaryEncodeBinary(b, ctx, input, bytes)
+ })
+
+ b.Run("fixed-size-binary-16", func(b *testing.B) {
+ values := make([][]byte, nvalues)
+ for i := range values {
+ values[i] = make([]byte, 16)
+ binary.LittleEndian.PutUint32(values[i],
uint32(i%nunique))
+ }
+
+ builder := array.NewFixedSizeBinaryBuilder(mem,
&arrow.FixedSizeBinaryType{ByteWidth: 16})
+ builder.AppendValues(values, nil)
+ input := builder.NewFixedSizeBinaryArray()
+ builder.Release()
+ defer input.Release()
+
+ benchmarkDictionaryEncodeBinary(b, ctx, input, nvalues*16)
+ })
+}
+
+func benchmarkDictionaryEncodeBinary(b *testing.B, ctx context.Context, input
arrow.Array, bytes int) {
+ b.ReportAllocs()
+ b.SetBytes(int64(bytes))
+ for b.Loop() {
+ result, err := compute.DictionaryEncodeArray(ctx,
compute.DictionaryEncodeOptions{}, input)
+ if err != nil {
+ b.Fatal(err)
+ }
+ result.Release()
+ }
+}
diff --git a/arrow/compute/vector_hash_binary_test.go
b/arrow/compute/vector_hash_binary_test.go
new file mode 100644
index 00000000..0029d98d
--- /dev/null
+++ b/arrow/compute/vector_hash_binary_test.go
@@ -0,0 +1,127 @@
+// 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_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/compute"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDictionaryEncodeFixedSizeBinary(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ values := [][]byte{
+ {0, 0, 0, 1},
+ {0, 0, 0, 2},
+ {0, 0, 0, 1},
+ {0, 0, 0, 3},
+ {0, 0, 0, 2},
+ {0, 0, 0, 3},
+ }
+ valid := []bool{true, true, true, false, true, true}
+
+ builder := array.NewFixedSizeBinaryBuilder(mem,
&arrow.FixedSizeBinaryType{ByteWidth: 4})
+ builder.AppendValues(values, valid)
+ input := builder.NewFixedSizeBinaryArray()
+ builder.Release()
+ defer input.Release()
+
+ ctx := compute.WithAllocator(context.Background(), mem)
+ result, err := compute.DictionaryEncodeArray(ctx,
compute.DictionaryEncodeOptions{}, input)
+ require.NoError(t, err)
+ defer result.Release()
+
+ encoded := result.(*array.Dictionary)
+ require.Equal(t, []int32{0, 1, 0, 0, 1, 2},
encoded.Indices().(*array.Int32).Int32Values())
+ require.Equal(t, 1, encoded.NullN())
+ require.Equal(t, 3, encoded.Dictionary().Len())
+
+ dict := encoded.Dictionary().(*array.FixedSizeBinary)
+ require.Equal(t, values[0], dict.Value(0))
+ require.Equal(t, values[1], dict.Value(1))
+ require.Equal(t, values[3], dict.Value(2))
+}
+
+func TestDictionaryEncodeBinarySlicedNullModes(t *testing.T) {
+ for _, dt := range []arrow.DataType{arrow.BinaryTypes.Binary,
arrow.BinaryTypes.LargeBinary, arrow.BinaryTypes.String,
arrow.BinaryTypes.LargeString, &arrow.FixedSizeBinaryType{ByteWidth: 4}} {
+ t.Run(dt.String(), func(t *testing.T) {
+ for _, mode := range
[]compute.NullEncodingBehavior{compute.NullEncodingMask,
compute.NullEncodingEncode} {
+ t.Run(fmt.Sprint(mode), func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ inputJSON := `["skip", "aaaa", null,
"bbbb", "aaaa", null, "skip"]`
+ if dt.ID() == arrow.BINARY || dt.ID()
== arrow.LARGE_BINARY || dt.ID() == arrow.FIXED_SIZE_BINARY {
+ inputJSON = `["c2tpcA==",
"YWFhYQ==", null, "YmJiYg==", "YWFhYQ==", null, "c2tpcA=="]`
+ }
+ base, _, err := array.FromJSON(mem, dt,
strings.NewReader(inputJSON))
+ require.NoError(t, err)
+ defer base.Release()
+ input := array.NewSlice(base, 1, 6)
+ defer input.Release()
+ execCtx := compute.DefaultExecCtx()
+ execCtx.ChunkSize = 2
+ ctx :=
compute.SetExecCtx(compute.WithAllocator(context.Background(), mem), execCtx)
+ result, err :=
compute.DictionaryEncodeArray(ctx,
compute.DictionaryEncodeOptions{NullEncoding: mode}, input)
+ require.NoError(t, err)
+ defer result.Release()
+ encoded := result.(*array.Dictionary)
+ require.True(t, arrow.TypeEqual(dt,
encoded.Dictionary().DataType()))
+ wantJSON := `["aaaa", "bbbb"]`
+ if mode == compute.NullEncodingEncode {
+ wantJSON = `["aaaa", null,
"bbbb"]`
+ }
+ if dt.ID() == arrow.BINARY || dt.ID()
== arrow.LARGE_BINARY || dt.ID() == arrow.FIXED_SIZE_BINARY {
+ wantJSON = `["YWFhYQ==",
"YmJiYg=="]`
+ if mode ==
compute.NullEncodingEncode {
+ wantJSON =
`["YWFhYQ==", null, "YmJiYg=="]`
+ }
+ }
+ wantDictionary, _, err :=
array.FromJSON(mem, dt, strings.NewReader(wantJSON))
+ require.NoError(t, err)
+ defer wantDictionary.Release()
+ require.True(t,
array.Equal(wantDictionary, encoded.Dictionary()))
+
+ require.Equal(t, 5, encoded.Len())
+ require.Equal(t,
encoded.GetValueIndex(0), encoded.GetValueIndex(3))
+ require.NotEqual(t,
encoded.GetValueIndex(0), encoded.GetValueIndex(2))
+ if mode == compute.NullEncodingMask {
+ require.Equal(t, 2,
encoded.Dictionary().Len())
+ require.Equal(t, 2,
encoded.NullN())
+ require.True(t,
encoded.IsNull(1))
+ require.True(t,
encoded.IsNull(4))
+ } else {
+ require.Equal(t, 3,
encoded.Dictionary().Len())
+ require.Zero(t, encoded.NullN())
+ require.Equal(t,
encoded.GetValueIndex(1), encoded.GetValueIndex(4))
+ require.True(t,
encoded.Dictionary().IsNull(encoded.GetValueIndex(1)))
+ }
+ })
+ }
+ })
+ }
+}