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 a144c951 perf(parquet): batch DELTA_BYTE_ARRAY encoding (#1179)
a144c951 is described below
commit a144c95147b38ce543d0b5e7fc4489671176995d
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 28 22:30:18 2026 +0200
perf(parquet): batch DELTA_BYTE_ARRAY encoding (#1179)
## Summary
- Batch DELTA_BYTE_ARRAY prefix lengths and suffixes in chunks of 256
values.
- Reuse fixed scratch space for DELTA_LENGTH_BYTE_ARRAY lengths.
- Preserve the previous value across batch boundaries and separate Put
calls.
- Add a boundary round-trip test and representative benchmarks.
## Why
DELTA_BYTE_ARRAY was calling the prefix and suffix encoders once per
value. Large pages therefore paid for many small calls and temporary
length slices.
Apache Arrow C++ already uses fixed-size batching for this encoder. This
change follows the same shape while keeping the existing Go encoding
format and last-value behavior.
## Correctness
- The encoded layout is unchanged: prefix lengths first, followed by the
suffix stream.
- The first value still has a zero prefix.
- Empty suffixes still work as before.
- The new test compares one large Put call with Put calls split around a
batch boundary, then decodes and compares every value.
## Benchmark
64K values on an Apple M1 Pro, 1 second per sample, 3 samples:
| Case | Before | After | Change |
| --- | ---: | ---: | ---: |
| prefix-heavy | 2.09 ms/op | 1.75 ms/op | ~16% faster |
| low-prefix | 1.68 ms/op | 1.47 ms/op | ~12% faster |
Allocation counts stayed the same in both cases.
## Checks
- `PARQUET_TEST_DATA=<parquet-testing-data> go test ./parquet/...`
- `go test -race ./parquet/internal/encoding -count=1`
- `git diff --check`
No public API changes.
---
parquet/internal/encoding/delta_byte_array.go | 56 ++++++++-------
.../encoding/delta_byte_array_benchmark_test.go | 78 +++++++++++++++++++++
parquet/internal/encoding/delta_byte_array_test.go | 46 +++++++++++++
.../encoding/delta_byte_array_validation_test.go | 23 +++++++
.../internal/encoding/delta_length_byte_array.go | 18 ++---
.../encoding/delta_length_byte_array_test.go | 80 ++++++++++++++++++++++
6 files changed, 268 insertions(+), 33 deletions(-)
diff --git a/parquet/internal/encoding/delta_byte_array.go
b/parquet/internal/encoding/delta_byte_array.go
index 948de218..86b7d585 100644
--- a/parquet/internal/encoding/delta_byte_array.go
+++ b/parquet/internal/encoding/delta_byte_array.go
@@ -38,9 +38,24 @@ type DeltaByteArrayEncoder struct {
prefixEncoder *DeltaBitPackInt32Encoder
suffixEncoder *DeltaLengthByteArrayEncoder
+ prefixLengths [deltaByteArrayBatchSize]int32
+ suffixes [deltaByteArrayBatchSize]parquet.ByteArray
+
lastVal parquet.ByteArray
}
+const deltaByteArrayBatchSize = 256
+
+func commonPrefixLength(left, right parquet.ByteArray) int {
+ maximum := min(left.Len(), right.Len())
+ for i := 0; i < maximum; i++ {
+ if left[i] != right[i] {
+ return i
+ }
+ }
+ return maximum
+}
+
func (enc *DeltaByteArrayEncoder) EstimatedDataEncodedSize() int64 {
prefixEstimatedSize := int64(0)
if enc.prefixEncoder != nil {
@@ -58,15 +73,15 @@ func (enc *DeltaByteArrayEncoder) initEncoders() {
encoder: newEncoderBase(enc.encoding, nil, enc.mem),
}
enc.suffixEncoder = &DeltaLengthByteArrayEncoder{
- newEncoderBase(enc.encoding, nil, enc.mem),
- &DeltaBitPackInt32Encoder{
+ encoder: newEncoderBase(enc.encoding, nil, enc.mem),
+ lengthEncoder: &DeltaBitPackInt32Encoder{
encoder: newEncoderBase(enc.encoding, nil, enc.mem),
},
}
}
// Type returns the underlying physical type this operates on, in this case
ByteArrays only
-func (DeltaByteArrayEncoder) Type() parquet.Type { return
parquet.Types.ByteArray }
+func (*DeltaByteArrayEncoder) Type() parquet.Type { return
parquet.Types.ByteArray }
// Put writes a slice of ByteArrays to the encoder
func (enc *DeltaByteArrayEncoder) Put(in []parquet.ByteArray) {
@@ -74,39 +89,29 @@ func (enc *DeltaByteArrayEncoder) Put(in
[]parquet.ByteArray) {
return
}
- var suf parquet.ByteArray
if enc.prefixEncoder == nil { // initialize our encoders if we haven't
yet
enc.initEncoders()
- enc.prefixEncoder.Put([]int32{0})
- suf = in[0]
- enc.lastVal = in[0]
- enc.suffixEncoder.Put([]parquet.ByteArray{suf})
- in = in[1:]
}
- // for each value, figure out the common prefix with the previous value
- // and then write the prefix length and the suffix.
- for _, val := range in {
- l1 := enc.lastVal.Len()
- l2 := val.Len()
- j := 0
- for j < l1 && j < l2 {
- if enc.lastVal[j] != val[j] {
- break
- }
- j++
+ lastVal := enc.lastVal
+ for offset := 0; offset < len(in); offset += deltaByteArrayBatchSize {
+ batchSize := min(deltaByteArrayBatchSize, len(in)-offset)
+ for i, val := range in[offset : offset+batchSize] {
+ prefixLength := commonPrefixLength(lastVal, val)
+ lastVal = val
+ enc.prefixLengths[i] = int32(prefixLength)
+ enc.suffixes[i] = val[prefixLength:]
}
- enc.prefixEncoder.Put([]int32{int32(j)})
- suf = val[j:]
- enc.suffixEncoder.Put([]parquet.ByteArray{suf})
- enc.lastVal = val
+ enc.suffixEncoder.Put(enc.suffixes[:batchSize])
+ enc.prefixEncoder.Put(enc.prefixLengths[:batchSize])
+ clear(enc.suffixes[:batchSize])
}
// do the memcpy after the loops to keep a copy of the lastVal
// we do a copy here so that we only copy and keep a reference
// to the suffix, and aren't forcing the *entire* value to stay
// in memory while we have this reference to just the suffix.
- enc.lastVal = append([]byte{}, enc.lastVal...)
+ enc.lastVal = append(enc.lastVal[:0], lastVal...)
}
// PutSpaced is like Put, but assumes the data is already spaced for nulls and
uses the bitmap provided and offset
@@ -143,6 +148,7 @@ func (enc *DeltaByteArrayEncoder) FlushValues() (Buffer,
error) {
ret.ResizeNoShrink(prefixBuf.Len() + suffixBuf.Len())
copy(ret.Bytes(), prefixBuf.Bytes())
copy(ret.Bytes()[prefixBuf.Len():], suffixBuf.Bytes())
+ enc.lastVal = nil
return poolBuffer{ret}, nil
}
diff --git a/parquet/internal/encoding/delta_byte_array_benchmark_test.go
b/parquet/internal/encoding/delta_byte_array_benchmark_test.go
new file mode 100644
index 00000000..37c677b6
--- /dev/null
+++ b/parquet/internal/encoding/delta_byte_array_benchmark_test.go
@@ -0,0 +1,78 @@
+// 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 encoding
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+)
+
+func BenchmarkDeltaByteArrayEncoding(b *testing.B) {
+ for _, test := range []struct {
+ name string
+ value func(int) []byte
+ }{
+ {
+ name: "prefix-heavy",
+ value: func(i int) []byte {
+ return []byte(fmt.Sprintf("partition/%06d", i))
+ },
+ },
+ {
+ name: "low-prefix",
+ value: func(i int) []byte {
+ return []byte(fmt.Sprintf("%c/%06d",
byte(i%251), i))
+ },
+ },
+ } {
+ b.Run(test.name, func(b *testing.B) {
+ const nvalues = 64 * 1024
+ values := make([]parquet.ByteArray, nvalues)
+ var inputBytes int64
+ for i := range values {
+ values[i] = test.value(i)
+ inputBytes += int64(values[i].Len())
+ }
+
+ for _, putSize := range []int{1, 8, 32,
deltaByteArrayBatchSize, nvalues} {
+ putSize := putSize
+ b.Run(fmt.Sprintf("put-%d", putSize), func(b
*testing.B) {
+ b.SetBytes(inputBytes)
+ b.ReportAllocs()
+ for b.Loop() {
+ enc :=
NewEncoder(parquet.Types.ByteArray, parquet.Encodings.DeltaByteArray,
+ false, nil,
memory.DefaultAllocator).(ByteArrayEncoder)
+ for offset := 0; offset <
len(values); offset += putSize {
+ end :=
min(offset+putSize, len(values))
+
enc.Put(values[offset:end])
+ }
+ buf, err := enc.FlushValues()
+ if err != nil {
+ enc.Release()
+ b.Fatal(err)
+ }
+ buf.Release()
+ enc.Release()
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/parquet/internal/encoding/delta_byte_array_test.go
b/parquet/internal/encoding/delta_byte_array_test.go
index e3eefe06..8f918a8e 100644
--- a/parquet/internal/encoding/delta_byte_array_test.go
+++ b/parquet/internal/encoding/delta_byte_array_test.go
@@ -23,6 +23,7 @@ import (
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestDeltaByteArrayDecoder_SetData(t *testing.T) {
@@ -46,3 +47,48 @@ func TestDeltaByteArrayDecoder_SetData(t *testing.T) {
})
}
}
+
+func TestDeltaByteArrayEncoderPreservesLastValueAcrossBatches(t *testing.T) {
+ values := make([]parquet.ByteArray, deltaByteArrayBatchSize*2+3)
+ for i := range values {
+ values[i] =
parquet.ByteArray(fmt.Sprintf("partition-%03d/value-%03d", i/7, i%7))
+ }
+ values[0] = parquet.ByteArray{}
+ values[deltaByteArrayBatchSize-1] =
parquet.ByteArray("boundary/repeated")
+ values[deltaByteArrayBatchSize] = parquet.ByteArray("boundary/repeated")
+ values[deltaByteArrayBatchSize+1] = parquet.ByteArray("boundary")
+ values[deltaByteArrayBatchSize*2] = parquet.ByteArray("boundary")
+
+ encode := func(batches ...[]parquet.ByteArray) []byte {
+ t.Helper()
+ enc := NewEncoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaByteArray, false, nil,
memory.DefaultAllocator).(ByteArrayEncoder)
+ defer enc.Release()
+ for _, batch := range batches {
+ enc.Put(batch)
+ }
+ buf, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer buf.Release()
+ return append([]byte(nil), buf.Bytes()...)
+ }
+
+ want := encode(values)
+ for _, split := range []int{0, 1, deltaByteArrayBatchSize - 1,
deltaByteArrayBatchSize,
+ deltaByteArrayBatchSize + 1, deltaByteArrayBatchSize*2 - 1,
deltaByteArrayBatchSize * 2,
+ len(values) - 1, len(values)} {
+ t.Run(fmt.Sprintf("split-%d", split), func(t *testing.T) {
+ got := encode(values[:split], values[split:])
+ require.Equal(t, want, got)
+
+ dec := NewDecoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaByteArray, nil,
memory.DefaultAllocator).(ByteArrayDecoder)
+ require.NoError(t, dec.SetData(len(values), got))
+ out := make([]parquet.ByteArray, len(values))
+ decoded, err := dec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(values), decoded)
+ for i := range values {
+ assert.Equal(t, string(values[i]),
string(out[i]), "value %d", i)
+ }
+ })
+ }
+}
diff --git a/parquet/internal/encoding/delta_byte_array_validation_test.go
b/parquet/internal/encoding/delta_byte_array_validation_test.go
index 0a584835..f4794978 100644
--- a/parquet/internal/encoding/delta_byte_array_validation_test.go
+++ b/parquet/internal/encoding/delta_byte_array_validation_test.go
@@ -64,6 +64,29 @@ func TestDeltaByteArrayDecoderResetsBetweenPages(t
*testing.T) {
require.Error(t, err)
}
+func TestDeltaByteArrayEncoderResetsBetweenPages(t *testing.T) {
+ enc := NewEncoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaByteArray, false, nil,
memory.DefaultAllocator).(ByteArrayEncoder)
+ defer enc.Release()
+
+ enc.Put([]parquet.ByteArray{parquet.ByteArray("prefix/old")})
+ first, err := enc.FlushValues()
+ require.NoError(t, err)
+ first.Release()
+
+ enc.Put([]parquet.ByteArray{parquet.ByteArray("prefix/new")})
+ second, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer second.Release()
+
+ dec := NewDecoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaByteArray, nil,
memory.DefaultAllocator).(ByteArrayDecoder)
+ require.NoError(t, dec.SetData(1, second.Bytes()))
+ out := make([]parquet.ByteArray, 1)
+ decoded, err := dec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, 1, decoded)
+ require.Equal(t, parquet.ByteArray("prefix/new"), out[0])
+}
+
func TestDeltaByteArrayDecoderRejectsInvalidPrefixes(t *testing.T) {
tests := []struct {
name string
diff --git a/parquet/internal/encoding/delta_length_byte_array.go
b/parquet/internal/encoding/delta_length_byte_array.go
index ffc8096a..30ce53ff 100644
--- a/parquet/internal/encoding/delta_length_byte_array.go
+++ b/parquet/internal/encoding/delta_length_byte_array.go
@@ -38,18 +38,20 @@ type DeltaLengthByteArrayEncoder struct {
encoder
lengthEncoder *DeltaBitPackInt32Encoder
+ lengths [deltaByteArrayBatchSize]int32
}
// Put writes the provided slice of byte arrays to the encoder
func (enc *DeltaLengthByteArrayEncoder) Put(in []parquet.ByteArray) {
- lengths := make([]int32, len(in))
- totalLen := int(0)
- for idx, val := range in {
- lengths[idx] = int32(val.Len())
- totalLen += val.Len()
+ totalLen := 0
+ for i := 0; i < len(in); i += deltaByteArrayBatchSize {
+ batchSize := min(deltaByteArrayBatchSize, len(in)-i)
+ for j, val := range in[i : i+batchSize] {
+ enc.lengths[j] = int32(val.Len())
+ totalLen += val.Len()
+ }
+ enc.lengthEncoder.Put(enc.lengths[:batchSize])
}
-
- enc.lengthEncoder.Put(lengths)
enc.sink.Reserve(totalLen)
for _, val := range in {
enc.sink.UnsafeWrite(val)
@@ -69,7 +71,7 @@ func (enc *DeltaLengthByteArrayEncoder) PutSpaced(in
[]parquet.ByteArray, validB
}
// Type returns the underlying type which is handled by this encoder,
ByteArrays only.
-func (DeltaLengthByteArrayEncoder) Type() parquet.Type {
+func (*DeltaLengthByteArrayEncoder) Type() parquet.Type {
return parquet.Types.ByteArray
}
diff --git a/parquet/internal/encoding/delta_length_byte_array_test.go
b/parquet/internal/encoding/delta_length_byte_array_test.go
new file mode 100644
index 00000000..7ed52d8a
--- /dev/null
+++ b/parquet/internal/encoding/delta_length_byte_array_test.go
@@ -0,0 +1,80 @@
+// 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 encoding
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeltaLengthByteArrayEncoderPreservesBatches(t *testing.T) {
+ for _, nvalues := range []int{255, 256, 257, 511, 512, 513} {
+ nvalues := nvalues
+ t.Run(fmt.Sprintf("%d-values", nvalues), func(t *testing.T) {
+ values := make([]parquet.ByteArray, nvalues)
+ for i := range values {
+ values[i] =
parquet.ByteArray(fmt.Sprintf("value-%d", i))
+ }
+
+ encode := func(batches ...[]parquet.ByteArray) []byte {
+ t.Helper()
+ enc := NewEncoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaLengthByteArray,
+ false, nil,
memory.DefaultAllocator).(ByteArrayEncoder)
+ defer enc.Release()
+ for _, batch := range batches {
+ enc.Put(batch)
+ }
+ buf, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer buf.Release()
+ return append([]byte(nil), buf.Bytes()...)
+ }
+
+ roundTrip := func(data []byte) {
+ t.Helper()
+ dec := NewDecoder(parquet.Types.ByteArray,
parquet.Encodings.DeltaLengthByteArray,
+ nil,
memory.DefaultAllocator).(ByteArrayDecoder)
+ require.NoError(t, dec.SetData(nvalues, data))
+ out := make([]parquet.ByteArray, nvalues)
+ decoded, err := dec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, nvalues, decoded)
+ require.Equal(t, values, out)
+ }
+
+ want := encode(values)
+ splits := []int{1, nvalues / 2, nvalues - 1}
+ for _, boundary := range []int{deltaByteArrayBatchSize
- 1, deltaByteArrayBatchSize, deltaByteArrayBatchSize + 1} {
+ if boundary > 0 && boundary < nvalues {
+ splits = append(splits, boundary)
+ }
+ }
+ for _, split := range splits {
+ split := split
+ t.Run(fmt.Sprintf("split-%d", split), func(t
*testing.T) {
+ got := encode(values[:split],
values[split:])
+ require.Equal(t, want, got)
+ roundTrip(got)
+ })
+ }
+ })
+ }
+}