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/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 1b50ce30f perf(table): reuse flat primitive Arrow columns during scans
(#2236)
1b50ce30f is described below
commit 1b50ce30fa71b28f1bfcf7433d0af1b616cd6d18
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 19:30:42 2026 +0200
perf(table): reuse flat primitive Arrow columns during scans (#2236)
* perf(table): reuse flat primitive Arrow columns during scans
Signed-off-by: Minh Vu <[email protected]>
* test(table): guard primitive projection fast path against mismatched
columns
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/arrow_scanner.go | 62 +++++++-
table/arrow_scanner_projection_fastpath_test.go | 199 ++++++++++++++++++++++++
2 files changed, 257 insertions(+), 4 deletions(-)
diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index 89c7dfdb2..3a75df002 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -1550,10 +1550,11 @@ func (c *preparedFileReadPlanCache) entry(path string)
*preparedFileReadEntry {
// a scan. The snapshots returned by Metadata are owned by the scan and are
// safe to share read-only across file workers.
type arrowScanInvariants struct {
- tableSchema *iceberg.Schema
- tableProperties iceberg.Properties
- projectedIDs set[int]
- nameMapping iceberg.NameMapping
+ tableSchema *iceberg.Schema
+ tableProperties iceberg.Properties
+ projectedIDs set[int]
+ nameMapping iceberg.NameMapping
+ outputArrowSchema *arrow.Schema
}
// collectLeafIDs recursively collects leaf field IDs from a type
@@ -2460,6 +2461,45 @@ func pruningFilterHasMissingInitialDefault(
return false, nil
}
+// canReuseFlatProjection detects the common case of flat, already-typed
+// batches. Full schema projection still handles nested types, schema
evolution,
+// promotions, defaults, and changed column ordering. A matching Arrow type
alone
+// is not enough: the file field ID and Iceberg type must also agree.
+func canReuseFlatProjection(requested, fileSchema *iceberg.Schema, input,
output *arrow.Schema) bool {
+ if requested == nil || fileSchema == nil || input == nil || output ==
nil {
+ return false
+ }
+
+ fields := requested.FieldsRef(iceinternal.SchemaRef{})
+ if len(fields) != input.NumFields() || len(fields) !=
output.NumFields() {
+ return false
+ }
+
+ for i, field := range fields {
+ if _, ok := field.Type.(iceberg.PrimitiveType); !ok {
+ return false
+ }
+ fileField, ok := fileSchema.FindFieldByIDRef(field.ID,
iceinternal.SchemaRef{})
+ if !ok || fileField.Name != field.Name ||
!fileField.Type.Equals(field.Type) {
+ return false
+ }
+
+ source, target := input.Field(i), output.Field(i)
+ if source.Name != field.Name || target.Name != field.Name ||
+ !arrow.TypeEqual(source.Type, target.Type) ||
len(input.FieldIndices(field.Name)) != 1 {
+ return false
+ }
+ if id, ok := source.Metadata.GetValue(ArrowParquetFieldIDKey);
ok {
+ parsed, err := strconv.Atoi(id)
+ if err != nil || parsed != field.ID {
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
func (as *arrowScan) recordsFromTask(ctx context.Context, task
tblutils.Enumerated[FileScanTask], sink *recordSink, positionalDeleteBitmap
*dv.RoaringPositionBitmap, dvBitmap *dv.RoaringPositionBitmap, eqDeleteSets
[]*equalityDeleteSet, invariants *arrowScanInvariants) (err error) {
defer func() {
if err != nil {
@@ -2588,9 +2628,22 @@ func (as *arrowScan) recordsFromTask(ctx
context.Context, task tblutils.Enumerat
pipeline = append(pipeline, filterFunc)
}
+ var lastInputSchema *arrow.Schema
+ var reuseFlatProjection bool
pipeline = append(pipeline, func(r arrow.RecordBatch)
(arrow.RecordBatch, error) {
defer r.Release()
+ if r.Schema() != lastInputSchema {
+ lastInputSchema = r.Schema()
+ reuseFlatProjection = canReuseFlatProjection(
+ as.projectedSchema, readSchema,
lastInputSchema, invariants.outputArrowSchema)
+ }
+ if reuseFlatProjection {
+ // Rewrap existing columns with the projected field
metadata. Do not
+ // rebuild the arrays or walk the Iceberg schema for
every batch.
+ return
array.NewRecordBatch(invariants.outputArrowSchema, r.Columns(), r.NumRows()),
nil
+ }
+
return ToRequestedSchema(ctx, as.projectedSchema, readSchema,
r, SchemaOptions{
UseLargeTypes: as.useLargeTypes,
TableProperties: invariants.tableProperties,
@@ -2952,6 +3005,7 @@ func (as *arrowScan) GetRecords(ctx context.Context,
tasks []FileScanTask) (*arr
if err != nil {
return nil, nil, err
}
+ invariants.outputArrowSchema = resultSchema
if err := as.addTaskProjectedFieldIDs(invariants, tasks); err != nil {
return nil, nil, err
}
diff --git a/table/arrow_scanner_projection_fastpath_test.go
b/table/arrow_scanner_projection_fastpath_test.go
new file mode 100644
index 000000000..b51f1a5e1
--- /dev/null
+++ b/table/arrow_scanner_projection_fastpath_test.go
@@ -0,0 +1,199 @@
+// 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 table
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "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/iceberg-go"
+ "github.com/stretchr/testify/require"
+)
+
+func projectionPrimitiveSchemas(t testing.TB) (*iceberg.Schema, *arrow.Schema,
*arrow.Schema) {
+ t.Helper()
+ requested := iceberg.NewSchema(1,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "city", Type:
iceberg.PrimitiveTypes.String, Doc: "city label"},
+ )
+ output, err := SchemaToArrowSchemaWithOptions(requested,
ArrowSchemaOptions{})
+ require.NoError(t, err)
+
+ fields := output.Fields()
+ for i := range fields {
+ fields[i].Metadata = arrow.NewMetadata(
+ []string{ArrowParquetFieldIDKey},
[]string{strconv.Itoa(i + 1)})
+ }
+ meta := arrow.NewMetadata([]string{"parquet-source"},
[]string{"source-metadata"})
+ input := arrow.NewSchema(fields, &meta)
+
+ return requested, input, output
+}
+
+func TestCanReuseFlatProjection(t *testing.T) {
+ requested, input, output := projectionPrimitiveSchemas(t)
+ require.True(t, canReuseFlatProjection(requested, requested, input,
output))
+
+ fields := input.Fields()
+ fields[0], fields[1] = fields[1], fields[0]
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "reordered columns must be projected")
+
+ fields = input.Fields()
+ fields[0].Metadata =
arrow.NewMetadata([]string{ArrowParquetFieldIDKey}, []string{"12"})
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "incorrect Arrow field IDs must not bypass projection")
+
+ fields = input.Fields()
+ fields[0].Name = "previous_name"
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "renamed Arrow fields must be projected")
+
+ fields = input.Fields()
+ fields[0].Type = arrow.PrimitiveTypes.Int32
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "mismatched physical Arrow types must not be rewrapped")
+
+ fields = input.Fields()
+ fields[1].Name = fields[0].Name
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "duplicate Arrow field names must not bypass projection")
+
+ fields = append(input.Fields(), arrow.Field{Name: "unexpected", Type:
arrow.PrimitiveTypes.Int32})
+ require.False(t, canReuseFlatProjection(requested, requested,
arrow.NewSchema(fields, nil), output),
+ "extra input columns must still follow schema projection")
+
+ oldName := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "previous_name", Type:
iceberg.PrimitiveTypes.Int64},
+ iceberg.NestedField{ID: 2, Name: "city", Type:
iceberg.PrimitiveTypes.String},
+ )
+ require.False(t, canReuseFlatProjection(requested, oldName, input,
output),
+ "renamed Iceberg fields must be projected")
+
+ oldType := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int32},
+ iceberg.NestedField{ID: 2, Name: "city", Type:
iceberg.PrimitiveTypes.String},
+ )
+ require.False(t, canReuseFlatProjection(requested, oldType, input,
output),
+ "type promotions must be projected")
+
+ missing := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ require.False(t, canReuseFlatProjection(requested, missing, input,
output),
+ "missing fields may need defaults")
+
+ nested := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "struct", Type: &iceberg.StructType{FieldList:
[]iceberg.NestedField{
+ {ID: 2, Name: "value", Type:
iceberg.PrimitiveTypes.Int64},
+ }},
+ })
+ nestedArrow, err := SchemaToArrowSchema(nested, nil, false, false)
+ require.NoError(t, err)
+ require.False(t, canReuseFlatProjection(nested, nested, nestedArrow,
nestedArrow),
+ "nested fields require the full projection visitor")
+}
+
+func TestReuseFlatProjectionPreservesRowsAndMetadata(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+ requested, inputSchema, outputSchema := projectionPrimitiveSchemas(t)
+ builder := array.NewRecordBuilder(mem, inputSchema)
+ defer builder.Release()
+
+ builder.Field(0).(*array.Int64Builder).AppendValues([]int64{7, 8, 9},
nil)
+ cities := builder.Field(1).(*array.StringBuilder)
+ cities.Append("Berlin")
+ cities.AppendNull()
+ cities.Append("Vienna")
+ batch := builder.NewRecordBatch()
+ defer batch.Release()
+
+ require.True(t, canReuseFlatProjection(requested, requested,
batch.Schema(), outputSchema))
+ expected, err := ToRequestedSchema(context.Background(), requested,
requested, batch, SchemaOptions{})
+ require.NoError(t, err)
+ defer expected.Release()
+ got := array.NewRecordBatch(outputSchema, batch.Columns(),
batch.NumRows())
+ defer got.Release()
+
+ require.True(t, array.RecordEqual(expected, got))
+ require.True(t, arrowSchemaEqual(expected.Schema(), got.Schema()))
+ require.Same(t, batch.Column(0), got.Column(0), "input arrays must be
reused")
+ require.Same(t, batch.Column(1), got.Column(1), "input arrays must be
reused")
+ require.Equal(t, 1, got.Column(1).NullN())
+}
+
+func BenchmarkFlatPrimitiveProjection(b *testing.B) {
+ for _, width := range []int{4, 32} {
+ b.Run(fmt.Sprintf("fields=%d", width), func(b *testing.B) {
+ fields := make([]iceberg.NestedField, width)
+ for i := range fields {
+ fields[i] = iceberg.NestedField{
+ ID: i + 1, Name:
fmt.Sprintf("field_%d", i), Type: iceberg.PrimitiveTypes.Int64,
+ }
+ }
+ requested := iceberg.NewSchema(1, fields...)
+ output, err := SchemaToArrowSchema(requested, nil,
false, false)
+ if err != nil {
+ b.Fatal(err)
+ }
+ inputFields := output.Fields()
+ for i := range inputFields {
+ inputFields[i].Metadata = arrow.NewMetadata(
+ []string{ArrowParquetFieldIDKey},
[]string{strconv.Itoa(i + 1)})
+ }
+ builder :=
array.NewRecordBuilder(memory.DefaultAllocator, arrow.NewSchema(inputFields,
nil))
+ defer builder.Release()
+ values := make([]int64, 1024)
+ for i := range values {
+ values[i] = int64(i)
+ }
+ for i := range inputFields {
+
builder.Field(i).(*array.Int64Builder).AppendValues(values, nil)
+ }
+ batch := builder.NewRecordBatch()
+ defer batch.Release()
+ if !canReuseFlatProjection(requested, requested,
batch.Schema(), output) {
+ b.Fatal("expected zero-copy projection")
+ }
+
+ b.Run("schema_visitor", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ out, err :=
ToRequestedSchema(context.Background(), requested, requested, batch,
SchemaOptions{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ out.Release()
+ }
+ })
+ b.Run("zero_copy", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ out := array.NewRecordBatch(output,
batch.Columns(), batch.NumRows())
+ out.Release()
+ }
+ })
+ })
+ }
+}