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 f5331867 perf(parquet/internal/encoding): fast-path constant delta
bitpacked blocks (#1396)
f5331867 is described below
commit f53318674137ad9b0f7701a28ae8d00bccca20f4
Author: Minh Vu <[email protected]>
AuthorDate: Thu Oct 22 19:49:41 2026 +0200
perf(parquet/internal/encoding): fast-path constant delta bitpacked blocks
(#1396)
### What
Speed up DELTA_BINARY_PACKED encoding for constant and sparse deltas.
### Why
Zero-width miniblocks produce no payload, but the encoder still does
per-value work.
### Implementation
- Encode uniform blocks without a payload.
- Skip writes for zero-width miniblocks in mixed blocks.
### Benchmark
65,536 values, Go 1.26.3, Apple M1 Pro, median of 5 runs.
| Metric | Before | After | Change |
|---|---:|---:|---:|
| int32 constant | 416.6 µs/op | 197.8 µs/op | 2.1x faster |
| int32 sparse | 420.2 µs/op | 312.1 µs/op | 1.35x faster |
| int64 constant | 411.5 µs/op | 193.1 µs/op | 2.1x faster |
| int64 sparse | 420.7 µs/op | 314.7 µs/op | 1.34x faster |
---------
Co-authored-by: Matt Topol <[email protected]>
---
parquet/internal/encoding/delta_bit_packing.go | 41 +++--
.../encoding/delta_bit_packing_block_bench_test.go | 81 ++++++++++
.../encoding/delta_bit_packing_fast_path_test.go | 174 +++++++++++++++++++++
3 files changed, 285 insertions(+), 11 deletions(-)
diff --git a/parquet/internal/encoding/delta_bit_packing.go
b/parquet/internal/encoding/delta_bit_packing.go
index dba6f6f3..884b115f 100644
--- a/parquet/internal/encoding/delta_bit_packing.go
+++ b/parquet/internal/encoding/delta_bit_packing.go
@@ -382,12 +382,28 @@ func (enc *deltaBitPackEncoder[T]) flushBlock() {
return
}
- // determine the minimum delta value
- minDelta := int64(math.MaxInt64)
- for _, delta := range enc.deltas {
+ // Find the minimum delta and check whether the block is constant in one
+ // pass. A separate equality scan hurts mixed blocks with a late
outlier.
+ firstDelta := enc.deltas[0]
+ minDelta := firstDelta
+ allSame := true
+ for _, delta := range enc.deltas[1:] {
if delta < minDelta {
minDelta = delta
}
+ if delta != firstDelta {
+ allSame = false
+ }
+ }
+ if allSame {
+ // Identical deltas need only the minimum and zero widths, no
payload.
+ enc.bitWriter.WriteZigZagVlqInt(firstDelta)
+ offset, _ := enc.bitWriter.SkipBytes(int(enc.numMiniBlocks))
+ // Explicitly clear the reserved widths for a reused buffer.
+ var widths [defaultNumMiniBlocks]byte
+ enc.bitWriter.WriteAt(widths[:], int64(offset))
+ enc.deltas = enc.deltas[:0]
+ return
}
enc.bitWriter.WriteZigZagVlqInt(minDelta)
@@ -412,17 +428,20 @@ func (enc *deltaBitPackEncoder[T]) flushBlock() {
// write out the bit width we used into the bytes we reserved
earlier
enc.bitWriter.WriteAt([]byte{byte(width)}, int64(offset+i))
- // write out our deltas
- for _, val := range enc.deltas[start : start+int(n)] {
- enc.bitWriter.WriteValue(uint64(val-minDelta), width)
+ // A zero-width miniblock has no packed payload. Avoid per-value
+ // writes for these miniblocks even if the full block is not
constant.
+ if width != 0 {
+ for _, val := range enc.deltas[start : start+int(n)] {
+ enc.bitWriter.WriteValue(uint64(val-minDelta),
width)
+ }
+
+ // pad the last block if n < miniBlockSize
+ for pad := n; pad < int64(enc.miniBlockSize); pad++ {
+ enc.bitWriter.WriteValue(0, width)
+ }
}
valuesToWrite -= n
-
- // pad the last block if n < miniBlockSize
- for ; n < int64(enc.miniBlockSize); n++ {
- enc.bitWriter.WriteValue(0, width)
- }
}
enc.deltas = enc.deltas[:0]
}
diff --git a/parquet/internal/encoding/delta_bit_packing_block_bench_test.go
b/parquet/internal/encoding/delta_bit_packing_block_bench_test.go
new file mode 100644
index 00000000..47ee2ba2
--- /dev/null
+++ b/parquet/internal/encoding/delta_bit_packing_block_bench_test.go
@@ -0,0 +1,81 @@
+// 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 (
+ "math/rand"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+)
+
+func benchmarkDeltaBitPackBlockFastPath[T int32 | int64](b *testing.B, typ
parquet.Type, bytesPerValue int) {
+ const length = 64 * 1024
+
+ for _, pattern := range []string{"constant", "sparse", "last-outlier",
"alternating", "random"} {
+ b.Run(pattern, func(b *testing.B) {
+ values := make([]T, length)
+ rng := rand.New(rand.NewSource(42))
+ for i := 1; i < length; i++ {
+ switch pattern {
+ case "constant":
+ values[i] = values[i-1] + T(3)
+ case "sparse":
+ step := T(3)
+ if i%128 == 90 {
+ step = 7
+ }
+ values[i] = values[i-1] + step
+ case "last-outlier":
+ step := T(3)
+ // Delta indices 128, 256, ... end each
full block.
+ if i%128 == 0 {
+ step = -7
+ }
+ values[i] = values[i-1] + step
+ case "alternating":
+ values[i] = values[i-1] + T(i%2+1)
+ case "random":
+ values[i] = T(rng.Uint64())
+ }
+ }
+
+ enc := NewEncoder(typ,
parquet.Encodings.DeltaBinaryPacked, false, nil,
memory.DefaultAllocator).(*deltaBitPackEncoder[T])
+ defer enc.Release()
+
+ b.SetBytes(int64(length * bytesPerValue))
+ b.ReportAllocs()
+ for b.Loop() {
+ enc.Put(values)
+ buf, err := enc.FlushValues()
+ if err != nil {
+ b.Fatal(err)
+ }
+ buf.Release()
+ }
+ })
+ }
+}
+
+func BenchmarkDeltaBitPackBlockFastPathInt32(b *testing.B) {
+ benchmarkDeltaBitPackBlockFastPath[int32](b, parquet.Types.Int32, 4)
+}
+
+func BenchmarkDeltaBitPackBlockFastPathInt64(b *testing.B) {
+ benchmarkDeltaBitPackBlockFastPath[int64](b, parquet.Types.Int64, 8)
+}
diff --git a/parquet/internal/encoding/delta_bit_packing_fast_path_test.go
b/parquet/internal/encoding/delta_bit_packing_fast_path_test.go
new file mode 100644
index 00000000..e3da8d34
--- /dev/null
+++ b/parquet/internal/encoding/delta_bit_packing_fast_path_test.go
@@ -0,0 +1,174 @@
+// 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"
+ "math"
+ "math/rand"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeltaBitPackConstantBlockWireFormat(t *testing.T) {
+ t.Run("full positive block", func(t *testing.T) {
+ values := make([]int32, 129)
+ for i := range values {
+ values[i] = 10 + int32(i)*2
+ }
+
+ enc := NewEncoder(parquet.Types.Int32,
parquet.Encodings.DeltaBinaryPacked, false, nil,
memory.DefaultAllocator).(*deltaBitPackEncoder[int32])
+ defer enc.Release()
+ enc.Put(values)
+
+ buf, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer buf.Release()
+
+ // Block size 128, 4 miniblocks, 129 values, first value 10,
+ // minimum delta 2, then four zero bit widths and no payload.
+ require.Equal(t, []byte{0x80, 0x01, 0x04, 0x81, 0x01, 0x14,
0x04, 0, 0, 0, 0}, buf.Bytes())
+ })
+
+ t.Run("partial negative block", func(t *testing.T) {
+ values := []int64{5, 4, 3, 2, 1, 0, -1}
+ enc := NewEncoder(parquet.Types.Int64,
parquet.Encodings.DeltaBinaryPacked, false, nil,
memory.DefaultAllocator).(*deltaBitPackEncoder[int64])
+ defer enc.Release()
+ enc.Put(values)
+
+ buf, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer buf.Release()
+
+ // The trailing miniblocks are also written as zero-width.
+ require.Equal(t, []byte{0x80, 0x01, 0x04, 0x07, 0x0a, 0x01, 0,
0, 0, 0}, buf.Bytes())
+ })
+}
+
+func testDeltaBitPackBlockRoundTrip[T int32 | int64](t *testing.T, typ
parquet.Type) {
+ t.Helper()
+
+ encode := func(parts ...[]T) []byte {
+ t.Helper()
+ enc := NewEncoder(typ, parquet.Encodings.DeltaBinaryPacked,
false, nil, memory.DefaultAllocator).(*deltaBitPackEncoder[T])
+ defer enc.Release()
+ for _, part := range parts {
+ enc.Put(part)
+ }
+ buf, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer buf.Release()
+ return append([]byte(nil), buf.Bytes()...)
+ }
+
+ verify := func(t *testing.T, values []T) {
+ t.Helper()
+ n := len(values)
+ wire := encode(values)
+ split := max(1, n/2)
+ require.Equal(t, wire, encode(values[:1], values[1:split],
values[split:]))
+
+ dec := NewDecoder(typ, parquet.Encodings.DeltaBinaryPacked,
nil, memory.DefaultAllocator).(*deltaBitPackDecoder[T])
+ require.NoError(t, dec.SetData(n, wire))
+ out := make([]T, n)
+ got, err := dec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, n, got)
+ require.Equal(t, values, out)
+ }
+
+ for _, n := range []int{1, 2, 31, 32, 33, 127, 128, 129, 130, 255, 257,
1001} {
+ for _, pattern := range []string{"constant", "zero", "sparse",
"alternating", "random", "overflow"} {
+ t.Run(fmt.Sprintf("%s/%d", pattern, n), func(t
*testing.T) {
+ values := make([]T, n)
+ rng := rand.New(rand.NewSource(int64(n)))
+ switch pattern {
+ case "overflow":
+ raw := int64(math.MaxInt32 - 2)
+ if typ == parquet.Types.Int64 {
+ raw = int64(math.MaxInt64 - 2)
+ }
+ values[0] = T(raw)
+ default:
+ values[0] = T(11)
+ }
+
+ for i := 1; i < n; i++ {
+ switch pattern {
+ case "constant":
+ values[i] = values[i-1] + T(3)
+ case "zero":
+ values[i] = values[i-1]
+ case "sparse":
+ step := T(3)
+ if i%128 == 90 {
+ step = 7
+ }
+ values[i] = values[i-1] + step
+ case "alternating":
+ values[i] = values[i-1] +
T(i%2+1)
+ case "random":
+ values[i] = T(rng.Uint64())
+ case "overflow":
+ values[i] = values[i-1] + T(7)
+ }
+ }
+
+ verify(t, values)
+ })
+ }
+ }
+ // The last delta in a block must be included in constant-block
detection.
+ // The 3-value higher case is [0, 3, 10]; 129 values fill one delta
block.
+ for _, n := range []int{3, 129, 130, 257} {
+ for _, outlier := range []struct {
+ name string
+ step T
+ }{
+ {name: "higher", step: 7},
+ {name: "lower", step: -7},
+ } {
+ t.Run(fmt.Sprintf("last-delta-%s/%d", outlier.name, n),
func(t *testing.T) {
+ values := make([]T, n)
+ outlierIndex := n - 1
+ if n == 130 {
+ // The outlier ends the first block,
with another delta after it.
+ outlierIndex = 128
+ }
+ for i := 1; i < n; i++ {
+ step := T(3)
+ if i == outlierIndex {
+ step = outlier.step
+ }
+ values[i] = values[i-1] + step
+ }
+ verify(t, values)
+ })
+ }
+ }
+}
+
+func TestDeltaBitPackConstantAndMixedBlocksInt32(t *testing.T) {
+ testDeltaBitPackBlockRoundTrip[int32](t, parquet.Types.Int32)
+}
+
+func TestDeltaBitPackConstantAndMixedBlocksInt64(t *testing.T) {
+ testDeltaBitPackBlockRoundTrip[int64](t, parquet.Types.Int64)
+}