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 68c555c9 perf(parquet): decode narrow packed levels directly (#1404)
68c555c9 is described below
commit 68c555c95fa5884eed9c4dce29152ec8bbc6ff7b
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 26 17:13:16 2026 +0100
perf(parquet): decode narrow packed levels directly (#1404)
## What
Decode 1-bit and 2-bit Parquet levels directly into `int16` output.
## Why
The current path unpacks values into `uint32` scratch space before
converting them to `int16`.
## Implementation
- Expand narrow packed bytes directly into the output.
- Keep the existing path for wider levels and trailing values.
- Add parity coverage for slices, truncated data, and sequential reads.
## Benchmark
Paired benchmark of the copied previous `GetBatchLevels` implementation
and the actual updated `BitReader.GetBatchLevels` method. Go 1.26.3,
Apple M1 Pro (`darwin/arm64`), `GOMAXPROCS=1`; 10 runs of 150 ms per
case.
| Metric | Before | After | Change |
| --- | ---: | ---: | ---: |
| 1-bit, mostly defined, 1,024 levels | 742.9 ns/op | 324.6 ns/op |
-56.32% |
| 1-bit, mostly defined, 65,536 levels | 43.84 µs/op | 17.50 µs/op |
-60.09% |
| 1-bit, alternating, 1,024 levels | 740.1 ns/op | 330.5 ns/op | -55.34%
|
| 1-bit, alternating, 65,536 levels | 44.45 µs/op | 17.43 µs/op |
-60.80% |
| 1-bit, varied, 1,024 levels | 744.3 ns/op | 325.4 ns/op | -56.28% |
| 1-bit, varied, 65,536 levels | 43.98 µs/op | 17.75 µs/op | -59.64% |
| 2-bit, mostly defined, 1,024 levels | 753.7 ns/op | 572.3 ns/op |
-24.07% |
| 2-bit, mostly defined, 65,536 levels | 45.10 µs/op | 30.10 µs/op |
-33.25% |
| 2-bit, alternating, 1,024 levels | 753.9 ns/op | 521.0 ns/op | -30.90%
|
| 2-bit, alternating, 65,536 levels | 45.32 µs/op | 29.97 µs/op |
-33.85% |
| 2-bit, varied, 1,024 levels | 756.7 ns/op | 525.6 ns/op | -30.54% |
| 2-bit, varied, 65,536 levels | 44.88 µs/op | 31.11 µs/op | -30.69% |
| Geometric mean, 12 cases | 5.777 µs/op | 3.114 µs/op | -46.10% |
All cases improved. Both paths use 48 B/op and 1 alloc/op. Command:
`GOMAXPROCS=1 go test ./parquet/internal/utils -run '^$' -bench
'^BenchmarkPackedLevelDecode$' -benchmem -benchtime=150ms -count=10
-cpu=1`.
---
parquet/internal/utils/bit_reader.go | 100 +++++--
.../utils/bit_reader_narrow_levels_test.go | 290 +++++++++++++++++++++
2 files changed, 375 insertions(+), 15 deletions(-)
diff --git a/parquet/internal/utils/bit_reader.go
b/parquet/internal/utils/bit_reader.go
index 85f012e2..278c9940 100644
--- a/parquet/internal/utils/bit_reader.go
+++ b/parquet/internal/utils/bit_reader.go
@@ -21,6 +21,7 @@ import (
"errors"
"io"
"math"
+ mathbits "math/bits"
"unsafe"
"github.com/apache/arrow-go/v18/arrow"
@@ -619,24 +620,35 @@ func (b *BitReader) GetBatchLevels(bits uint, out
[]int16, maxLevel int16) (int,
if _, err := b.reader.Seek(b.byteoffset, io.SeekStart); err != nil {
return i, maxCount, err
}
- for i < length {
- unpackSize := utils.Min(buflen, length-i)
- numUnpacked, err := unpack32(b.reader,
b.unpackBuf[:unpackSize], int(bits))
-
- for k, val := range b.unpackBuf[:numUnpacked] {
- level := int16(val)
- out[i+k] = level
- if level == maxLevel {
- maxCount++
- }
- }
- i += numUnpacked
- b.byteoffset += int64(numUnpacked * int(bits) / 8)
+ if bits == 1 {
+ // Narrow Parquet levels can be expanded directly from packed
bytes.
+ // Avoid the intermediate uint32 unpack buffer and second
conversion pass.
+ n, count, err := b.getBatchLevelsOneBit(out[i:], maxLevel)
+ i += n
+ maxCount += count
if err != nil {
return i, maxCount, err
}
- if numUnpacked == 0 {
- break
+ } else {
+ for i < length {
+ unpackSize := utils.Min(buflen, length-i)
+ numUnpacked, err := unpack32(b.reader,
b.unpackBuf[:unpackSize], int(bits))
+
+ for k, val := range b.unpackBuf[:numUnpacked] {
+ level := int16(val)
+ out[i+k] = level
+ if level == maxLevel {
+ maxCount++
+ }
+ }
+ i += numUnpacked
+ b.byteoffset += int64(numUnpacked * int(bits) / 8)
+ if err != nil {
+ return i, maxCount, err
+ }
+ if numUnpacked == 0 {
+ break
+ }
}
}
@@ -658,6 +670,64 @@ func (b *BitReader) GetBatchLevels(bits uint, out []int16,
maxLevel int16) (int,
return i, maxCount, nil
}
+// getBatchLevelsOneBit consumes complete groups of 32 packed one-bit levels.
+// It must be called with a byte-aligned reader; the caller decodes trailing
+// values with next. Only complete groups are written on short reads.
+func (b *BitReader) getBatchLevelsOneBit(out []int16, maxLevel int16) (int,
int64, error) {
+ const valuesPerGroup = 32
+ const groupBytes = 4
+ toDecode := len(out) / valuesPerGroup * valuesPerGroup
+ packed := arrow.Uint32Traits.CastToBytes(b.unpackBuf[:])
+ decoded := 0
+ var maxCount int64
+
+ for decoded < toDecode {
+ batch := min(toDecode-decoded, buflen)
+ nread, err := io.ReadFull(b.reader, packed[:batch/8])
+ completeBytes := nread / groupBytes * groupBytes
+ complete := completeBytes * 8
+ dst := out[decoded : decoded+complete]
+
+ for _, value := range packed[:completeBytes] {
+ dst[0] = int16(value & 1)
+ dst[1] = int16((value >> 1) & 1)
+ dst[2] = int16((value >> 2) & 1)
+ dst[3] = int16((value >> 3) & 1)
+ dst[4] = int16((value >> 4) & 1)
+ dst[5] = int16((value >> 5) & 1)
+ dst[6] = int16((value >> 6) & 1)
+ dst[7] = int16(value >> 7)
+ switch maxLevel {
+ case 0:
+ maxCount += int64(8 -
mathbits.OnesCount8(value))
+ case 1:
+ maxCount += int64(mathbits.OnesCount8(value))
+ }
+ dst = dst[8:]
+ }
+
+ decoded += complete
+ b.byteoffset += int64(completeBytes)
+ if err != nil {
+ // ReadFull can consume bytes beyond the last complete
group.
+ // Restore the cursor and scalar buffer so later reads
can
+ // resume at the first undecoded level.
+ if _, seekErr := b.reader.Seek(b.byteoffset,
io.SeekStart); seekErr != nil {
+ return decoded, maxCount, seekErr
+ }
+ if fillErr := b.fillbuffer(); fillErr != nil {
+ return decoded, maxCount, fillErr
+ }
+ // unpack32 uses EOF for short reads ending at a group
boundary.
+ if err == io.ErrUnexpectedEOF && nread%groupBytes == 0 {
+ err = io.EOF
+ }
+ return decoded, maxCount, err
+ }
+ }
+ return decoded, maxCount, nil
+}
+
// GetBatchBitmap fills out with bit-packed boolean values without unpacking
// them into a slice of integers. The input must be packed one bit per value.
func (b *BitReader) GetBatchBitmap(out []byte, outOffset, length int) (int,
error) {
diff --git a/parquet/internal/utils/bit_reader_narrow_levels_test.go
b/parquet/internal/utils/bit_reader_narrow_levels_test.go
new file mode 100644
index 00000000..b7eeebf9
--- /dev/null
+++ b/parquet/internal/utils/bit_reader_narrow_levels_test.go
@@ -0,0 +1,290 @@
+// 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 utils
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "slices"
+ "testing"
+)
+
+// getBatchLevelsBeforeNarrow is the original GetBatchLevels implementation.
+// Keeping it here enables exact before/after comparisons on the same machine.
+func getBatchLevelsBeforeNarrow(b *BitReader, width uint, out []int16,
maxLevel int16) (int, int64, error) {
+ if width > 16 {
+ return 0, 0, fmt.Errorf("must be 16 bits or less per read")
+ }
+ var maxCount int64
+ length := len(out)
+ i := 0
+ for ; i < length && b.bitoffset != 0; i++ {
+ val, err := b.next(width)
+ if err != nil {
+ return i, maxCount, err
+ }
+ level := int16(val)
+ out[i] = level
+ if level == maxLevel {
+ maxCount++
+ }
+ }
+ if _, err := b.reader.Seek(b.byteoffset, io.SeekStart); err != nil {
+ return i, maxCount, err
+ }
+ for i < length {
+ unpackSize := min(buflen, length-i)
+ numUnpacked, err := unpack32(b.reader,
b.unpackBuf[:unpackSize], int(width))
+ for k, val := range b.unpackBuf[:numUnpacked] {
+ level := int16(val)
+ out[i+k] = level
+ if level == maxLevel {
+ maxCount++
+ }
+ }
+ i += numUnpacked
+ b.byteoffset += int64(numUnpacked * int(width) / 8)
+ if err != nil {
+ return i, maxCount, err
+ }
+ if numUnpacked == 0 {
+ break
+ }
+ }
+ if err := b.fillbuffer(); err != nil {
+ return i, maxCount, err
+ }
+ for ; i < length; i++ {
+ val, err := b.next(width)
+ if err != nil {
+ return i, maxCount, err
+ }
+ level := int16(val)
+ out[i] = level
+ if level == maxLevel {
+ maxCount++
+ }
+ }
+ return i, maxCount, nil
+}
+
+func packedLevelFixture(width uint, n int, pattern string) ([]int16, []byte) {
+ values := make([]int16, n)
+ for i := range values {
+ switch pattern {
+ case "defined":
+ values[i] = int16((1 << width) - 1)
+ case "mostly-defined":
+ if i%20 != 0 {
+ values[i] = int16((1 << width) - 1)
+ }
+ case "alternating":
+ values[i] = int16(i % 2)
+ case "varied":
+ values[i] = int16((i*29 + i/5 + i*i/71) % (1 << width))
+ }
+ }
+ packed := make([]byte, (n*int(width)+7)/8)
+ for i, value := range values {
+ bit := i * int(width)
+ for j := uint(0); j < width; j++ {
+ if value&(1<<j) != 0 {
+ packed[(bit+int(j))/8] |= 1 << ((bit + int(j))
% 8)
+ }
+ }
+ }
+ return values, packed
+}
+
+func TestGetBatchLevelsNarrowParity(t *testing.T) {
+ for _, width := range []uint{1, 2} {
+ for _, pattern := range []string{"defined", "mostly-defined",
"alternating", "varied"} {
+ for _, n := range []int{0, 1, 7, 31, 32, 33, 63, 64,
65, 1023, 1024, 1025, 4097} {
+ _, packed := packedLevelFixture(width, n+130,
pattern)
+ for _, prefix := range []int{0, 1, 7, 32, 64} {
+ for _, maxLevel := range []int16{0, 1,
2, 3, 7} {
+ name :=
fmt.Sprintf("width=%d/%s/len=%d/prefix=%d/max=%d", width, pattern, n, prefix,
maxLevel)
+ t.Run(name, func(t *testing.T) {
+ got := make([]int16, n)
+ want := make([]int16, n)
+ for i := range got {
+ got[i] = -123
+ want[i] = -123
+ }
+ r1 :=
NewBitReader(bytes.NewReader(packed))
+ r2 :=
NewBitReader(bytes.NewReader(packed))
+ if prefix != 0 {
+ if err :=
r1.fillbuffer(); err != nil {
+
t.Fatal(err)
+ }
+ if err :=
r2.fillbuffer(); err != nil {
+
t.Fatal(err)
+ }
+ for range
prefix {
+ if _,
err := r1.next(width); err != nil {
+
t.Fatal(err)
+ }
+ if _,
err := r2.next(width); err != nil {
+
t.Fatal(err)
+ }
+ }
+ }
+ gotN, gotCount, gotErr
:= r1.GetBatchLevels(width, got, maxLevel)
+ wantN, wantCount,
wantErr := getBatchLevelsBeforeNarrow(r2, width, want, maxLevel)
+ if gotN != wantN ||
gotCount != wantCount || gotErr != wantErr || !slices.Equal(got, want) {
+ t.Fatalf("got
n=%d count=%d err=%v values=%v; want n=%d count=%d err=%v values=%v",
+ gotN,
gotCount, gotErr, got, wantN, wantCount, wantErr, want)
+ }
+ if r1.byteoffset !=
r2.byteoffset || r1.bitoffset != r2.bitoffset {
+
t.Fatalf("reader offsets differ: byte=%d bit=%d vs byte=%d bit=%d",
+
r1.byteoffset, r1.bitoffset, r2.byteoffset, r2.bitoffset)
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestGetBatchLevelsNarrowShortReads(t *testing.T) {
+ for _, width := range []uint{1, 2} {
+ _, packed := packedLevelFixture(width, 2050, "varied")
+ for _, nbytes := range []int{0, 1, 3, 4, 5, 7, 8, 9, 63, 64,
65, 128, 129, 256} {
+ if nbytes > len(packed) {
+ continue
+ }
+ for _, nvalues := range []int{1, 31, 32, 33, 63, 64,
65, 1024, 2048} {
+
t.Run(fmt.Sprintf("width=%d/bytes=%d/values=%d", width, nbytes, nvalues),
func(t *testing.T) {
+ data := packed[:nbytes]
+ got := make([]int16, nvalues)
+ want := make([]int16, nvalues)
+ for i := range got {
+ got[i], want[i] = -123, -123
+ }
+ r1 :=
NewBitReader(bytes.NewReader(data))
+ r2 :=
NewBitReader(bytes.NewReader(data))
+ gotN, gotCount, gotErr :=
r1.GetBatchLevels(width, got, int16((1<<width)-1))
+ wantN, wantCount, wantErr :=
getBatchLevelsBeforeNarrow(r2, width, want, int16((1<<width)-1))
+ if gotN != wantN || gotCount !=
wantCount || gotErr != wantErr || !slices.Equal(got, want) {
+ t.Fatalf("got n=%d count=%d
err=%v, want n=%d count=%d err=%v",
+ gotN, gotCount, gotErr,
wantN, wantCount, wantErr)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestGetBatchLevelsOneBitResumeAfterPartialGroup(t *testing.T) {
+ const width uint = 1
+ values, packed := packedLevelFixture(width, 2048, "varied")
+ for _, nbytes := range []int{1, 2, 3, 5, 7, 9, 63, 65, 129} {
+ t.Run(fmt.Sprintf("bytes=%d", nbytes), func(t *testing.T) {
+ source := bytes.NewReader(packed[:nbytes])
+ reader := NewBitReader(source)
+ out := make([]int16, 2048)
+ n, maxCount, err := reader.GetBatchLevels(width, out, 1)
+ wantN := nbytes / 4 * 32
+ if n != wantN || err != io.ErrUnexpectedEOF {
+ t.Fatalf("decoded %d, err=%v; want %d and
unexpected EOF", n, err, wantN)
+ }
+ if !slices.Equal(out[:n], values[:n]) {
+ t.Fatalf("decoded levels differ from expected
values")
+ }
+ var wantCount int64
+ for _, level := range values[:n] {
+ if level == 1 {
+ wantCount++
+ }
+ }
+ if maxCount != wantCount {
+ t.Fatalf("max level count=%d; want %d",
maxCount, wantCount)
+ }
+ if reader.byteoffset != int64(wantN/8) ||
reader.bitoffset != 0 {
+ t.Fatalf("reader offset=%d:%d; want %d:0",
reader.byteoffset, reader.bitoffset, wantN/8)
+ }
+ cursor, seekErr := source.Seek(0, io.SeekCurrent)
+ if seekErr != nil || cursor != reader.byteoffset {
+ t.Fatalf("underlying cursor=%d, err=%v; want
%d", cursor, seekErr, reader.byteoffset)
+ }
+
+ // The failed bulk read consumed part of the next group.
+ // Those bytes must still be available to scalar
decoding.
+ for i := n; i < nbytes*8; i++ {
+ value, err := reader.next(width)
+ if err != nil || int16(value) != values[i] {
+ t.Fatalf("subsequent level %d: got %d,
err=%v; want %d", i, value, err, values[i])
+ }
+ }
+ if _, err := reader.next(width); err !=
io.ErrUnexpectedEOF {
+ t.Fatalf("read past available bits: got %v;
want unexpected EOF", err)
+ }
+ })
+ }
+}
+
+func TestGetBatchLevelsNarrowSequentialReads(t *testing.T) {
+ for _, width := range []uint{1, 2} {
+ _, packed := packedLevelFixture(width, 4096, "varied")
+ gotReader := NewBitReader(bytes.NewReader(packed))
+ wantReader := NewBitReader(bytes.NewReader(packed))
+ for _, size := range []int{31, 1, 32, 7, 1024, 33, 2000, 968} {
+ got := make([]int16, size)
+ want := make([]int16, size)
+ gotN, gotCount, gotErr :=
gotReader.GetBatchLevels(width, got, int16((1<<width)-1))
+ wantN, wantCount, wantErr :=
getBatchLevelsBeforeNarrow(wantReader, width, want, int16((1<<width)-1))
+ if gotN != wantN || gotCount != wantCount || gotErr !=
wantErr || !slices.Equal(got, want) {
+ t.Fatalf("width=%d size=%d: got n=%d count=%d
err=%v; want n=%d count=%d err=%v",
+ width, size, gotN, gotCount, gotErr,
wantN, wantCount, wantErr)
+ }
+ }
+ }
+}
+
+func BenchmarkPackedLevelDecode(b *testing.B) {
+ for _, width := range []uint{1, 2} {
+ for _, pattern := range []string{"mostly-defined",
"alternating", "varied"} {
+ for _, size := range []int{1024, 65536} {
+ _, packed := packedLevelFixture(width, size,
pattern)
+ maxLevel := int16((1 << width) - 1)
+ for _, implementation := range
[]string{"before", "after"} {
+
b.Run(fmt.Sprintf("width=%d/%s/n=%d/%s", width, pattern, size, implementation),
func(b *testing.B) {
+ reader :=
NewBitReader(bytes.NewReader(packed))
+ output := make([]int16, size)
+ b.ReportAllocs()
+ b.SetBytes(int64(size * 2))
+ for b.Loop() {
+
reader.Reset(bytes.NewReader(packed))
+ var n int
+ var err error
+ if implementation ==
"before" {
+ n, _, err =
getBatchLevelsBeforeNarrow(reader, width, output, maxLevel)
+ } else {
+ n, _, err =
reader.GetBatchLevels(width, output, maxLevel)
+ }
+ if err != nil || n !=
size {
+
b.Fatalf("decoded %d of %d, err=%v", n, size, err)
+ }
+ }
+ })
+ }
+ }
+ }
+ }
+}