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 ea989eb2 perf(parquet/pqarrow): build nullable fixed-size list
children directly (#1195)
ea989eb2 is described below
commit ea989eb2eba113b70a342815d0022c8bba649e4f
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 19 00:25:47 2026 +0200
perf(parquet/pqarrow): build nullable fixed-size list children directly
(#1195)
## Summary
- keep the existing zero-null fixed-size-list path unchanged
- validate nullable parent spans once
- use the existing slice concatenation for a small number of validity
runs
- build one child array with a typed Take for highly fragmented validity
- avoid one temporary Arrow array per validity run
## Why
Parquet does not write child values for null fixed-size-list parents.
Arrow still needs `list_size` child slots for every parent, so the
reader has to add null child values.
The old nullable path creates one temporary array for each validity run
and concatenates them. Alternating validity can therefore create one
temporary array per parent row.
## Benchmark
Apple M1 Pro, 65,536 `FixedSizeList<int32, 4>` parents:
| validity | old | new | old allocs | new allocs |
| --- | ---: | ---: | ---: | ---: |
| 10% nulls | ~4.4 ms | ~1.3 ms | 85,220 | 46 |
| alternating nulls | ~23–30 ms | ~1.1 ms | 458,770 | 46 |
| one clustered null run | ~0.3 ms | ~0.3 ms | 28 | 27 |
The low-run path is kept for the clustered case, where creating a single
pair of slices is cheaper than building an index array.
## Tests
- `go vet ./parquet/pqarrow`
- `go test -race ./parquet/pqarrow -run
'TestBuildFixedSizeListArray|TestParquetArrowIO/TestFixedSizeList'`
- `PARQUET_TEST_DATA=parquet-testing/data
ARROW_TEST_DATA=arrow-testing/data go test ./...`
---
parquet/pqarrow/column_readers.go | 109 ++++++++++++++++++++++--------
parquet/pqarrow/column_readers_test.go | 117 +++++++++++++++++++++++++++++++++
2 files changed, 198 insertions(+), 28 deletions(-)
diff --git a/parquet/pqarrow/column_readers.go
b/parquet/pqarrow/column_readers.go
index 1c175391..3456fccb 100644
--- a/parquet/pqarrow/column_readers.go
+++ b/parquet/pqarrow/column_readers.go
@@ -573,44 +573,97 @@ func (lr *listReader) buildFixedSizeListArray(length int,
offsets []int32, valid
return arrow.NewChunked(lr.field.Type, []arrow.Array{out}), nil
}
- // Each validity run becomes one piece. Alternating valid and null
parents
- // create O(length) temporary arrays before concatenation.
- pieces := make([]arrow.Array, 0, length)
- defer func() { releaseArrays(pieces) }()
-
- for i := 0; i < length; {
- valid := !lr.field.Nullable ||
bitutil.BitIsSet(validityBuffer.Bytes(), i)
- end := i + 1
- for end < length {
- nextValid := !lr.field.Nullable ||
bitutil.BitIsSet(validityBuffer.Bytes(), end)
- if nextValid != valid {
- break
+ validity := validityBuffer.Bytes()
+ runCount := 0
+ previousValid := false
+ for idx := 0; idx < length; idx++ {
+ valid := !lr.field.Nullable || bitutil.BitIsSet(validity, idx)
+ if valid {
+ if size := offsets[idx+1] - offsets[idx]; size !=
int32(listSize) {
+ return nil, fmt.Errorf("expected all lists to
be of size=%d, but index %d had size=%d", listSize, idx, size)
+ }
+ } else {
+ if size := offsets[idx+1] - offsets[idx]; size != 0 {
+ return nil, fmt.Errorf("null fixed-size list at
index %d consumed %d child values", idx, size)
}
- end++
}
+ if idx == 0 || valid != previousValid {
+ runCount++
+ }
+ previousValid = valid
+ }
- if valid {
- for idx := i; idx < end; idx++ {
- if size := offsets[idx+1] - offsets[idx]; size
!= int32(listSize) {
- return nil, fmt.Errorf("expected all
lists to be of size=%d, but index %d had size=%d", listSize, idx, size)
+ var child arrow.Array
+ var err error
+ // For a small number of runs, concatenating slices avoids materializing
+ // indices. Once the run count is high, the temporary arrays dominate.
+ const minRunsForTake = 1024
+ if runCount < minRunsForTake {
+ pieces := make([]arrow.Array, 0, runCount)
+ defer func() { releaseArrays(pieces) }()
+ for i := 0; i < length; {
+ valid := !lr.field.Nullable ||
bitutil.BitIsSet(validity, i)
+ end := i + 1
+ for end < length {
+ nextValid := !lr.field.Nullable ||
bitutil.BitIsSet(validity, end)
+ if nextValid != valid {
+ break
}
+ end++
}
- pieces = append(pieces, array.NewSlice(item,
int64(offsets[i]), int64(offsets[end])))
- } else {
- for idx := i; idx < end; idx++ {
- if size := offsets[idx+1] - offsets[idx]; size
!= 0 {
- return nil, fmt.Errorf("null fixed-size
list at index %d consumed %d child values", idx, size)
+ if valid {
+ pieces = append(pieces, array.NewSlice(item,
int64(offsets[i]), int64(offsets[end])))
+ } else {
+ pieces = append(pieces,
array.MakeArrayOfNull(lr.rctx.mem, listType.Elem(), (end-i)*listSize))
+ }
+ i = end
+ }
+ if len(pieces) == 0 {
+ pieces = append(pieces,
array.MakeArrayOfNull(lr.rctx.mem, listType.Elem(), 0))
+ }
+ child, err = array.Concatenate(pieces, lr.rctx.mem)
+ } else {
+ childLength := length * listSize
+ indicesBuffer := memory.NewResizableBuffer(lr.rctx.mem)
+ defer indicesBuffer.Release()
+
indicesBuffer.Resize(arrow.Int32Traits.BytesRequired(childLength))
+ indices :=
arrow.Int32Traits.CastFromBytes(indicesBuffer.Bytes())
+
+ indicesValidity := memory.NewResizableBuffer(lr.rctx.mem)
+ defer indicesValidity.Release()
+
indicesValidity.Resize(int(bitutil.BytesForBits(int64(childLength))))
+ clear(indicesValidity.Bytes())
+
+ for i := 0; i < length; {
+ valid := !lr.field.Nullable ||
bitutil.BitIsSet(validity, i)
+ end := i + 1
+ for end < length {
+ nextValid := !lr.field.Nullable ||
bitutil.BitIsSet(validity, end)
+ if nextValid != valid {
+ break
}
+ end++
}
- pieces = append(pieces,
array.MakeArrayOfNull(lr.rctx.mem, listType.Elem(), (end-i)*listSize))
+ if valid {
+ childStart := i * listSize
+ runChildLength := (end - i) * listSize
+ bitutil.SetBitsTo(indicesValidity.Bytes(),
int64(childStart), int64(runChildLength), true)
+ for idx := 0; idx < runChildLength; idx++ {
+ indices[childStart+idx] = offsets[i] +
int32(idx)
+ }
+ }
+ i = end
}
- i = end
- }
- if len(pieces) == 0 {
- pieces = append(pieces, array.MakeArrayOfNull(lr.rctx.mem,
listType.Elem(), 0))
+ indicesData := array.NewData(arrow.PrimitiveTypes.Int32,
childLength,
+ []*memory.Buffer{indicesValidity, indicesBuffer}, nil,
int(nullCount)*listSize, 0)
+ defer indicesData.Release()
+ indicesArr := array.NewInt32Data(indicesData)
+ defer indicesArr.Release()
+
+ ctx := compute.WithAllocator(context.Background(), lr.rctx.mem)
+ child, err = compute.TakeArrayOpts(ctx, item, indicesArr,
compute.TakeOptions{BoundsCheck: false})
}
- child, err := array.Concatenate(pieces, lr.rctx.mem)
if err != nil {
return nil, err
}
diff --git a/parquet/pqarrow/column_readers_test.go
b/parquet/pqarrow/column_readers_test.go
index 1371d6f9..f3aba2d7 100644
--- a/parquet/pqarrow/column_readers_test.go
+++ b/parquet/pqarrow/column_readers_test.go
@@ -19,11 +19,13 @@ package pqarrow
import (
"bytes"
"context"
+ "fmt"
"io"
"testing"
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
"github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
@@ -266,6 +268,121 @@ func
TestBuildFixedSizeListArrayConcatenatesSpecializedChildren(t *testing.T) {
})
}
+func TestBuildFixedSizeListArrayDirectChildren(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ const (
+ length = 2048
+ listSize = 2
+ )
+ validityBytes := make([]byte, bitutil.BytesForBits(length))
+ offsets := make([]int32, length+1)
+ validCount := 0
+ for i := 0; i < length; i++ {
+ offsets[i] = int32(validCount * listSize)
+ if i%2 == 0 {
+ bitutil.SetBit(validityBytes, i)
+ validCount++
+ }
+ }
+ offsets[length] = int32(validCount * listSize)
+
+ builder := array.NewInt32Builder(mem)
+ values := make([]int32, validCount*listSize)
+ for i := range values {
+ values[i] = int32(i)
+ }
+ builder.AppendValues(values, nil)
+ item := builder.NewArray()
+ builder.Release()
+ defer item.Release()
+
+ validity := memory.NewBufferBytes(validityBytes)
+ defer validity.Release()
+ listType := arrow.FixedSizeListOf(listSize, arrow.PrimitiveTypes.Int32)
+ field := arrow.Field{Type: listType, Nullable: true}
+ lr := &listReader{rctx: &readerCtx{mem: mem}, field: &field}
+
+ out, err := lr.buildFixedSizeListArray(length, offsets, validity,
length/2, item)
+ require.NoError(t, err)
+ defer out.Release()
+
+ list := out.Chunk(0).(*array.FixedSizeList)
+ valuesArray := list.ListValues().(*array.Int32)
+ require.Equal(t, length*listSize, valuesArray.Len())
+ for i := 0; i < length; i++ {
+ if i%2 == 0 {
+ assert.True(t, list.IsValid(i))
+ assert.Equal(t, int32((i/2)*listSize),
valuesArray.Value(i*listSize))
+ } else {
+ assert.True(t, list.IsNull(i))
+ assert.True(t, valuesArray.IsNull(i*listSize))
+ }
+ }
+}
+
+func BenchmarkBuildFixedSizeListArray(b *testing.B) {
+ const (
+ length = 1 << 16
+ listSize = 4
+ )
+
+ tests := []struct {
+ name string
+ valid func(int) bool
+ }{
+ {name: "no_nulls", valid: func(int) bool { return true }},
+ {name: "ten_percent_nulls", valid: func(i int) bool { return
i%10 != 0 }},
+ {name: "clustered_half_null", valid: func(i int) bool { return
i < length/2 }},
+ {name: "alternating", valid: func(i int) bool { return i%2 == 0
}},
+ }
+
+ for _, tt := range tests {
+ b.Run(fmt.Sprintf("%s/length=%d", tt.name, length), func(b
*testing.B) {
+ mem := memory.NewGoAllocator()
+ offsets := make([]int32, length+1)
+ validityBytes := make([]byte,
bitutil.BytesForBits(length))
+ validCount := 0
+ for i := 0; i < length; i++ {
+ offsets[i] = int32(validCount * listSize)
+ if tt.valid(i) {
+ bitutil.SetBit(validityBytes, i)
+ validCount++
+ }
+ }
+ offsets[length] = int32(validCount * listSize)
+
+ builder := array.NewInt32Builder(mem)
+ builder.AppendValues(make([]int32,
validCount*listSize), nil)
+ item := builder.NewArray()
+ builder.Release()
+ defer item.Release()
+
+ var validity *memory.Buffer
+ nullCount := int64(length - validCount)
+ if nullCount > 0 {
+ validity = memory.NewBufferBytes(validityBytes)
+ defer validity.Release()
+ }
+
+ listType := arrow.FixedSizeListOf(listSize,
arrow.PrimitiveTypes.Int32)
+ field := arrow.Field{Type: listType, Nullable: true}
+ lr := &listReader{rctx: &readerCtx{mem: mem}, field:
&field}
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ out, err := lr.buildFixedSizeListArray(length,
offsets, validity, nullCount, item)
+ if err != nil {
+ b.Fatal(err)
+ }
+ out.Release()
+ }
+ })
+ }
+}
+
func TestChunkedTableRoundTrip(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
defer mem.AssertSize(t, 0)