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 ef86032e perf(parquet/pqarrow): decode booleans directly to bitmaps 
(#1182)
ef86032e is described below

commit ef86032eeaf8758438c8cc22bf1e3908060452ca
Author: Minh Vu <[email protected]>
AuthorDate: Tue Aug 18 23:56:51 2026 +0200

    perf(parquet/pqarrow): decode booleans directly to bitmaps (#1182)
    
    ## What does this PR do?
    
    - decodes Parquet BOOLEAN values directly into a packed bitmap
    - supports both PLAIN and RLE boolean pages
    - expands nullable values inside the bitmap without creating a `[]bool`
    - passes the record reader value bitmap directly to the Arrow Boolean
    array
    
    ## Why?
    
    The read path currently stores decoded booleans as one byte per value.
    It then allocates an Arrow bitmap and packs the values again in
    `transferBool`.
    
    This finishes wiring the direct bitmap decoder API from #707 into the
    pqarrow read path. The record reader now keeps boolean values packed
    from decoding through Arrow array construction.
    
    The change also fixes decoder state after a partial PLAIN bitmap decode,
    so the next call resumes at the correct source bit.
    
    ## Benchmarks
    
    Apple M1 Pro, 1M values, uncompressed Parquet, `GOMAXPROCS=1`:
    
    | case | before | after | change | before B/op | after B/op | change |
    | --- | ---: | ---: | ---: | ---: | ---: | ---: |
    | dense | 1.48 ms | 0.15 ms | -90% | 1.61 MB | 0.56 MB | -65% |
    | 50% null | 6.72 ms | 4.63 ms | -31% | 4.11 MB | 3.08 MB | -25% |
    
    ```text
    GOMAXPROCS=1 go test ./parquet/pqarrow -run '^$' -bench 
'^BenchmarkBooleanBitmapRead$' -benchmem -benchtime=500ms -count=5
    ```
    
    ## Tests
    
    - added direct bitmap tests for consecutive PLAIN decode calls
    - added nullable bitmap tests for PLAIN and RLE with unaligned offsets
    - ran `go test ./parquet/...`
    - ran race tests for the touched encoding, file, and pqarrow packages
    - ran `go vet` for the touched packages
---
 parquet/file/record_reader.go                |  92 ++++++++++++++--
 parquet/internal/encoding/boolean_decoder.go | 147 ++++++++++++++++++++++++-
 parquet/internal/encoding/encoding_test.go   | 126 ++++++++++++++++++++++
 parquet/internal/encoding/typed_encoder.go   |   9 ++
 parquet/pqarrow/boolean_bitmap_bench_test.go |  80 ++++++++++++++
 parquet/pqarrow/boolean_bitmap_test.go       | 155 +++++++++++++++++++++++++++
 parquet/pqarrow/column_readers.go            |  31 +++---
 7 files changed, 616 insertions(+), 24 deletions(-)

diff --git a/parquet/file/record_reader.go b/parquet/file/record_reader.go
index 62090443..213883f7 100644
--- a/parquet/file/record_reader.go
+++ b/parquet/file/record_reader.go
@@ -21,7 +21,6 @@ import (
        "fmt"
        "sync"
        "sync/atomic"
-       "unsafe"
 
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
@@ -85,6 +84,16 @@ type RecordReader interface {
        SeekToRow(int64) error
 }
 
+// BooleanRecordReader provides access to the packed value bitmap used for
+// Boolean columns. The existing Values and ReleaseValues methods continue to
+// return one byte per value for compatibility.
+type BooleanRecordReader interface {
+       RecordReader
+       // ReleaseValueBitmap transfers the packed Boolean value bitmap to the
+       // caller. A new buffer will be allocated on subsequent reads.
+       ReleaseValueBitmap() *memory.Buffer
+}
+
 // RecordReaderWithError is implemented by record readers that expose failures
 // while replacing their page reader. It supplements RecordReader without
 // changing the existing no-error SetPageReader method.
@@ -140,6 +149,7 @@ type primitiveRecordReader struct {
        valuesCap     int64
        nullCount     int64
        values        *memory.Buffer
+       legacyValues  *memory.Buffer
        validBits     *memory.Buffer
        mem           memory.Allocator
 
@@ -174,6 +184,10 @@ func (pr *primitiveRecordReader) Release() {
                        pr.values.Release()
                        pr.values = nil
                }
+               if pr.legacyValues != nil {
+                       pr.legacyValues.Release()
+                       pr.legacyValues = nil
+               }
                if pr.validBits != nil {
                        pr.validBits.Release()
                        pr.validBits = nil
@@ -196,8 +210,50 @@ func (pr *primitiveRecordReader) ReleaseValidBits() 
*memory.Buffer {
        return res
 }
 
+func (pr *primitiveRecordReader) ReleaseValueBitmap() *memory.Buffer {
+       res := pr.values
+       res.Resize(int(bitutil.BytesForBits(pr.valuesWritten)))
+       pr.values = memory.NewResizableBuffer(pr.mem)
+       pr.valuesCap = 0
+       if pr.legacyValues != nil {
+               pr.legacyValues.ResizeNoShrink(0)
+       }
+       return res
+}
+
+func (pr *primitiveRecordReader) booleanValues() *memory.Buffer {
+       if pr.legacyValues == nil {
+               pr.legacyValues = memory.NewResizableBuffer(pr.mem)
+       }
+
+       pr.legacyValues.Resize(int(pr.valuesWritten))
+       values := pr.legacyValues.Bytes()
+       bitmap := pr.values.Bytes()
+       if len(bitmap) < int(bitutil.BytesForBits(pr.valuesWritten)) {
+               pr.legacyValues.Resize(0)
+               return pr.legacyValues
+       }
+       for i := range values {
+               if bitutil.BitIsSet(bitmap, i) {
+                       values[i] = 1
+               } else {
+                       values[i] = 0
+               }
+       }
+       return pr.legacyValues
+}
+
 func (pr *primitiveRecordReader) ReleaseValues() (res *memory.Buffer) {
        res = pr.values
+       if pr.Descriptor().PhysicalType() == parquet.Types.Boolean {
+               res = pr.booleanValues()
+               pr.legacyValues = memory.NewResizableBuffer(pr.mem)
+               pr.values.Release()
+               pr.values = memory.NewResizableBuffer(pr.mem)
+               pr.valuesCap = 0
+               return
+       }
+
        nbytes, err := pr.numBytesForValues(pr.valuesWritten)
        if err != nil {
                panic(err)
@@ -217,10 +273,18 @@ func (pr *primitiveRecordReader) IncrementWritten(w, n 
int64) {
 }
 func (pr *primitiveRecordReader) GetValidBits() []byte { return 
pr.validBits.Bytes() }
 func (pr *primitiveRecordReader) ValuesWritten() int64 { return 
pr.valuesWritten }
-func (pr *primitiveRecordReader) Values() []byte       { return 
pr.values.Bytes() }
+func (pr *primitiveRecordReader) Values() []byte {
+       if pr.Descriptor().PhysicalType() == parquet.Types.Boolean {
+               return pr.booleanValues().Bytes()
+       }
+       return pr.values.Bytes()
+}
 func (pr *primitiveRecordReader) ResetValues() {
        if pr.valuesWritten > 0 {
                pr.values.ResizeNoShrink(0)
+               if pr.legacyValues != nil {
+                       pr.legacyValues.ResizeNoShrink(0)
+               }
                pr.validBits.ResizeNoShrink(0)
                pr.valuesWritten = 0
                pr.valuesCap = 0
@@ -229,6 +293,9 @@ func (pr *primitiveRecordReader) ResetValues() {
 }
 
 func (pr *primitiveRecordReader) numBytesForValues(nitems int64) (num int64, 
err error) {
+       if pr.Descriptor().PhysicalType() == parquet.Types.Boolean {
+               return bitutil.BytesForBits(nitems), nil
+       }
        typeSize := int64(pr.Descriptor().PhysicalType().ByteSize())
        var ok bool
        if num, ok = utils.Mul64(nitems, typeSize); !ok {
@@ -264,9 +331,8 @@ func (pr *primitiveRecordReader) ReserveValues(extra int64, 
hasNullable bool) er
 func (pr *primitiveRecordReader) ReadValuesDense(toRead int64) (err error) {
        switch cr := pr.ColumnChunkReader.(type) {
        case *BooleanColumnChunkReader:
-               data := pr.values.Bytes()[int(pr.valuesWritten):]
-               values := *(*[]bool)(unsafe.Pointer(&data))
-               _, err = 
cr.curDecoder.(encoding.BooleanDecoder).Decode(values[:toRead])
+               _, err = 
cr.curDecoder.(encoding.BooleanBitmapDecoder).DecodeToBitmap(
+                       pr.values.Bytes(), pr.valuesWritten, int(toRead))
        case *Int32ColumnChunkReader:
                values := 
arrow.Int32Traits.CastFromBytes(pr.values.Bytes())[int(pr.valuesWritten):]
                _, err = 
cr.curDecoder.(encoding.Int32Decoder).Decode(values[:toRead])
@@ -300,9 +366,8 @@ func (pr *primitiveRecordReader) 
ReadValuesSpaced(valuesWithNulls, nullCount int
 
        switch cr := pr.ColumnChunkReader.(type) {
        case *BooleanColumnChunkReader:
-               data := pr.values.Bytes()[int(pr.valuesWritten):]
-               values := *(*[]bool)(unsafe.Pointer(&data))
-               _, err = 
cr.curDecoder.(encoding.BooleanDecoder).DecodeSpaced(values[:int(valuesWithNulls)],
 int(nullCount), validBits, offset)
+               _, err = 
cr.curDecoder.(encoding.BooleanBitmapDecoder).DecodeSpacedToBitmap(
+                       pr.values.Bytes(), offset, int(valuesWithNulls), 
int(nullCount), validBits, offset)
        case *Int32ColumnChunkReader:
                values := 
arrow.Int32Traits.CastFromBytes(pr.values.Bytes())[int(pr.valuesWritten):]
                _, err = 
cr.curDecoder.(encoding.Int32Decoder).DecodeSpaced(values[:int(valuesWithNulls)],
 int(nullCount), validBits, offset)
@@ -364,6 +429,14 @@ func (b *binaryRecordReader) ReserveData(nbytes int64) {
        b.recordReaderImpl.(binaryRecordReaderImpl).ReserveData(nbytes)
 }
 
+type booleanRecordReader struct {
+       *recordReader
+}
+
+func (b *booleanRecordReader) ReleaseValueBitmap() *memory.Buffer {
+       return b.recordReaderImpl.(*primitiveRecordReader).ReleaseValueBitmap()
+}
+
 func newRecordReader(descr *schema.Column, info LevelInfo, mem 
memory.Allocator, bufferPool *sync.Pool) RecordReader {
        if mem == nil {
                mem = memory.DefaultAllocator
@@ -376,6 +449,9 @@ func newRecordReader(descr *schema.Column, info LevelInfo, 
mem memory.Allocator,
                repLevels:        memory.NewResizableBuffer(mem),
        }
        rr.refCount.Add(1)
+       if descr.PhysicalType() == parquet.Types.Boolean {
+               return &booleanRecordReader{recordReader: rr}
+       }
        return rr
 }
 
diff --git a/parquet/internal/encoding/boolean_decoder.go 
b/parquet/internal/encoding/boolean_decoder.go
index 84924742..61acb3a7 100644
--- a/parquet/internal/encoding/boolean_decoder.go
+++ b/parquet/internal/encoding/boolean_decoder.go
@@ -24,6 +24,7 @@ import (
        "io"
 
        "github.com/apache/arrow-go/v18/arrow/bitutil"
+       "github.com/apache/arrow-go/v18/internal/bitutils"
        shared_utils "github.com/apache/arrow-go/v18/internal/utils"
        "github.com/apache/arrow-go/v18/parquet"
        "github.com/apache/arrow-go/v18/parquet/internal/utils"
@@ -169,7 +170,8 @@ func (dec *PlainBooleanDecoder) DecodeToBitmap(out []byte, 
outOffset int64, leng
                        dstSlice[fullBytes] = (dstSlice[fullBytes] &^ mask) | 
(lastByte & mask)
                }
 
-               dec.data = dec.data[bytesToCopy:]
+               dec.data = dec.data[fullBytes:]
+               dec.bitOffset = trailingBits
                dec.nvals -= max
                return max, nil
        }
@@ -205,6 +207,107 @@ func (dec *PlainBooleanDecoder) DecodeSpaced(out []bool, 
nullCount int, validBit
        return dec.Decode(out)
 }
 
+func copyBitmapChunk(buf []byte, srcOffset, dstOffset, length int) {
+       srcByte, srcBit := srcOffset/8, srcOffset%8
+       value := uint16(buf[srcByte]) >> srcBit
+       if srcBit+length > 8 {
+               value |= uint16(buf[srcByte+1]) << (8 - srcBit)
+       }
+       value &= uint16(1<<length) - 1
+
+       dstByte, dstBit := dstOffset/8, dstOffset%8
+       first := min(length, 8-dstBit)
+       firstMask := byte((1<<first)-1) << dstBit
+       buf[dstByte] = (buf[dstByte] &^ firstMask) | (byte(value) << dstBit & 
firstMask)
+       if first < length {
+               remaining := length - first
+               secondMask := byte((1 << remaining) - 1)
+               buf[dstByte+1] = (buf[dstByte+1] &^ secondMask) | 
(byte(value>>first) & secondMask)
+       }
+}
+
+func copyBitmapWithinBuffer(buf []byte, srcOffset, dstOffset, length int) {
+       if length == 0 || srcOffset == dstOffset {
+               return
+       }
+
+       if dstOffset < srcOffset {
+               for copied := 0; copied < length; {
+                       n := min(length-copied, 8)
+                       copyBitmapChunk(buf, srcOffset+copied, 
dstOffset+copied, n)
+                       copied += n
+               }
+               return
+       }
+
+       for copied := length; copied > 0; {
+               n := min(copied, 8)
+               copied -= n
+               copyBitmapChunk(buf, srcOffset+copied, dstOffset+copied, n)
+       }
+}
+
+func expandSpacedBitmapPerBit(out []byte, outOffset int64, length, nullCount 
int,
+       validBits []byte, validBitsOffset int64) {
+       physicalIndex := 0
+       physicalOffset := int(outOffset) + nullCount
+       for logicalIndex := 0; logicalIndex < length; logicalIndex++ {
+               destination := int(outOffset) + logicalIndex
+               if bitutil.BitIsSet(validBits, 
int(validBitsOffset)+logicalIndex) {
+                       value := bitutil.BitIsSet(out, 
physicalOffset+physicalIndex)
+                       bitutil.SetBitTo(out, destination, value)
+                       physicalIndex++
+               }
+       }
+}
+
+func decodeSpacedToBitmap(dec BooleanBitmapDecoder, out []byte, outOffset 
int64,
+       length, nullCount int, validBits []byte, validBitsOffset int64) (int, 
error) {
+       if nullCount == 0 {
+               return dec.DecodeToBitmap(out, outOffset, length)
+       }
+
+       valuesToRead := length - nullCount
+       valuesRead, err := dec.DecodeToBitmap(out, outOffset+int64(nullCount), 
valuesToRead)
+       if err != nil {
+               return valuesRead, err
+       }
+       if valuesRead != valuesToRead {
+               return valuesRead, errors.New("parquet: boolean decoder: number 
of values / definition levels read did not match")
+       }
+       perBitThreshold := length / 8
+       if length%8 != 0 {
+               perBitThreshold++
+       }
+       if nullCount >= perBitThreshold {
+               expandSpacedBitmapPerBit(out, outOffset, length, nullCount, 
validBits, validBitsOffset)
+               return length, nil
+       }
+
+       // Expand the packed physical values into their logical positions.
+       // Decoding after the null slots means each destination run is at or 
before
+       // its source run, so bitmap copies remain safe while the buffers 
overlap.
+       physicalIndex := int64(0)
+       runs := bitutils.NewSetBitRunReader(validBits, validBitsOffset, 
int64(length))
+       for {
+               run := runs.NextRun()
+               if run.Length == 0 {
+                       break
+               }
+
+               copyBitmapWithinBuffer(out,
+                       int(outOffset+int64(nullCount)+physicalIndex),
+                       int(outOffset+run.Pos), int(run.Length))
+               physicalIndex += run.Length
+       }
+       return length, nil
+}
+
+func (dec *PlainBooleanDecoder) DecodeSpacedToBitmap(out []byte, outOffset 
int64,
+       length, nullCount int, validBits []byte, validBitsOffset int64) (int, 
error) {
+       return decodeSpacedToBitmap(dec, out, outOffset, length, nullCount, 
validBits, validBitsOffset)
+}
+
 type RleBooleanDecoder struct {
        decoder
 
@@ -276,6 +379,43 @@ func (dec *RleBooleanDecoder) Decode(out []bool) (int, 
error) {
        return max, nil
 }
 
+func (dec *RleBooleanDecoder) DecodeToBitmap(out []byte, outOffset int64, 
length int) (int, error) {
+       max := shared_utils.Min(length, dec.nvals)
+       writer := bitutil.NewBitmapWriter(out, int(outOffset), max)
+
+       var (
+               buf [1024]uint64
+               n   = max
+       )
+       for n > 0 {
+               batch := shared_utils.Min(len(buf), n)
+               decoded, err := dec.rleDec.GetBatch(buf[:batch])
+               for _, value := range buf[:decoded] {
+                       if value != 0 {
+                               writer.Set()
+                       } else {
+                               writer.Clear()
+                       }
+                       writer.Next()
+               }
+               n -= decoded
+               if err != nil {
+                       writer.Finish()
+                       dec.nvals -= max - n
+                       return max - n, err
+               }
+               if decoded != batch {
+                       writer.Finish()
+                       dec.nvals -= max - n
+                       return max - n, io.ErrUnexpectedEOF
+               }
+       }
+
+       writer.Finish()
+       dec.nvals -= max
+       return max, nil
+}
+
 func (dec *RleBooleanDecoder) DecodeSpaced(out []bool, nullCount int, 
validBits []byte, validBitsOffset int64) (int, error) {
        if nullCount > 0 {
                toRead := len(out) - nullCount
@@ -290,3 +430,8 @@ func (dec *RleBooleanDecoder) DecodeSpaced(out []bool, 
nullCount int, validBits
        }
        return dec.Decode(out)
 }
+
+func (dec *RleBooleanDecoder) DecodeSpacedToBitmap(out []byte, outOffset int64,
+       length, nullCount int, validBits []byte, validBitsOffset int64) (int, 
error) {
+       return decodeSpacedToBitmap(dec, out, outOffset, length, nullCount, 
validBits, validBitsOffset)
+}
diff --git a/parquet/internal/encoding/encoding_test.go 
b/parquet/internal/encoding/encoding_test.go
index 3afac422..4b527332 100644
--- a/parquet/internal/encoding/encoding_test.go
+++ b/parquet/internal/encoding/encoding_test.go
@@ -1322,6 +1322,132 @@ func TestBooleanPlainDecoderDecodeToBitmapUnaligned(t 
*testing.T) {
        }
 }
 
+func TestBooleanPlainDecoderDecodeToBitmapConsecutiveCalls(t *testing.T) {
+       descr := schema.NewColumn(schema.NewBooleanNode("bool", 
parquet.Repetitions.Optional, -1), 0, 0)
+       enc := encoding.NewEncoder(parquet.Types.Boolean, 
parquet.Encodings.Plain, false, descr, memory.DefaultAllocator)
+       expected := []bool{true, false, true, true, false, false, true, false, 
true, true}
+       enc.(encoding.BooleanEncoder).Put(expected)
+       buf, err := enc.FlushValues()
+       require.NoError(t, err)
+
+       dec := encoding.NewDecoder(parquet.Types.Boolean, 
parquet.Encodings.Plain, descr, memory.DefaultAllocator)
+       require.NoError(t, dec.SetData(len(expected), buf.Buf()))
+       bdec := dec.(encoding.BooleanBitmapDecoder)
+
+       out := make([]byte, bitutil.BytesForBits(16))
+       n, err := bdec.DecodeToBitmap(out, 0, 3)
+       require.NoError(t, err)
+       require.Equal(t, 3, n)
+       n, err = bdec.DecodeToBitmap(out, 8, 7)
+       require.NoError(t, err)
+       require.Equal(t, 7, n)
+
+       for i, value := range expected[:3] {
+               assert.Equal(t, value, bitutil.BitIsSet(out, i))
+       }
+       for i, value := range expected[3:] {
+               assert.Equal(t, value, bitutil.BitIsSet(out, i+8))
+       }
+}
+
+func TestBooleanDecoderDecodeSpacedToBitmap(t *testing.T) {
+       descr := schema.NewColumn(schema.NewBooleanNode("bool", 
parquet.Repetitions.Optional, -1), 0, 0)
+       const validBitsOffset = 3
+       valid := []bool{false, true, true, false, true, true, false, true, 
true, false, true}
+       validBits := make([]byte, 
bitutil.BytesForBits(validBitsOffset+int64(len(valid))))
+       for i, isValid := range valid {
+               if isValid {
+                       bitutil.SetBit(validBits, validBitsOffset+i)
+               }
+       }
+       logicalValues := []bool{false, true, false, false, true, true, false, 
false, true, false, true}
+       physicalValues := make([]bool, 0, len(logicalValues))
+       for i, value := range logicalValues {
+               if valid[i] {
+                       physicalValues = append(physicalValues, value)
+               }
+       }
+
+       for _, encodingType := range 
[]parquet.Encoding{parquet.Encodings.Plain, parquet.Encodings.RLE} {
+               t.Run(encodingType.String(), func(t *testing.T) {
+                       enc := encoding.NewEncoder(parquet.Types.Boolean, 
encodingType, false, descr, memory.DefaultAllocator)
+                       enc.(encoding.BooleanEncoder).Put(physicalValues)
+                       buf, err := enc.FlushValues()
+                       require.NoError(t, err)
+
+                       dec := encoding.NewDecoder(parquet.Types.Boolean, 
encodingType, descr, memory.DefaultAllocator)
+                       require.NoError(t, dec.SetData(len(physicalValues), 
buf.Buf()))
+                       bdec := dec.(encoding.BooleanBitmapDecoder)
+
+                       out := []byte{0xff, 0xff}
+                       n, err := bdec.DecodeSpacedToBitmap(out, 2, 
len(logicalValues),
+                               len(logicalValues)-len(physicalValues), 
validBits, validBitsOffset)
+                       require.NoError(t, err)
+                       require.Equal(t, len(logicalValues), n)
+                       for i, value := range logicalValues {
+                               if valid[i] {
+                                       assert.Equal(t, value, 
bitutil.BitIsSet(out, i+2), "value %d", i)
+                               }
+                       }
+                       assert.True(t, bitutil.BitIsSet(out, 0))
+                       assert.True(t, bitutil.BitIsSet(out, 1))
+               })
+       }
+}
+
+func TestBooleanDecoderDecodeSpacedToBitmapSparseNulls(t *testing.T) {
+       descr := schema.NewColumn(schema.NewBooleanNode("bool", 
parquet.Repetitions.Optional, -1), 0, 0)
+       const (
+               validBitsOffset = 3
+               length          = 32
+               nullIndex       = 11
+               outOffset       = 2
+       )
+
+       logicalValues := make([]bool, length)
+       validBits := make([]byte, bitutil.BytesForBits(validBitsOffset+length))
+       physicalValues := make([]bool, 0, length-1)
+       for i := range logicalValues {
+               logicalValues[i] = i%3 == 0
+               if i != nullIndex {
+                       bitutil.SetBit(validBits, validBitsOffset+i)
+                       physicalValues = append(physicalValues, 
logicalValues[i])
+               }
+       }
+
+       for _, encodingType := range 
[]parquet.Encoding{parquet.Encodings.Plain, parquet.Encodings.RLE} {
+               t.Run(encodingType.String(), func(t *testing.T) {
+                       enc := encoding.NewEncoder(parquet.Types.Boolean, 
encodingType, false, descr, memory.DefaultAllocator)
+                       enc.(encoding.BooleanEncoder).Put(physicalValues)
+                       buf, err := enc.FlushValues()
+                       require.NoError(t, err)
+
+                       dec := encoding.NewDecoder(parquet.Types.Boolean, 
encodingType, descr, memory.DefaultAllocator)
+                       require.NoError(t, dec.SetData(len(physicalValues), 
buf.Buf()))
+                       bdec := dec.(encoding.BooleanBitmapDecoder)
+
+                       out := []byte{0xa5, 0xa5, 0xa5, 0xa5, 0xa5}
+                       before := append([]byte(nil), out...)
+                       n, err := bdec.DecodeSpacedToBitmap(out, outOffset, 
length, 1, validBits, validBitsOffset)
+                       require.NoError(t, err)
+                       require.Equal(t, length, n)
+
+                       for i, value := range logicalValues {
+                               got := bitutil.BitIsSet(out, outOffset+i)
+                               if i != nullIndex {
+                                       assert.Equal(t, value, got, "value %d", 
i)
+                               }
+                       }
+                       for i := 0; i < outOffset; i++ {
+                               assert.Equal(t, bitutil.BitIsSet(before, i), 
bitutil.BitIsSet(out, i), "prefix bit %d", i)
+                       }
+                       for i := outOffset + length; i < len(out)*8; i++ {
+                               assert.Equal(t, bitutil.BitIsSet(before, i), 
bitutil.BitIsSet(out, i), "suffix bit %d", i)
+                       }
+               })
+       }
+}
+
 func TestPlainBooleanEncoderPutSpacedBitmapBasic(t *testing.T) {
        descr := schema.NewColumn(schema.NewBooleanNode("bool", 
parquet.Repetitions.Optional, -1), 0, 0)
        enc := encoding.NewEncoder(parquet.Types.Boolean, 
parquet.Encodings.Plain, false, descr, memory.DefaultAllocator)
diff --git a/parquet/internal/encoding/typed_encoder.go 
b/parquet/internal/encoding/typed_encoder.go
index 3d72ea60..79b887a3 100644
--- a/parquet/internal/encoding/typed_encoder.go
+++ b/parquet/internal/encoding/typed_encoder.go
@@ -56,6 +56,15 @@ type Decoder[T parquet.ColumnTypes] interface {
 }
 
 type BooleanDecoder = Decoder[bool]
+
+// BooleanBitmapDecoder decodes boolean values directly into a packed bitmap.
+type BooleanBitmapDecoder interface {
+       BooleanDecoder
+       DecodeToBitmap(out []byte, outOffset int64, length int) (int, error)
+       DecodeSpacedToBitmap(out []byte, outOffset int64, length, nullCount int,
+               validBits []byte, validBitsOffset int64) (int, error)
+}
+
 type Int32Decoder = Decoder[int32]
 type Int64Decoder = Decoder[int64]
 type Int96Decoder = Decoder[parquet.Int96]
diff --git a/parquet/pqarrow/boolean_bitmap_bench_test.go 
b/parquet/pqarrow/boolean_bitmap_bench_test.go
index 1531e9c3..8309bc68 100644
--- a/parquet/pqarrow/boolean_bitmap_bench_test.go
+++ b/parquet/pqarrow/boolean_bitmap_bench_test.go
@@ -18,6 +18,7 @@ package pqarrow
 
 import (
        "bytes"
+       "context"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -25,6 +26,7 @@ import (
        "github.com/apache/arrow-go/v18/arrow/memory"
        "github.com/apache/arrow-go/v18/parquet"
        "github.com/apache/arrow-go/v18/parquet/compress"
+       "github.com/apache/arrow-go/v18/parquet/file"
 )
 
 // Benchmark writing boolean columns with direct bitmap path
@@ -106,12 +108,90 @@ func benchmarkBooleanWrite(b *testing.B, size int, 
nullable bool) {
        b.SetBytes(int64(size / 8)) // Report bits as bytes for throughput
 }
 
+func BenchmarkBooleanBitmapRead(b *testing.B) {
+       patterns := []struct {
+               name      string
+               nullEvery int
+       }{
+               {name: "dense"},
+               {name: "nullable-1pct", nullEvery: 100},
+               {name: "nullable-5pct", nullEvery: 20},
+               {name: "nullable-10pct", nullEvery: 10},
+               {name: "nullable-50pct", nullEvery: 2},
+       }
+
+       for _, size := range []int{65536, 1000000} {
+               for _, pattern := range patterns {
+                       b.Run(formatSize(size)+"/"+pattern.name, func(b 
*testing.B) {
+                               benchmarkBooleanRead(b, size, pattern.nullEvery)
+                       })
+               }
+       }
+}
+
+func benchmarkBooleanRead(b *testing.B, size, nullEvery int) {
+       mem := memory.NewGoAllocator()
+       arrowSchema := arrow.NewSchema([]arrow.Field{{
+               Name: "bools", Type: arrow.FixedWidthTypes.Boolean, Nullable: 
nullEvery > 0,
+       }}, nil)
+
+       bldr := array.NewBooleanBuilder(mem)
+       defer bldr.Release()
+       for i := 0; i < size; i++ {
+               if nullEvery > 0 && i%nullEvery == 0 {
+                       bldr.AppendNull()
+               } else {
+                       bldr.Append(i%2 == 0)
+               }
+       }
+       arr := bldr.NewBooleanArray()
+       defer arr.Release()
+       rec := array.NewRecordBatch(arrowSchema, []arrow.Array{arr}, 
int64(size))
+       defer rec.Release()
+
+       var buf bytes.Buffer
+       writer, err := NewFileWriter(arrowSchema, &buf,
+               
parquet.NewWriterProperties(parquet.WithCompression(compress.Codecs.Uncompressed)),
+               NewArrowWriterProperties(WithAllocator(mem)))
+       if err != nil {
+               b.Fatal(err)
+       }
+       if err := writer.WriteBuffered(rec); err != nil {
+               b.Fatal(err)
+       }
+       if err := writer.Close(); err != nil {
+               b.Fatal(err)
+       }
+
+       b.ResetTimer()
+       b.ReportAllocs()
+       b.SetBytes(int64(size / 8))
+       ctx := context.Background()
+       for i := 0; i < b.N; i++ {
+               pf, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+               if err != nil {
+                       b.Fatal(err)
+               }
+               reader, err := NewFileReader(pf, ArrowReadProperties{}, mem)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               tbl, err := reader.ReadTable(ctx)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               tbl.Release()
+       }
+}
+
 func formatSize(size int) string {
        switch {
        case size >= 1000000:
                return "1M"
        case size >= 100000:
                return "100K"
+       case size >= 65536:
+               return "64K"
        case size >= 10000:
                return "10K"
        case size >= 1000:
diff --git a/parquet/pqarrow/boolean_bitmap_test.go 
b/parquet/pqarrow/boolean_bitmap_test.go
new file mode 100644
index 00000000..db346b73
--- /dev/null
+++ b/parquet/pqarrow/boolean_bitmap_test.go
@@ -0,0 +1,155 @@
+// 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 pqarrow
+
+import (
+       "bytes"
+       "context"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet"
+       "github.com/apache/arrow-go/v18/parquet/compress"
+       "github.com/apache/arrow-go/v18/parquet/file"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestBooleanBitmapReadAcrossPages(t *testing.T) {
+       const numValues = 128
+       for _, tc := range []struct {
+               name     string
+               encoding parquet.Encoding
+       }{
+               {name: "plain", encoding: parquet.Encodings.Plain},
+               {name: "rle", encoding: parquet.Encodings.RLE},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+
+                       schema := arrow.NewSchema([]arrow.Field{{
+                               Name:     "bools",
+                               Type:     arrow.FixedWidthTypes.Boolean,
+                               Nullable: true,
+                       }}, nil)
+
+                       bldr := array.NewBooleanBuilder(mem)
+                       defer bldr.Release()
+                       for i := 0; i < numValues; i++ {
+                               if i%5 == 0 {
+                                       bldr.AppendNull()
+                               } else {
+                                       bldr.Append(i%2 == 0)
+                               }
+                       }
+                       expected := bldr.NewBooleanArray()
+                       defer expected.Release()
+
+                       var buf bytes.Buffer
+                       writer, err := NewFileWriter(schema, &buf,
+                               parquet.NewWriterProperties(
+                                       
parquet.WithCompression(compress.Codecs.Uncompressed),
+                                       parquet.WithDataPageSize(1),
+                                       parquet.WithEncoding(tc.encoding),
+                               ),
+                               NewArrowWriterProperties(WithAllocator(mem)),
+                       )
+                       require.NoError(t, err)
+
+                       record := array.NewRecordBatch(schema, 
[]arrow.Array{expected}, numValues)
+                       require.NoError(t, writer.WriteBuffered(record))
+                       record.Release()
+                       require.NoError(t, writer.Close())
+
+                       parquetReader, err := 
file.NewParquetReader(bytes.NewReader(buf.Bytes()),
+                               
file.WithReadProps(parquet.NewReaderProperties(mem)))
+                       require.NoError(t, err)
+                       defer parquetReader.Close()
+
+                       reader, err := NewFileReader(parquetReader, 
ArrowReadProperties{}, mem)
+                       require.NoError(t, err)
+
+                       column, err := reader.GetColumn(context.Background(), 0)
+                       require.NoError(t, err)
+                       defer column.Release()
+
+                       got, err := column.NextBatch(numValues)
+                       require.NoError(t, err)
+                       defer got.Release()
+
+                       require.Len(t, got.Chunks(), 1)
+                       require.True(t, array.Equal(expected, got.Chunk(0)))
+               })
+       }
+}
+
+func TestBooleanRecordReaderValuesRemainByteEncoded(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       const numValues = 10
+       schema := arrow.NewSchema([]arrow.Field{{
+               Name:     "bools",
+               Type:     arrow.FixedWidthTypes.Boolean,
+               Nullable: false,
+       }}, nil)
+       want := []byte{1, 0, 1, 1, 0, 0, 1, 0, 1, 1}
+
+       bldr := array.NewBooleanBuilder(mem)
+       defer bldr.Release()
+       for _, value := range want {
+               bldr.Append(value != 0)
+       }
+       expected := bldr.NewBooleanArray()
+       defer expected.Release()
+
+       var buf bytes.Buffer
+       writer, err := NewFileWriter(schema, &buf,
+               
parquet.NewWriterProperties(parquet.WithCompression(compress.Codecs.Uncompressed)),
+               NewArrowWriterProperties(WithAllocator(mem)),
+       )
+       require.NoError(t, err)
+       record := array.NewRecordBatch(schema, []arrow.Array{expected}, 
numValues)
+       require.NoError(t, writer.WriteBuffered(record))
+       record.Release()
+       require.NoError(t, writer.Close())
+
+       parquetReader, err := 
file.NewParquetReader(bytes.NewReader(buf.Bytes()),
+               file.WithReadProps(parquet.NewReaderProperties(mem)))
+       require.NoError(t, err)
+       defer parquetReader.Close()
+
+       pageReader, err := parquetReader.RowGroup(0).GetColumnPageReader(0)
+       require.NoError(t, err)
+       rr := file.NewRecordReader(
+               parquetReader.MetaData().Schema.Column(0),
+               file.LevelInfo{}, arrow.FixedWidthTypes.Boolean, mem, 
parquetReader.BufferPool())
+       defer rr.Release()
+       rr.SetPageReader(pageReader)
+       require.NoError(t, rr.Reserve(numValues))
+       read, err := rr.ReadRecords(numValues)
+       require.NoError(t, err)
+       require.EqualValues(t, numValues, read)
+
+       require.Equal(t, want, rr.Values())
+       values := rr.ReleaseValues()
+       defer values.Release()
+       require.Equal(t, want, values.Bytes())
+}
diff --git a/parquet/pqarrow/column_readers.go 
b/parquet/pqarrow/column_readers.go
index 1ab16bd7..1c175391 100644
--- a/parquet/pqarrow/column_readers.go
+++ b/parquet/pqarrow/column_readers.go
@@ -24,7 +24,6 @@ import (
        "sync"
        "sync/atomic"
        "time"
-       "unsafe"
 
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
@@ -846,26 +845,28 @@ func transferInt(rdr file.RecordReader, dt 
arrow.DataType) arrow.ArrayData {
 }
 
 func transferBool(rdr file.RecordReader) arrow.ArrayData {
-       // TODO(mtopol): optimize this so we don't convert bitmap to []bool 
back to bitmap
        length := rdr.ValuesWritten()
-       data := make([]byte, int(bitutil.BytesForBits(int64(length))))
-       bytedata := rdr.Values()
-       values := *(*[]bool)(unsafe.Pointer(&bytedata))
-
-       for idx, v := range values[:length] {
-               if v {
-                       bitutil.SetBit(data, idx)
-               }
-       }
-
        bitmap := rdr.ReleaseValidBits()
        if bitmap != nil {
                defer bitmap.Release()
        }
-       bb := memory.NewBufferBytes(data)
-       defer bb.Release()
+       var values *memory.Buffer
+       if boolReader, ok := rdr.(file.BooleanRecordReader); ok {
+               values = boolReader.ReleaseValueBitmap()
+       } else {
+               // Keep the bridge compatible with RecordReader implementations 
that do
+               // not expose the packed Boolean fast path.
+               data := make([]byte, int(bitutil.BytesForBits(int64(length))))
+               for idx, value := range rdr.Values()[:length] {
+                       if value != 0 {
+                               bitutil.SetBit(data, idx)
+                       }
+               }
+               values = memory.NewBufferBytes(data)
+       }
+       defer values.Release()
        return array.NewData(&arrow.BooleanType{}, length, []*memory.Buffer{
-               bitmap, bb,
+               bitmap, values,
        }, nil, int(rdr.NullCount()), 0)
 }
 

Reply via email to