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 7c210e36 perf(parquet): decode nullable plain values into valid runs
(#1314)
7c210e36 is described below
commit 7c210e364d8b54ff1328acdd7c6edff2a46bd720
Author: Minh Vu <[email protected]>
AuthorDate: Tue Sep 15 21:14:27 2026 +0200
perf(parquet): decode nullable plain values into valid runs (#1314)
## What does this change?
- `PlainDecoder.DecodeSpaced` used to decode all non-null values into a
dense prefix first, then move them into their bitmap positions.
- For simple validity patterns, decode each valid run directly into its
final output range.
- Keep the existing dense plus spaced expansion path for highly
fragmented validity.
- Use the reverse bitmap reader so trailing-null pages stop after
finding the dense prefix.
- Add edge-case tests and a focused benchmark.
## Benchmark
Apple M1 Pro, Go 1.26.3, 65,536 `int32` values, 8 runs, 300ms per
sample:
| Validity pattern | Before | After |
| --- | ---: | ---: |
| leading null | 11.6 us/op | 7.1 us/op |
| clustered nulls | 8.6 us/op | 7.2 us/op |
| trailing null | 5.6 us/op | 5.6 us/op |
Random 10% nulls and alternating validity keep the existing fallback
path. All cases stayed at **0 B/op** and **0 allocs/op**.
## Tests
- `PARQUET_TEST_DATA=parquet-testing/data
ARROW_TEST_DATA=arrow-testing/data go test -p 2 ./... -count=1`
- `go test -race -p 2 ./parquet/internal/encoding ./parquet/file`
- `go vet -p 2 ./parquet/internal/encoding ./parquet/file`
---
.../plain_decoder_spaced_benchmark_test.go | 96 ++++++++++++++++++
.../internal/encoding/plain_decoder_spaced_test.go | 108 +++++++++++++++++++++
parquet/internal/encoding/plain_encoding_types.go | 83 +++++++++++++++-
3 files changed, 282 insertions(+), 5 deletions(-)
diff --git a/parquet/internal/encoding/plain_decoder_spaced_benchmark_test.go
b/parquet/internal/encoding/plain_decoder_spaced_benchmark_test.go
new file mode 100644
index 00000000..14b02627
--- /dev/null
+++ b/parquet/internal/encoding/plain_decoder_spaced_benchmark_test.go
@@ -0,0 +1,96 @@
+// 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"
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+)
+
+func BenchmarkPlainDecoderDecodeSpaced(b *testing.B) {
+ const size = 1 << 16
+
+ patterns := []struct {
+ name string
+ valid func(int) bool
+ }{
+ {name: "trailing_null", valid: func(i int) bool { return i !=
size-1 }},
+ {name: "leading_null", valid: func(i int) bool { return i != 0
}},
+ {name: "clustered_nulls", valid: func(i int) bool { return i <
size/2-32 || i >= size/2+32 }},
+ {name: "random_10pct_nulls", valid: randomValidity(size, 10)},
+ {name: "alternating", valid: func(i int) bool { return i%2 == 0
}},
+ }
+
+ for _, pattern := range patterns {
+ pattern := pattern
+ b.Run(fmt.Sprintf("int32/%s", pattern.name), func(b *testing.B)
{
+ data, validBits, nullCount :=
newPlainInt32SpacedInput(size, pattern.valid)
+ out := make([]int32, size)
+ dec := NewDecoder(parquet.Types.Int32,
parquet.Encodings.Plain, nil, memory.DefaultAllocator).(Int32Decoder)
+
+ b.ReportAllocs()
+ b.SetBytes(int64(size * arrow.Int32SizeBytes))
+ b.ResetTimer()
+ for b.Loop() {
+ if err := dec.SetData(size-nullCount, data);
err != nil {
+ b.Fatal(err)
+ }
+ n, err := dec.DecodeSpaced(out, nullCount,
validBits, 0)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if n != size {
+ b.Fatalf("expected %d values, got %d",
size, n)
+ }
+ }
+ })
+ }
+}
+
+func newPlainInt32SpacedInput(size int, valid func(int) bool) (data, validBits
[]byte, nullCount int) {
+ validBits = make([]byte, bitutil.BytesForBits(int64(size)))
+ values := make([]int32, 0, size)
+ for i := 0; i < size; i++ {
+ if valid(i) {
+ bitutil.SetBit(validBits, i)
+ values = append(values, int32(i))
+ } else {
+ nullCount++
+ }
+ }
+
+ enc := NewEncoder(parquet.Types.Int32, parquet.Encodings.Plain, false,
nil, memory.DefaultAllocator).(Int32Encoder)
+ enc.Put(values)
+ buf, _ := enc.FlushValues()
+ defer buf.Release()
+ return append([]byte(nil), buf.Bytes()...), validBits, nullCount
+}
+
+func randomValidity(size, nullPercent int) func(int) bool {
+ state := uint32(1)
+ valid := make([]bool, size)
+ for i := range valid {
+ state = state*1664525 + 1013904223
+ valid[i] = state%100 >= uint32(nullPercent)
+ }
+ return func(i int) bool { return valid[i] }
+}
diff --git a/parquet/internal/encoding/plain_decoder_spaced_test.go
b/parquet/internal/encoding/plain_decoder_spaced_test.go
new file mode 100644
index 00000000..bab202bb
--- /dev/null
+++ b/parquet/internal/encoding/plain_decoder_spaced_test.go
@@ -0,0 +1,108 @@
+// 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 (
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+ "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 TestPlainDecoderDecodeSpaced(t *testing.T) {
+ cases := []struct {
+ name string
+ valid []bool
+ offset int64
+ }{
+ {
+ name: "leading null",
+ valid: []bool{false, true, true, true, true, true},
+ },
+ {
+ name: "internal null",
+ valid: []bool{true, true, false, true, true, true},
+ },
+ {
+ name: "trailing null",
+ valid: []bool{true, true, true, true, true, false},
+ },
+ {
+ name: "clustered nulls",
+ valid: []bool{true, true, false, false, false, true,
true, true},
+ },
+ {
+ name: "fragmented validity",
+ valid: []bool{true, false, true, false, true, false,
true, false, true, false, true, false},
+ },
+ {
+ name: "highly fragmented validity",
+ valid: []bool{true, false, true, false, true, false,
true, false, true, false, true, false, true, false, true, false, true, false,
true, false},
+ },
+ {
+ name: "offset",
+ valid: []bool{false, true, true, false, true, true},
+ offset: 3,
+ },
+ {
+ name: "all null",
+ valid: []bool{false, false, false, false},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ validBits := make([]byte,
bitutil.BytesForBits(tc.offset+int64(len(tc.valid))))
+ physical := make([]int32, 0, len(tc.valid))
+ nullCount := 0
+ for i, valid := range tc.valid {
+ if valid {
+ bitutil.SetBit(validBits,
int(tc.offset)+i)
+ physical = append(physical, int32(i))
+ } else {
+ nullCount++
+ }
+ }
+
+ enc := NewEncoder(parquet.Types.Int32,
parquet.Encodings.Plain, false, nil, memory.DefaultAllocator).(Int32Encoder)
+ enc.Put(physical)
+ data, err := enc.FlushValues()
+ require.NoError(t, err)
+ defer data.Release()
+
+ dec := NewDecoder(parquet.Types.Int32,
parquet.Encodings.Plain, nil, memory.DefaultAllocator).(Int32Decoder)
+ require.NoError(t, dec.SetData(len(physical),
data.Bytes()))
+
+ out := make([]int32, len(tc.valid))
+ for i := range out {
+ out[i] = -1
+ }
+
+ n, err := dec.DecodeSpaced(out, nullCount, validBits,
tc.offset)
+ require.NoError(t, err)
+ assert.Equal(t, len(tc.valid), n)
+ for i, valid := range tc.valid {
+ if valid {
+ assert.Equal(t, int32(i), out[i])
+ }
+ }
+ })
+ }
+}
diff --git a/parquet/internal/encoding/plain_encoding_types.go
b/parquet/internal/encoding/plain_encoding_types.go
index f026e7ed..dd8119f5 100644
--- a/parquet/internal/encoding/plain_encoding_types.go
+++ b/parquet/internal/encoding/plain_encoding_types.go
@@ -96,14 +96,76 @@ func (dec *PlainDecoder[T]) Decode(out []T) (int, error) {
}
func (dec *PlainDecoder[T]) DecodeSpaced(out []T, nullCount int, validBits
[]byte, validBitsOffset int64) (int, error) {
+ if nullCount == 0 {
+ if err := dec.decodeDense(out, len(out)); err != nil {
+ return 0, err
+ }
+ return len(out), nil
+ }
+
toread := len(out) - nullCount
- values, err := dec.Decode(out[:toread])
- if err != nil {
- return 0, err
+ if toread == 0 {
+ return len(out), nil
}
- if values != toread {
- return 0, errors.New("parquet: number of values / definition
levels read did not match")
+ if dec.bitSetReader == nil {
+ dec.bitSetReader =
bitutils.NewReverseSetBitRunReader(validBits, validBitsOffset, int64(len(out)))
+ } else {
+ dec.bitSetReader.Reset(validBits, validBitsOffset,
int64(len(out)))
+ }
+
+ const maxDirectDecodeRuns = 8
+ var validRuns [maxDirectDecodeRuns]bitutils.SetBitRun
+ runCount := 0
+ decodedPos := int64(0)
+ needsExpansion := false
+ for {
+ run := dec.bitSetReader.NextRun()
+ if run.Length == 0 {
+ break
+ }
+ if runCount == len(validRuns) {
+ return dec.decodeSpacedDense(out, nullCount, validBits,
validBitsOffset)
+ }
+ validRuns[runCount] = run
+ runCount++
+ densePos := int64(toread) - decodedPos - run.Length
+ if run.Pos != densePos {
+ needsExpansion = true
+ }
+ decodedPos += run.Length
+ if decodedPos >= int64(toread) {
+ break
+ }
+ }
+
+ if decodedPos != int64(toread) {
+ return dec.decodeSpacedDense(out, nullCount, validBits,
validBitsOffset)
+ }
+ if !needsExpansion {
+ if err := dec.decodeDense(out, toread); err != nil {
+ return 0, err
+ }
+ return len(out), nil
+ }
+
+ for i := runCount - 1; i >= 0; i-- {
+ run := validRuns[i]
+ values, err :=
dec.Decode(out[int(run.Pos):int(run.Pos+run.Length)])
+ if err != nil {
+ return 0, err
+ }
+ if values != int(run.Length) {
+ return 0, errors.New("parquet: number of values /
definition levels read did not match")
+ }
+ }
+ return len(out), nil
+}
+
+func (dec *PlainDecoder[T]) decodeSpacedDense(out []T, nullCount int,
validBits []byte, validBitsOffset int64) (int, error) {
+ toread := len(out) - nullCount
+ if err := dec.decodeDense(out, toread); err != nil {
+ return 0, err
}
nvalues := len(out)
@@ -130,6 +192,17 @@ func (dec *PlainDecoder[T]) DecodeSpaced(out []T,
nullCount int, validBits []byt
return nvalues, nil
}
+func (dec *PlainDecoder[T]) decodeDense(out []T, toread int) error {
+ values, err := dec.Decode(out[:toread])
+ if err != nil {
+ return err
+ }
+ if values != toread {
+ return errors.New("parquet: number of values / definition
levels read did not match")
+ }
+ return nil
+}
+
type (
PlainInt32Encoder = PlainEncoder[int32]
PlainInt32Decoder = PlainDecoder[int32]