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 d65b0db1 perf(parquet/internal/encoding): expand nullable boolean
bitmaps by byte (#1405)
d65b0db1 is described below
commit d65b0db1606c391ea9cce5677780c8cd6f52eb78
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 18:57:34 2026 +0200
perf(parquet/internal/encoding): expand nullable boolean bitmaps by byte
(#1405)
## What
Expand nullable boolean bitmaps eight values at a time.
## Why
The dense-null path currently reads and writes each bit individually.
## Implementation
- Use a 256-byte lookup to deposit packed values into valid positions by
byte.
- Keep the sparse-null path unchanged and preserve overlap and
surrounding bits.
- Add offset and null-pattern parity coverage.
## Benchmark
The per-bit and byte paths both include the initial output-buffer copy.
Go 1.26.3, Apple M1 Pro (`darwin/arm64`), `GOMAXPROCS=1`; 10 runs of 150
ms per case.
| Metric | Before | After | Change |
| --- | ---: | ---: | ---: |
| 64 Ki values, 50% null, offset 0 | 204.39 µs/op | 34.70 µs/op |
-83.03% |
| 64 Ki values, 50% null, offset 5 | 203.34 µs/op | 36.82 µs/op |
-81.89% |
| 64 Ki values, 14.3% null, offset 0 | 302.05 µs/op | 37.53 µs/op |
-87.58% |
| 64 Ki values, 14.3% null, offset 5 | 300.81 µs/op | 37.19 µs/op |
-87.64% |
| 1 Mi values, 50% null, offset 0 | 3.336 ms/op | 555.0 µs/op | -83.36%
|
| 1 Mi values, 50% null, offset 5 | 3.337 ms/op | 585.9 µs/op | -82.44%
|
| 1 Mi values, 14.3% null, offset 0 | 5.112 ms/op | 600.6 µs/op |
-88.25% |
| 1 Mi values, 14.3% null, offset 5 | 5.043 ms/op | 597.1 µs/op |
-88.16% |
| Geometric mean, 8 cases | 1.010 ms/op | 146.1 µs/op | -85.53% |
All cases use 0 B/op and 0 allocs/op. Command: `GOMAXPROCS=1 go test
./parquet/internal/encoding -run '^$' -bench
'^BenchmarkSpacedBitmapExpansion$' -benchmem -benchtime=150ms -count=10
-cpu=1`.
---
.../internal/encoding/boolean_bitmap_deposit.go | 90 ++++++++++++++++++++
.../encoding/boolean_bitmap_deposit_test.go | 96 ++++++++++++++++++++++
parquet/internal/encoding/boolean_decoder.go | 2 +-
3 files changed, 187 insertions(+), 1 deletion(-)
diff --git a/parquet/internal/encoding/boolean_bitmap_deposit.go
b/parquet/internal/encoding/boolean_bitmap_deposit.go
new file mode 100644
index 00000000..4d8dce6d
--- /dev/null
+++ b/parquet/internal/encoding/boolean_bitmap_deposit.go
@@ -0,0 +1,90 @@
+// 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/bits"
+
+// nibbleBitDeposit deposits up to four packed bits into the set positions of
+// a four-bit mask. The table is only 256 bytes and works on every
architecture.
+var nibbleBitDeposit = func() [256]byte {
+ var table [256]byte
+ for mask := 0; mask < 16; mask++ {
+ for packed := 0; packed < 16; packed++ {
+ source := byte(packed)
+ var deposited byte
+ for bit := uint(0); bit < 4; bit++ {
+ if mask&(1<<bit) != 0 {
+ if source&1 != 0 {
+ deposited |= 1 << bit
+ }
+ source >>= 1
+ }
+ }
+ table[mask<<4|packed] = deposited
+ }
+ }
+ return table
+}()
+
+func depositBitmapByte(packed, valid byte) byte {
+ low := valid & 15
+ high := valid >> 4
+ return nibbleBitDeposit[int(low)<<4|int(packed&15)] |
+
nibbleBitDeposit[int(high)<<4|int((packed>>bits.OnesCount8(low))&15)]<<4
+}
+
+// expandSpacedBitmapByByte expands compacted boolean values in-place.
+// Only valid output bits are touched; nulls and bits outside the range are
+// left unchanged. The compressed bits start at outOffset+nullCount, so source
+// bits for the current output byte are always at or after its destination.
+func expandSpacedBitmapByByte(out []byte, outOffset int64, length, nullCount
int,
+ validBits []byte, validBitsOffset int64) {
+ outputPos := int(outOffset)
+ validPos := int(validBitsOffset)
+ physicalPos := outputPos + nullCount
+
+ for remaining := length; remaining > 0; {
+ destShift := outputPos & 7
+ count := min(remaining, 8-destShift)
+
+ validByte := validBits[validPos>>3] >> (validPos & 7)
+ if (validPos&7)+count > 8 {
+ validByte |= validBits[(validPos>>3)+1] << (8 -
(validPos & 7))
+ }
+ validByte &= byte((1 << count) - 1)
+
+ setBits := bits.OnesCount8(validByte)
+ if setBits != 0 {
+ // Read the full packed source segment before writing
the output
+ // byte, which also makes overlapping
source/destination safe.
+ packed := out[physicalPos>>3] >> (physicalPos & 7)
+ if (physicalPos&7)+setBits > 8 {
+ packed |= out[(physicalPos>>3)+1] << (8 -
(physicalPos & 7))
+ }
+
+ deposited := depositBitmapByte(packed, validByte)
+ mask := validByte << destShift
+ byteIndex := outputPos >> 3
+ out[byteIndex] = (out[byteIndex] &^ mask) | (deposited
<< destShift)
+ physicalPos += setBits
+ }
+
+ outputPos += count
+ validPos += count
+ remaining -= count
+ }
+}
diff --git a/parquet/internal/encoding/boolean_bitmap_deposit_test.go
b/parquet/internal/encoding/boolean_bitmap_deposit_test.go
new file mode 100644
index 00000000..ebb46b70
--- /dev/null
+++ b/parquet/internal/encoding/boolean_bitmap_deposit_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 (
+ "bytes"
+ "fmt"
+ "math/rand"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+)
+
+func makeSpacedBitmapCase(length, outOffset, validOffset, nullEvery int)
([]byte, []byte, int) {
+ valid := bytes.Repeat([]byte{0xa5}, (validOffset+length+15)/8)
+ out := bytes.Repeat([]byte{0x3c}, (outOffset+length+15)/8)
+ nulls := 0
+ rng := rand.New(rand.NewSource(23))
+ for i := 0; i < length; i++ {
+ isValid := nullEvery == 0 || (nullEvery > 0 && i%nullEvery !=
0) ||
+ (nullEvery == -1 && rng.Intn(2) == 0)
+ bitutil.SetBitTo(valid, validOffset+i, isValid)
+ if !isValid {
+ nulls++
+ }
+ }
+ for i := 0; i < length-nulls; i++ {
+ bitutil.SetBitTo(out, outOffset+nulls+i, rng.Intn(2) == 0)
+ }
+ return out, valid, nulls
+}
+
+func TestExpandSpacedBitmapByByteParity(t *testing.T) {
+ for _, length := range []int{0, 1, 2, 3, 7, 8, 9, 15, 16, 17, 31, 32,
33, 63, 64, 65, 127, 128, 129, 4096} {
+ for outOffset := 0; outOffset < 8; outOffset++ {
+ for validOffset := 0; validOffset < 8; validOffset++ {
+ for _, nullEvery := range []int{0, 1, 2, 3, 7,
9, -1} {
+ out, valid, nulls :=
makeSpacedBitmapCase(length, outOffset, validOffset, nullEvery)
+ want := bytes.Clone(out)
+ got := bytes.Clone(out)
+ expandSpacedBitmapPerBit(want,
int64(outOffset), length, nulls, valid, int64(validOffset))
+ expandSpacedBitmapByByte(got,
int64(outOffset), length, nulls, valid, int64(validOffset))
+ if !bytes.Equal(got, want) {
+ t.Fatalf("length=%d
outOffset=%d validOffset=%d nullEvery=%d nulls=%d: got %x, want %x",
+ length, outOffset,
validOffset, nullEvery, nulls, got, want)
+ }
+ }
+ }
+ }
+ }
+}
+
+var spacedBitmapBenchmarkSink []byte
+
+func BenchmarkSpacedBitmapExpansion(b *testing.B) {
+ for _, length := range []int{64 * 1024, 1024 * 1024} {
+ for _, nullEvery := range []int{2, 7} {
+ for _, offset := range []int{0, 5} {
+ source, valid, nulls :=
makeSpacedBitmapCase(length, offset, offset+1, nullEvery)
+ for _, implementation := range []struct {
+ name string
+ fn func([]byte, int64, int, int,
[]byte, int64)
+ }{
+ {"baseline_per_bit",
expandSpacedBitmapPerBit},
+ {"byte_deposit",
expandSpacedBitmapByByte},
+ } {
+
b.Run(fmt.Sprintf("length_%d/null_every_%d/offset_%d/%s", length, nullEvery,
offset, implementation.name), func(b *testing.B) {
+ out := make([]byte, len(source))
+ b.ReportAllocs()
+ b.SetBytes(int64(length))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ copy(out, source)
+ implementation.fn(out,
int64(offset), length, nulls, valid, int64(offset+1))
+ }
+ spacedBitmapBenchmarkSink = out
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/parquet/internal/encoding/boolean_decoder.go
b/parquet/internal/encoding/boolean_decoder.go
index a3b51665..69c8ce99 100644
--- a/parquet/internal/encoding/boolean_decoder.go
+++ b/parquet/internal/encoding/boolean_decoder.go
@@ -280,7 +280,7 @@ func decodeSpacedToBitmap(dec BooleanBitmapDecoder, out
[]byte, outOffset int64,
perBitThreshold++
}
if nullCount >= perBitThreshold {
- expandSpacedBitmapPerBit(out, outOffset, length, nullCount,
validBits, validBitsOffset)
+ expandSpacedBitmapByByte(out, outOffset, length, nullCount,
validBits, validBitsOffset)
return length, nil
}